2023年12月27日

文字列の表示幅を取得する

前回Skia4DelphiのISkUnicodeを使って文字列を書記素クラスタ(grapheme cluster)に分割する方法を扱いましたが、(等幅フォントでの表記を前提として)文字列が何文字分の幅を占めるのか(いわゆる"表示幅")を取得するためには、それぞれの書記素クラスタ(の基底文字)がいわゆる"半角幅"(halfwidth、1/2 Em)なのか"全角幅"(fullwidth、1 Em)なのかを知る必要があります(Emは文字の高さを基準にした単位で、1/2 Emは高さの半分の文字幅、1 Emは高さと同じ文字幅になる、という意味)。これに関するUnicodeの規格がUnicode Standard Annex #11 East Asian Width(UAX #11)になります。

UAX #11では既存の実装に配慮して、文字をその占める幅によって
  • Fullwidth("F"/全角)
  • Halfwidth ("H"/半角)
  • Wide ("W"/広)
  • Narrow ("Na"/狭)
  • Ambiguous ("A"/曖昧)
  • Neutral ("N"/中立)(Not East Asian)
に分類しています。"F"は全角英数などUnicodeの規格上"FULLWIDTH"とされるもの、"H"は半角カナなど"HALFWIDTH"とされるもの、"W"はJISの漢字や東アジアの組版専用の句読点など文字幅が1 Emで扱われてきたもの、"Na"は半角英数など"F"や"W"に対応する全角文字が存在するもの、"A"はJISのギリシャ文字やキリル文字のように東アジアでは文字幅が1 Emで扱われるもの、"N"はそれ以外のもの、となり、"F"と"W"は全角幅、"H"、"Na"、"N"は半角幅として扱います。ここで問題になるのが"A"で、これは組版の文脈(≒使われるフォント)によって、全角幅か半角幅かのどちらかになります(例えばギリシャ文字やキリル文字はMS Gothicのような日本語のフォントでは全角幅に、Consolasのような欧文のフォントでは半角幅になる)。

では実際にどの文字(コードポイント)がどの分類になるのか、ですが、これは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

0 件のコメント: