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

2023年12月25日

文字列を書記素クラスタで分割する

このアーティクルはDelphi Advent Calendar 2023の25日目の記事です(12日ぶり11回目)。

Unicodeの世界では、1つの"文字"(書記素、grapheme)が1つのコードポイント(code point)で表されるとは限りません。サロゲートペアとか結合文字とか絵文字とか、文字列に入っているものがどれくらいの"文字数"になっているのかを知るのは意外に難しい話です。
現在一般的と考えられる方法として、がありますが、ICUはインタフェースがUTF-8ベースでDelphiからは使いにくく、Delphiの正規表現ライブラリはPCRE(1) 8.45ベースでだいぶ古くてUnicodeの新しいバージョンには対応していません(RSP-42524)。また自前での実装は相当面倒なうえに、Unicodeのバージョンアップに追従していくのが大変です。
ところがDelphi 12で標準サポートされたSkia4Delphiでは簡単に文字列を書記素クラスタで分割できるようになりました(ICUを取り込んでいるようです)。試しに正規表現(PCRE)による分割と比較してみましょう。
なおDelphi 11 Alexandriaおよびそれ以前のバージョンではSkia4Delphiをインストールする必要があります(現時点(2023/12)の最新は6.0.0)。

新規プロジェクトを作成し、フォーム(フォントサイズを大きめにしておくと結果が見やすくなります)にTEditを1つ、TButtonを2つ、結果表示のためのTMemoを1つ配置します。またプロジェクトツールウィンドウでプロジェクトを右クリック→Skiaを有効化を選択して、実行ファイルと同じ場所にsk4d.dllが配置されるようにします。
usesにSystem.Skiaを追加し、フォームのOnShowイベントで
procedure TForm1.FormShow(Sender: TObject);
begin
  Edit1.Text := #$20BB7 + '野屋のコピペ' +
                #$00E5 + #$00E1 + #$0302 + #$0303 + #$0304 +
                #$1F62D +
                #$1F937 + #$1F3FD + #$200D + #$2640 + #$FE0F +
                #$1F468 + #$200D + #$1F469 + #$200D + #$1F467 + #$200D + #$1F466 +
                #$1F469 + #$1F3FD + #$200D + #$1F4BB +
                #$1F1EF + #$1F1F5;
end;
とEdit1.Textにサンプル文字列を設定し、Button1のOnClickイベントで
procedure TForm1.Button1Click(Sender: TObject);
begin
  for var Match in TRegEx.Matches(Edit1.Text,'\X') do
  begin
    Memo1.Lines.Add(Match.Value);
  end;
end;
Button2のOnClickイベントで
procedure TForm1.Button2Click(Sender: TObject);
var
  SkUnicode: ISkUnicode;
begin
  SkUnicode := TSkUnicode.Create;
  for var S in SkUnicode.GetBreaks(Edit1.Text,TSkBreakType.Graphemes) do
  begin
    Memo1.Lines.Add(S);
  end;
end;
とします。では実行してみましょう。まず正規表現です。
𠮷
野
屋
の
コ
ピ
ペ
å
á̂̃̄
😭
🤷
🏽‍
♀️
👨‍
👩‍
👧‍
👦
👩
🏽‍
💻
🇯🇵
(Windows上の表示とは若干異なります)
正規表現による分割では絵文字のZWJ(ゼロ幅接合子)による結合はうまく処理できないようです。これはPCRE 8.45がだいぶ古いバージョンで、最新のUnicodeに対応できていないことが原因と考えられます。

次にSkia4Delphiです。
𠮷
野
屋
の
コ
ピ
ペ
å
á̂̃̄
😭
🤷🏽‍♀️
👨‍👩‍👧‍👦
👩🏽‍💻
🇯🇵
一方でSkia4Delphiによる分割は正しく処理できていますね。ちなみにWindowsでは国旗の絵文字は絵文字としてではなくRegional indicator symbol2文字での表現になるようです(サンプル最後の"🇯🇵"が"JP"となる)。

書記素クラスタによる分割は文字列のレンダリングに必須なためSkia(Skia4Delphi)に実装されていると考えられますが、プログラムで書記素クラスタによる分割が必要になるのは(帳票などで)文字列がどのくらいの幅を占めるかを知りたいときではないでしょうか。そのためには書記素クラスタによる分割だけでなく、Unicode Standard Annex #11 East Asian Widthも必要になります。これはDelphi標準のSystem.CharacterユニットにもSkia4Delphiにも実装されておらず、EastAsianWidth.txtを取り込んで自前で実装する必要があります。これについては後日別のアーティクルで扱うことにします。

Skia4Delphを使って見やすくしたバージョンをGistに上げてあります。

2023年12月13日

Microsoft Monthly Update 2023/12

今日はMicrosoftのセキュリティアップデートの日です。
2023 年 12 月のセキュリティ更新プログラム - リリース ノート - セキュリティ更新プログラム ガイド - Microsoft
2023 年 12 月のセキュリティ更新プログラム (月例) | MSRC Blog | Microsoft Security Response Center

TSqidsEncodingで文字列を難読化する

このアーティクルはDelphi Advent Calendar 2023の12日目の記事です(2日ぶり10回目)。

前回はDelphi 12 Athensで導入されたTSqidsEncodingを普通に使ってみましたが、TSqidsEncodingにはTArray<Integer>を扱うoverloadがあり、今回はそれを使って文字列を難読化してみます。
とはいっても、単にDelphiの文字列(UTF-16)の1文字を単純にUInt16(=Word)として扱うだけです。
uses
  ..., System.NetEncoding.Sqids;

type
  TSqidsEncodingHelper = class helper for TSqidsEncoding
  public
    function EncodeFromString(const AStr: String): String;
    function DecodeToString(const AHash: String): String;
  end;

function TSqidsEncodingHelper.EncodeFromString(const AStr: String): String;
var
  Values: TArray;
  I: Integer;
begin
  SetLength(Values,Length(AStr));
  for I := 0 to Length(AStr) - 1 do
  begin
    Values[I] := UInt16(AStr[I + 1]);
  end;
  Result := Encode(Values);
end;

function TSqidsEncodingHelper.DecodeToString(const AHash: String): String;
var
  Values: TArray;
  I: Integer;
begin
  Values := Decode(AHash);

  SetLength(Result,Length(Values));
  for I := 0 to Length(Result) - 1 do
  begin
    Result[I + 1] := Char(Values[I]);
  end;
end;
文字列の難読化としてXORやROT13などがよく使われますが、このほうがまだましな気がしますね。

2023年12月10日

TSqidsEncodingで数値と短縮IDを相互変換する

このアーティクルはDelphi Advent Calendar 2023の10日目の記事です(1年ぶり9回目)。

Delphi 12 Athensで数値と短縮IDを相互変換するSqidsを実装したTSqidsEncodingが追加されました。
Sqidsはあくまで難読化であって暗号化ではないので、内容を隠すことはできませんが、ID番号などをそのまま見せるよりはまし、というような場合に有効です。Sqidsの公式サイトでは、適しているケースとして
  • 短縮リンク
    • URLで安全に使用できる
  • イベントID
    • 衝突しないエンコード/デコード
  • ワンタイムパスワード
    • 短く問題のあるワードを含まない
適していないケースとして
  • 機密データ
    • 暗号化されるわけではない
  • ユーザID
    • デコードすることでユーザ数が漏洩する
を挙げています。

使用方法は簡単で、エンコードするには
uses
  ..., System.NetEncoding.Sqids;

var
  Sqids: TSqidsEncoding;
begin
  Sqids := TSqidsEncoding.Create;
  try
    Edit2.Text := Sqids.Encode(Edit1.Text);

  finally
    Sqids.Free;
  end;
end;
デコードするには
var
  Sqids: TSqidsEncoding;
begin
  Sqids := TSqidsEncoding.Create;
  try
    Edit3.Text := Sqids.DecodeToStr(Edit2.Text);

  finally
    Sqids.Free;
  end;
end;
とするだけです。Sqidsのソースは数値ですが、エンコードではInreger、TArray<Integer>、Stringを受け取るoverloadが用意されています(Stringは単独またはカンマ区切りの数値表現)。
またデコードはTArray<Integer>を返すもの(Decode)、Integerを返すもの(DecodeSingle/TryDecodeSingle)、Stringを返すもの(DecodeToStr)が用意されています。
さらにコンストラクタにエンコード用の文字列を渡すことで生成される文字列をカスタマイズすることもできます。

2023/12/22追記: 公式のblogにもSqidsの使いかたの記事が出ました。
Sqids: RAD Serverとの統合およびスタンドアロンライブラリ (en)