Technical Article

HotPDFを使ったDelphiにおけるPDFテキストの両端揃え

両端揃え(ジャスティフィケーション)とは、テキストの列を左端と右端の両方で揃えるレイアウトであり、印刷された書籍や正式なレポートで期待される外観です。説明は簡単ですが、驚くほど間違いやすいです。なぜなら「余分なスペースはどこへ行くのか」という問いへの答えは、英語と日本語では異なるからです。また、各行を計測する素朴な方法は、高速なページを低速にしてしまいます。HotPDFは単一のボックスレイアウト呼び出しを通じてスクリプト対応の両端揃えを提供し、その呼び出しの内部には独立して理解する価値のある教科書的なパフォーマンス改善が含まれています

この記事では両方を解説します。まず、単語間スペースを持つスクリプトとそうでないスクリプトにおけるスラック(余白)の分配を決めるタイポグラフィのルールです。次に、出力に視覚的な差異を生じさせることなくページごとの両端揃えコストを約80倍削減した計測の改善です。大量のドキュメントを生成する場合に、モノスペースの出力を引き伸ばしたものではなく、本格的な組版のように見せたいのであれば、どちらも重要です

両端揃えが実際に必要とするもの

自然な幅で描画されたテキストの行は、ほぼ決して列の右端に達しません。最後のグリフが終わる場所と列の境界との間には常に残余(スラック)があります。左揃えはそのスラックを右に残します。右揃えはそれを左に移動させます。センタリングはそれを分割します。両端揃えは、両端がボックスに達するまで行自体を広げることによってスラックを除去します。そのための唯一の正直な方法は、内部からグリフを押し開けることです

良い両端揃えと悪い両端揃えを分けるルールは、スラックをどこに置くかです。英語や他のラテン系など、単語間にスペースを挟んで書くスクリプトは、すべての単語間スペースに自然な継ぎ目があります。それらのスペースを広げることは、読者がすでに単語間の間隔が変わることを許容しているため、目には見えません。漢字、日本語の仮名、韓国語のハングルのような、単語間スペースなしに書くスクリプトにはそのような継ぎ目がありません。その場合、スラックは隣接するグリフ間に均等に広げる必要があります。これが日本の組版家が「均等割り付け」と呼ぶ原則です。CJKの行にラテン語スタイルの単語間スペース伸縮を適用したり、CJKの行がたまたまスペースを含む1か所にスラックをすべて詰め込んだりすると、アマチュア的な出力を示す川やギャップが生じます

HotPDFがスペースをどこに配置するかを決定する方法

HotPDFはその決定を行単位ではなく、ギャップ単位で行います。行を両端揃えにするとき、隣接するすべてのグリフのペアをたどり、その間に伸縮可能な境界があるかどうかを確認します。境界は、どちらかの側がスペースまたはタブ(ラテンの場合)の場合、あるいは両側がCJKで分割可能な文字(均等スペースの場合)の場合に伸縮可能です。それらの境界を数え、行のスラックをそれらの間で均等に分割し、各対象ギャップにその分を加算します

結果は自然に出てきます。英語の行は単語スペースにのみ伸縮可能な境界があるため、すべてのスラックがそこに集まり、各単語内の文字は自然なスペーシングを保ちながら単語が広がります。漢字や仮名の行はほぼすべてのグリフのペアの間に伸縮可能な境界があるため、スラックが行全体に均等に分配されます。これはまさにそれらのスクリプトが要求する均等なグリフ間スペーシングです。内部スペースのない単一の長いラテン語は伸縮可能な境界がまったくないため、HotPDFはそれを自然な幅のまま残します。同じロジックがフラグを切り替えることなく1つの行のラテン語とCJK混合の実行を処理します。なぜなら決定は各境界に対してローカルだからです

1つの境界は至る所で意図的に除外されています。行の最後のグリフの後の位置は決してギャップとして扱われません。なぜならそこを広げると右側の残余が再導入されてしまい、それは両端揃えの逆だからです

最終行がそのままにされる理由

段落の最終行は特別であり、それを間違えることが最もよくある両端揃えのバグです。段落の最終行は通常短く、多くの場合数単語しかなく、それを列の全幅まで伸ばすと、それらの単語がページを横切って疎らで壊れた行に引き伸ばされます。正しいタイポグラフィは最終行をその自然な幅のまま左揃えで置きます

HotPDFは位置によって末尾行を検出します。テキストを行に折り返す際、分割された行が提供された文字列の末尾に達したタイミングを知っています。その最終行は通常の左揃えで出力され、自然な幅を保ちます。その前のすべての行は両端で揃えられます。テキストに書き込んだ強制改行はそのまま尊重されるため、意図的な短い行も伸ばされません。読者は、最後の行が自然に終わるきれいな長方形のテキストブロックを見ます。これは目が期待するものです

両端揃えを遅くした計測コスト

行を両端揃えにするには正確な幅を知る必要があり、余分なスペースを正確に配置するために各グリフのアドバンスを知る必要があります。最初の実装はそれらの数値を明白な方法で取得していました。完全なUnicode幅クエリで行全体を計測し、次に差分で各グリフのアドバンスを回収するためにプレフィックスを次々と計測していました。N個のグリフの行に対してそれはN+1回の計測エンジンへの呼び出しであり、各呼び出しはオペレーティングシステムにテキストをシェイプして計測し答えを返すよう求める完全なGDIラウンドトリップです

行単位では安く聞こえます。しかしページ全体ではそうではありません。密なA4ページの本文テキスト(約45行、各行約80文字)を考えてみましょう。行ごとにN+1ラウンドトリップで、それは各行で約81ラウンドトリップ、ページで約3,645になり、そのほぼすべてが、エンジンがすでに直前に見たテキストの再計測に費やされます。何千ページも生成するバッチジョブでは、このオーバーヘッドがレイアウト時間を支配し、すべてのラウンドトリップがプロセスとグラフィックスサブシステムの間の境界を越えます

N+1の代わりに1回の呼び出し

修正は小さく見えて大きな見返りをもたらすタイプの変更です。GDIはすでに文字列の合計幅とすべてのグリフの位置を1回のクエリで報告できます。HotPDFはそれをGetWideCharAdvancesを通じて公開しており、これはカーニングを含む各グリフの自然なアドバンスを配列に埋め込み、合計幅をN+1ではなく1回の呼び出しで返します。内部では_HPDFEmitJustifiedWideLineという両端揃えルーティンがすべてのアドバンスを一度だけ要求し、スラックを計算し、伸縮可能な境界に分配して、行を出力します

同じA4ページでは、行あたりの計測が約81ラウンドトリップから1に落ち、ページは約3,645ラウンドトリップから約45へ、約80倍の削減になります。出力はバイトごとに同一であり、計測について何も変わっておらず、要求する回数が減っただけです。同じGDIエンジン、同じフォントメトリクス、同じカーニングが同じ数値を供給します。ラウンドトリップ数だけが減少しました。計測がすでに正しい場合、適切な最適化は何度も再要求するのをやめることであり、近似することではありません

行がページに到達する方法

スラックが分配されると、HotPDFはExtTextOutとグリフごとのアドバンス配列(Dx配列)で行を出力します。各エントリは1つのグリフの原点から次のグリフの原点までの距離で、それはそのグリフの自然なアドバンスに、伸縮可能な境界がそれに続く場合はスラックのシェアを加えたものです。これはPDFイメージングモデルに直接マッピングされます。位置付けられたテキストはTJオペレーターで書かれます。これはグリフランと明示的な水平調整を交互に挟む配列であり、Dxの値はまさにそれらの調整になります。だからこそ余分なスペースはパディング文字でごまかすのではなく、正確なサブポイント位置でグリフ間に配置され、両端揃えのHotPDF行をダウンストリームのツールが読み取っても正しく計測される理由です

両端揃え段落のためにExtTextOutを自分で呼び出す必要はありません。エントリポイントはWideTextOutBoxで、これはUnicode文字列をボックスに折り返し、指定した揃え方を適用します。テキストをボックス幅に収まる行に分割し、各行をボックスの高さに沿って配置し、垂直方向のスペースが尽きる前に収めることができた文字数を返します。揃え方は揃え方列挙型で選択します

type
  THPDFJustificationType = (jtLeft, jtCenter, jtRight, jtJustify);

最初の3つは説明不要の左揃え、センタリング、右揃えです。4番目のjtJustifyはここで説明した完全な両端揃えで、WideTextOutBoxがスクリプト対応スペーシングをオンにするために読み取る値です

実際に段落を両端揃えにする

完全な例として、ドキュメントを作成し、フォントを設定し、完全な両端揃えでボックスに段落を流し込みます。スクリプト対応はAPIの下に存在するため、同じコードでフラグを変えることなくラテン語とCJKテキストを両端揃えにします

uses
  HPDFDoc;

procedure JustifyParagraph;
var
  Pdf: THotPDF;
  Body: WideString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'Justified.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', 11);

    Body :=
      'Full justification spreads the slack on each filled line so both ' +
      'edges meet the column, while the last line keeps its natural width. ' +
      'For scripts with word gaps the space lands between words; for ' +
      'scripts without them it spreads evenly between glyphs.';

    // X, Y, LineSpacing, BoxWidth, BoxHeight, Text, Align
    Pdf.CurrentPage.WideTextOutBox(72, 72, 4, 380, 240, Body, jtJustify);

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

同じブロックを左揃え、センタリング、右揃えにするには、最後の引数をjtLeftjtCenterjtRightに変えるだけです。折り返し、行の配置、戻り値はそのままです。4つのパスすべてを駆動する計測幅はGetWideTextWidthから来ます。これはWideStringを正確に計測するUnicode対応の幅クエリであり、古いバイト単位の計測がLatin-1を超えるものすべてのサイズを誤測定するのに対し、CJKやサロゲートペアのテキストを正しい場所でボックス折り返しします

両端揃えはより大きなテキストシェイピングスタックの1層です。行にグリフを並べ替えたり結合したりするスクリプトが含まれる場合、ここでのスペーシング決定は複雑なスクリプトテキストシェイピングに関する記事で説明されている作業の上に位置します。フォントが選択したいタイポグラフィのバリアントを持つ場合は、OpenType GSUBスタイリスティック代替の駆動方法を参照してください。これらはすべてDelphiとC++Builder向けのHotPDF Componentに含まれており、このブログ全体でカバーされているより広いテキスト、レイアウト、ドキュメントAPIとともに提供されています