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
この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
0 件のコメント:
コメントを投稿