UAX #11では既存の実装に配慮して、文字をその占める幅によって
- Fullwidth("F"/全角)
- Halfwidth ("H"/半角)
- Wide ("W"/広)
- Narrow ("Na"/狭)
- Ambiguous ("A"/曖昧)
- Neutral ("N"/中立)(Not East Asian)
では実際にどの文字(コードポイント)がどの分類になるのか、ですが、これはUnicodeデータベースの一部としてhttps://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txtにリストされています。基本的にこれを何らかの形で配列化しておいて参照すればいいのですが、Unicode(UCS4)で扱えるコードポイントは最大でU+0000からU+10FFFFの1,114,112個あり、単純にテーブル化すると約1MBになってしまいます。しかしプレーン4~13は未割当で定義も存在しません(この場合はデフォルトで"N"である(
All code points, assigned or unassigned, that are not listed explicitly are given the value "N".)と明記されています)。そこで今回はプレーン毎に分割して、フルマッピングするプレーンと1つの値で代表させるプレーン、という形で領域を節約した実装を考えてみます。
まずはUAX #11で規定されている分類を列挙型として定義し、レコードヘルパでそれぞれに対応する文字幅を返すようにします。
type
TEastAsianWidth = (Neutral, // N
Fullwidth, // F
Halfwidth, // H
Wide, // W
Narrow, // Na
Ambiguous); // A
TEastAsianWidthHelper = record helper for TEastAsianWidth
private
const
Width: array [Boolean,TEastAsianWidth] of Integer =
((1, // Neutral
2, // Fullwidth
1, // Halfwidth
2, // Wide
1, // Narrow
1), // Ambiguous (same as Neutral)
(1, // Neutral
2, // Fullwidth
1, // Halfwidth
2, // Wide
1, // Narrow
2)); // Ambiguous (same as Fullwidth)
private
class var
FEastAsian: Boolean;
public
function GetWidth: Integer; overload; inline;
class property EastAsian: Boolean read FEastAsian write FEastAsian;
end;
function TEastAsianWidthHelper.GetWidth: Integer;
begin
Result := Width[FEastAsian,Self];
end;
ここでクラスプロパティTEastAsianWidth.EastAsianは"A"の扱いを決めるもので、Trueなら東アジアの組版(フォントが日本語など)、Falseなら欧文の組版(フォントが欧文)であることを示します。次にEastAsianWidth.txtをテーブル化します。まずそれぞれのプレーンを格納するテーブルのレコード型と、このテーブルへのポインタと代表値をセットにしたレコード型を定義します。
type
TPlaneData = packed record
public
Data: array [0..65535] of Byte;
end;
PPlaneData = ^TPlaneData;
TPlane = record
PlaneDefault: Byte;
PlaneData: PPlaneData;
end;
このTPlaneの配列(0..16)を今回はEastAsianWidth.txtから生成しますが、とりあえず仮に
const
Plane0: TPlaneData = (Data: (
$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,
...
$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00,$00));
Plane0Default = $00;
Plane1Default = $00;
...
Plane16Default = $00;
Planes: array [0..16] of TPlane =
((PlaneDefault: Plane0Default; PlaneData: @Plane0),
(PlaneDefault: Plane1Default; PlaneData: nil),
...
(PlaneDefault: Plane16Default; PlaneData: nil));
こんな定数定義があるものとします。これを使用する形でレコードヘルパTEastAsianWidthHelperにクラスメソッドGetEastAsianWidthを追加します。
uses
System.SysUtils, System.RTLConsts;
type
TEastAsianWidthHelper = record helper for TEastAsianWidth
public
...
class function GetEastAsianWidth(C: UCS4Char): TEastAsianWidth; static;
end;
class function TEastAsianWidthHelper.GetEastAsianWidth(C: UCS4Char): TEastAsianWidth;
var
PlaneNum: Integer;
Plane: TPlane;
B: Byte;
begin
PlaneNum := (C shr 16) and $FFFF;
if (PlaneNum < Low(Planes)) and (PlaneNum > High(Planes)) then
begin
raise EArgumentOutOfRangeException.CreateRes(@SArgumentOutOfRange);
end;
Plane := Planes[PlaneNum];
if Plane.PlaneData <> nil then
begin
B := Plane.PlaneData^.Data[C and $FFFF];
end
else
begin
B := Plane.PlaneDefault;
end;
Result := TEastAsianWidth(B);
end;
クラスメソッドGetEastAsianWidthでは、プレーン毎のテーブルがあればそこから、テーブルがなければ代表値を取得し、列挙型TEastAsianWidthとして返します。あとはUnicodeのバージョンアップに簡単に追随できるようにするために、EastAsianWidth.txtから上記のテーブル部分を自動生成する処理を別ユニットに作っていきます。
まず読み込んだプレーン毎のデータを格納するレコード型と、関係するメソッド(初期化、値の指定、代表値だけでテーブルを省略可能かどうかの判定)を用意します。
type
TPlaneData = record
Table: array [$0000..$FFFF] of Byte;
procedure Init;
procedure Fill(StartIndex: UInt16; EndIndex: UInt16; Data: Byte);
function IsAllSame: Boolean;
end;
procedure TPlaneData.Init;
var
I: Integer;
begin
for I := Low(Table) to High(Table) do
begin
Table[I] := 0; // 'Neutral'
end;
end;
procedure TPlaneData.Fill(StartIndex: UInt16; EndIndex: UInt16; Data: Byte);
var
I: Integer;
begin
for I := StartIndex to EndIndex do
begin
Table[I] := Data;
end;
end;
function TPlaneData.IsAllSame: Boolean;
var
I: Integer;
B: Byte;
begin
B := Table[Low(Table)];
for I := Low(Table) + 1 to High(Table) do
begin
if Table[I] <> B then
begin
Result := False;
Exit;
end;
end;
Result := True;
end;
これを使ってUCS4のコード範囲全体を扱うレコード型を用意します。
type
TEastAsianWidthProperties = record
Planes: array [0..16] of TPlaneData;
procedure Init;
end;
PEastAsianWidthProperties = ^TEastAsianWidthProperties;
procedure TEastAsianWidthProperties.Init;
var
I: Integer;
begin
for I := Low(Planes) to High(Planes) do
begin
Planes[I].Init;
end;
end;
あとはEastAsianWidth.txtを解析して値を格納し、テーブル化するメソッドを用意します。
uses
System.Classes, System.SysUtils, System.RegularExpressions, System.SysConst;
function SymbolToEastAsianWidth(const S: String): Byte;
const
EastAsianWidthSymbols: array [0..5] of String =
('N',
'F',
'H',
'W',
'Na',
'A');
begin
for Result := Low(EastAsianWidthSymbols) to High(EastAsianWidthSymbols) do
begin
if EastAsianWidthSymbols[Result] = S then
begin
Exit;
end;
end;
raise EConvertError.CreateRes(@SRangeError);
end;
procedure ConvertEastAsianWidth(Input, Output: TStrings);
var
PEAWP: PEastAsianWidthProperties;
RegEx1: TRegEx;
RegEx2: TRegEx;
Match: TMatch;
I: Integer;
S: String;
Position: Integer;
StartCodePoint: String;
EndCodePoint: String;
Name: String;
Plane: UInt16;
CPLow: UInt16;
CPLow2: UInt16;
PlaneDefault: Byte;
begin
New(PEAWP);
try
PEAWP^.Init;
RegEx1 := TRegEx.Create('([0-9A-Fa-f]{4,6})[.][.]([0-9A-Fa-f]{4,6})\s*[;]\s*(.{1,2})\s*');
RegEx2 := TRegEx.Create('([0-9A-Fa-f]{4,6})\s*[;]\s*(.{1,2})\s*');
for I := 0 to Input.Count - 1 do
begin
S := Input.Strings[I];
Position := Pos('#',S);
if Position > 0 then
begin
Delete(S,Position,Length(S));
end;
S := Trim(S);
if S = '' then
begin
Continue;
end;
Match := RegEx1.Match(S);
if Match.Success = True then
begin
StartCodePoint := Match.Groups[1].Value;
EndCodePoint := Match.Groups[2].Value;
Name := Match.Groups[3].Value;
end
else
begin
Match := RegEx2.Match(S);
if Match.Success = True then
begin
StartCodePoint := Match.Groups[1].Value;
EndCodePoint := StartCodePoint;
Name := Match.Groups[2].Value;
end;
end;
if Match.Success = True then
begin
Plane := (StrToInt64('$' + StartCodePoint) shr 16) and $FFFF;
if Plane <= 16 then
begin
CPLow := (StrToInt64('$' + StartCodePoint)) and $FFFF;
CPLow2 := (StrToInt64('$' + EndCodePoint)) and $FFFF;
PEAWP^.Planes[Plane].Fill(CPLow,CPLow2,Ord(SymbolToEastAsianWidth(Name)));
end;
end;
end;
for Plane := Low(PEAWP^.Planes) to High(PEAWP^.Planes) do
begin
Output.Add(Format(' { Plane%d }',[Plane]));
if PEAWP^.Planes[Plane].IsAllSame = False then
begin
Output.Add(Format(' Plane%d: TPlaneData = (Data: (',[Plane]));
S := '';
for I := Low(PEAWP^.Planes[Plane].Table) to High(PEAWP^.Planes[Plane].Table) do
begin
if (I mod 16) = 0 then
begin
S := ' ';
end;
S := S + Format('$%.02X,',[PEAWP^.Planes[Plane].Table[I]]);
if (I mod 16) = 15 then
begin
if I = High(PEAWP^.Planes[Plane].Table) then
begin
Delete(S,Length(S),1);
S := S + '));';
end
else
begin
S := S + ' ';
end;
S := S + Format(' // U+%.4X',[(Plane shl 16) or (I and $FFF0)]);
Output.Add(S);
S := '';
end;
end;
PlaneDefault := 0;
end
else
begin
PlaneDefault := PEAWP^.Planes[Plane].Table[0];
end;
Output.Add(Format(' Plane%dDefault = $%.2X;',[Plane,PlaneDefault]));
Output.Add('');
end;
Output.Add(' { Plane data table }');
Output.Add(' Planes: array [0..16] of TPlane =');
for Plane := Low(PEAWP^.Planes) to High(PEAWP^.Planes) do
begin
if PEAWP^.Planes[Plane].IsAllSame = True then
begin
S := Format(' (PlaneDefault: Plane%dDefault; PlaneData: nil),',[Plane]);
end
else
begin
S := Format(' (PlaneDefault: Plane%dDefault; PlaneData: @Plane%d),',[Plane,Plane]);
end;
if Plane = Low(PEAWP^.Planes) then
begin
S[5] := '(';
end
else if Plane = High(PEAWP^.Planes) then
begin
Delete(S,Length(S),1);
S := S + ');';
end;
Output.Add(S);
end;
finally
Dispose(PEAWP);
end;
end;
レコード型TEastAsianWidthPropertiesはサイズが大きく、デフォルトの設定ではスタックオーバフローするため、New/Disposeでヒープ上に確保するようにしています。読み込んだEastAsianWidth.txtの各行が
0000..001F ; N # Cc [32] <control-0000>..
0020 ; Na # Zs SPACE
このようになっているものを、正規表現で複数指定、単独指定のどちらかのパターンにマッチングさせて、コードポイント(の範囲)と属性を取り込んでテーブルに格納し、最後にテーブルの内容をDelphiのコードの一部として出力しています。このConvertEastAsianWidthを使ってEastAsianWidth.txtをEastAsianWidth.incとして変換したら、上記のTPlaneの配列部分を
const
{$I 'EastAsianWidth.inc'}
と置き換えれば完成です。これで前回のサンプルにチェックボックスを1つ追加して、
procedure TForm1.Button2Click(Sender: TObject);
var
SkUnicode: ISkUnicode;
L: Integer;
TotalL: Integer;
W: Integer;
TotalW: Integer;
begin
TEastAsianWidth.EastAsian := CheckBox1.Checked;
SkLabel1.Words.Items[0].Caption := Edit1.Text;
Memo1.Lines.Clear;
SkLabel2.Words.Clear;
TotalL := 0;
TotalW := 0;
SkUnicode := TSkUnicode.Create;
for var S in SkUnicode.GetBreaks(Edit1.Text,TSkBreakType.Graphemes) do
begin
L := Length(S);
W := TEastAsianWidth.GetEastAsianWidth(Char.ConvertToUtf32(S,0)).GetWidth;
Memo1.Lines.Add(S + Format(' (L=%d,W=%d)',[L,W]));
SkLabel2.Words.Add(S + sLineBreak);
TotalL := TotalL + L;
TotalW := TotalW + W;
end;
Memo1.Lines.Add(Format('Total: L=%d, W=%d',[L,W]));
end;
こんな感じで全体の文字数を得ることができるようになります。チェックボックスのチェック状態で"á̂̃̄"のWが変化するのがわかりますね。誰ですか、絵文字が混ざると意味がないじゃないか、とかいう人は!そう、絵文字がZWJ(ゼロ幅接合子)を使ってグリフを増やすようになったあたりから、実際にレンダリングしてみないとどのくらいの表示幅になるのかはわからなくなっているのです。
最終的なプロジェクト全体をGitHubに上げてあります。今後Unicodeのバージョンが上がっても、更新されたEastAsianWidth.txtを取り込むことで最新のものに準拠することができます。
参考: 東アジアの文字幅 - Wikipedia