技術記事

DelphiでPDFに日本語と中国語の縦書きテキストを配置する

「コード」を1文字ずつ描画してページの下へ並べ、前の文字より1emずつ下げていくと、5つのグリフのうち3つは間違った姿になります。2つの隅括弧は横書きの行に置いたときと同じ姿で列の中に座り、横へ口を開いています。長音記号「ー」は、日本語の読者が短い縦線を期待する場所に、列を横切る平たいダッシュとして現れます。ごく普通のカタカナであるコとドだけが、植字屋が組むだろう通りの形をしています

行全体を90度回転させれば、隅括弧と長音記号は半ば偶然なりますが、それ以外のすべてが壊れます。漢字も仮名も横倒しに寝てしまうのです。縦書きは3つのことを一度に行います。文字は正立のままであること、送りがページを横切らずに下へ走ること、そして一握りの文字が別の字形へ切り替わることです。この3つをすべて正しく行うには、縦書き字形と縦書きメトリクスを備えたフォントと、それを使うよう設定されたPDFフォントが必要です

PDF Library for Delphi の比較図。回転した横書きテキスト行と真の縦書きモードの対比。縦書きではグリフが直立を保ち、送り方向がページを下向きに進む
縦書きは横書き行を回転するのではなく、フォント固有の縦書きメトリクスで直立した文字を配置します。コピーした列も文字単位で正しく読み返せます

PDF Library for Delphiで縦書きを有効にする

PDF Library for Delphiは、縦書きを描画モードとして扱います。SetVerticalWritingMode(1)で有効になると、その後のすべてのDrawText呼び出しは、指定した点からページを下へ走り、SetVerticalWritingMode(0)で横書きに戻ります。セッターは呼び出し前に有効だったモードを返し、GetVerticalWritingModeは現在のモードを読むため、ヘルパールーチンはモードを切り替えて、後で見つけた値を正確に戻せます

2つの座標は、横書きテキストとは少し違う意味を持ちます。xの値は列の中心線を指定します。各グリフは水平方向の中央をその線に掛けて吊るされるため、細い記号と全角の漢字が同じ軸上に揃います。yの値は最初の文字セルの上端で、列はそこから下へ伸びます。y = 0をページの上端に置くSetOrigin(1)を使えば、計算式が紙の上でページをレイアウトする感覚と一致して読めます

var
  Lib: TPDFlib;
  H: Double;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.AddTrueTypeFont('MS Mincho', 1);      // 1 = フォントを埋め込み
    Lib.SetTextSize(12);
    Lib.SetVerticalWritingMode(1);            // 通常の DrawText が縦に走ります
    H := Lib.GetVerticalTextHeight('MS Mincho', 12, '第三章 保守点検');
    Lib.DrawText(480, 72, '第三章 保守点検');     // 列はy = 72から始まる
    Lib.SetVerticalWritingMode(0);            // 水平方向に戻します
    // Origin 1ではy = 0が上端。つまり72 + Hが列の下端で、
    // 横書きキャプションはその1行下に置かれる
    Lib.DrawText(456, 72 + H + 18, 'Chapter 3');
    Lib.SaveToFile('manual-ja.pdf');
  finally
    Lib.Free;
  end;
end;

GetVerticalTextHeightはフェイス名、サイズ、テキストを受け取り、列の長さをポイントで返します。この例では、72 + Hが列の下端であり、英語のキャプションはその18ポイント下、列の中心線から24ポイント左を起点に置かれるため、短いラテン文字の行がおおよそ列の下に収まります。この関数は、その名前のフェイスを開けないとき0を返します。ゼロの高さを前提に組んだレイアウトは、キャプションを列の上に重ねてしまうため、値を使う前に必ず検査してください

縦の列で括弧が横倒しになる理由

フォントがその種の文字について2つの字形を持ち、水平の字形がデフォルトだからです。OpenTypeフェイスは、vertというGSUBフィーチャーに代替字形を列挙します。縦組み用に既に回転済みのグリフを同梱するフェイスではvrt2です。テキストの行をシェーピングすると横書きのグリフが返り、その後に何かが縦書きフィーチャーを適用しなければなりません。グリフ1つに対して1つずつです。隅括弧、括弧、長音記号は列に沿って走る形へ変わり、「、」や「。」のような小さな仮名や句読点は、文字の正方形の別の隅へ移動します

PDF Library for Delphiは、描画に使うフェイスからそのフィーチャーを読み、各グリフをフォントの指定する縦書き字形へ置き換えます。フェイスが両方のフィーチャーを持つ場合はvrt2が優先され、vertは無視されます。仕様はvrt2がvertを置き換えるものと意図しており、両方を読むと、どちらのルックアップが先に答えるかによって、1文字に2種類の縦書きグリフが割り当てられかねません。どちらのフィーチャーも持たないフェイスは、通常の字形のままで描かれます。組み込みの置換テーブルよりも、この結果のほうが望ましいと考えています。推測のテーブルはよく使う文字は正しく押さえる一方で、設計者が特定の文字に違う扱いを与えたフェイスを黙って上書きしてしまうからです

Windowsに同梱されるフォントのGSUBテーブルをダンプしてみると、数字は大きく離れています。MS Gothicの縦書きフィーチャーは979個のグリフをマップし、Yu Gothicは2,491個、SimSunは72個、Arialはゼロです(正確な数字はフォントのバージョンで動きます)。たまたま仮名をいくつか含むラテン系フェイスは、置換すべきものがフォントに存在しないため、すべての括弧と長音記号を横書きの形のまま、正立の列を描きます

PDF Library for Delphi の図。フォントの vert 特性が縦列に沿って並ぶ括弧と長音記号の字形を供給し、通常文字はそのまま直立。全体を GetVerticalTextHeight が測定
列の中でどのグリフが字形を変えるかはフォント自身の縦書き機能が決め、GetVerticalTextHeight は実際に描画される字形を測ります

置換は測定も変えます。縦書き字形は、置き換え相手の横書きグリフと異なる独自のアドバンス幅を持つことがあるため、GetVerticalTextHeightはアドバンス幅を足し上げる前に、同じ置換を適用します。横書きの字形を測り、縦書きの字形を描くと、列は予約したボックスを、括弧1個につき数ポイントずつはみ出します

これらのどれも、テキストそのものには触れません。ビューアから列をコピーすれば「コード」が返ってきます。各縦書きグリフは、渡した文字へマップしているからです。検索とテキスト抽出が見るのはソースの文字列であって、回転した表示字形では決してありません

縦書きメトリクスがPDFにどう落ち着くのか

縦書きのランの中の各文字は、フォント自身の縦書きメトリクスが与える距離だけ送られます。出力をPDFインスペクタで開くと、Identity-Vエンコーディングを持つType0フォントが見つかり、そのCIDFontは使用した全グリフにW2エントリを抱えています。フェイスのvmtxテーブルからの縦方向アドバンス、グリフ幅の半分の列中心線へのオフセット、そして縦方向の原点です。ISO 32000-1 §9.7.4.3がこの3つの数字を定義しています。縦書きメトリクスを持たないフェイスにはW2配列がなく、DW2のデフォルトがすべての文字をちょうど1emずつ送ります

縦方向の原点こそ、サードパーティフォント同士が食い違う場所です。vheaテーブルにはアセンダとディセンダのフィールドがあり、一見して自然な情報源に見えますが、ディセンダの符号規約はフェイス間で共有されていません。Windows同梱分を任意のフォントテーブルビューアで読むと、MS Gothicでは+140、Yu Gothicでは+120に対し、SimSunでは−144、MingLiUでは−500となり、値はフォントのバージョンで変わることもあります。そのフィールドから導出した位置は、フェイスごとに異なる量だけずれます。PDF Library for Delphiは、上記のCJKフェイスが一貫して報告する縦方向アセンダから原点を取ります。フェイスがまともな値を報告しない場合は、PDF仕様自体がDW2に与えているデフォルトの880単位へフォールバックします

縦書きグリフを手で配置することには、固有の罠があります。横書きテキストはTJ配列の中の数値で間隔を詰めることがよくあります。縦書きモードの下では、ISO 32000-1 §9.4.4により、それらの数値は列に沿って適用されるため、横書き間隔向けに計算した補正は、各文字をさらにページの下へ押し込みます。だからこそ、コンテンツストリームの中の縦書きランは、調整を一切含まない素のグリフコード列であり、間隔のすべてはW2から来ます

出力を検査するときは、辞書ではなくインクを見てください。フォントに/Identity-Vを見つけたことは、エンコーディングが宣言されたことの証明にはなりますが、テキストが実際にどちらへ走るかについては何も語りません。ページをレンダリングし、描かれたピクセルのバウンディングボックスを測れば分かります。7文字のランは、幅およそ1文字、高さおよそ7文字になるはずです

横書きページの中の1つの縦書きラン

DrawVerticalTextは1つのランだけを縦に描き、書き込みモードには手を触れません。位置、フォント名、サイズ、テキストを受け取るため、現在のフォント選択もそのまま保たれます。背表紙のラベル、印章、表の横の名前の一列。こうしたものは横書きドキュメントの中の縦書きの例外であり、それぞれの周囲でグローバルモードをオン・オフするのは、仕事に見合う以上の状態管理です

PDF Library for Delphi の図。DrawVerticalText が共有ページ上で直立した縦書き実行を通常の横書き DrawText 行の隣に配置し、グローバルな書字方向に触れない
DrawVerticalText は独自のフォント、サイズ、位置を持つため、1 本の縦書きの列が周囲の横書き呼び出しと共存できます
// それ以外は水平のページの中の、1つの縦書きラン
if Lib.DrawVerticalText(520, 96, 'MS Mincho', 14, '保守点検記録') = 0 then
begin
  // フェイスを開けなかったか、このテキストを設定できなかった。
  // LogLayoutProblemは独自ルーチンであり、ライブラリの一部ではない
  LogLayoutProblem('vertical label on page ' + IntToStr(Lib.SelectedPage));
end;
// 周囲の水平テキストは影響を受けません
Lib.DrawText(72, 96, 'Maintenance inspection record');

両方向を混ぜるページは、リソースにフェイスを2回、つまり横書きエンコーディングで1回、Identity-Vで1回運びます。どちらも同じフォントプログラムから作られます。各方向は自分のメトリクスを保持するため、縦書きの本文と横書きの柱を持つ日本語の書籍ページも、横書きの詳細行の上に縦書きタイトルを据えた中国語の証明書も、両方向が正しい間隔で出てきます

この戻り値こそ、ほとんどが縦書きのドキュメントでもDrawVerticalTextに手を伸ばす理由です。モードベースの描画では、縦に組めないランは捨てられる代わりに、普通どおり横書きで描かれ、DrawTextはどちらでも成功を報告します。本文としては、そのほうが良いデフォルトです。迷い込んだ横書きの行は校正で見つけやすい一方、消えた段落は見つけにくいからです。方向こそが本題であるラベルにはDrawVerticalTextを使い、0をレイアウト失敗として扱ってください

列を右から左へ配置する

列の順序は、自分で決める事項です。日本語と繁体字中国語の縦書きテキストは、列ごとに右から左へ読むため、ページは右マージンから始まり、新しい列は前の列の左へ座ります。書き込みモードの設定は、テキストからそれを推論できません。右綴じの本のページ順序や、ふりがな、脚注、図のキャプションの置き場所も、同じく推論の対象外です

以下のルーチンは、文字列を固定の高さに収まる列へ分解していきます。GetVerticalTextHeightで測りながら進み、列ごとに1.5emずつ左へ足を運びます:

procedure DrawColumns(Lib: TPDFlib; const Face: WideString; Size: Double;
  const Text: WideString; Right, Top, BoxH: Double);
var
  Col, Trial: WideString;
  I: Integer;
  X: Double;
begin
  // 提案するガードであり、ライブラリの検査ではない。高さゼロはフェイスを
  // 開けられなかったことを意味し、下のループは列を決して切らない
  if Lib.GetVerticalTextHeight(Face, Size, Text) <= 0 then
    Exit;
  X := Right;                                  // 最初の列は右マージンに
  Col := '';
  for I := 1 to Length(Text) do                // サロゲートペアがない前提
  begin
    Trial := Col + Text[I];
    if (Col <> '') and (Lib.GetVerticalTextHeight(Face, Size, Trial) > BoxH) then
    begin
      Lib.DrawVerticalText(X, Top, Face, Size, Col);
      X := X - Size * 1.5;                     // 次の列は左へ
      Col := Text[I];
    end
    else
      Col := Trial;
  end;
  if Col <> '' then
    Lib.DrawVerticalText(X, Top, Face, Size, Col);
end;

1文字ごとに試験文字列を測り直すのは、アドバンス幅を足し合わせるより遅い方法ですが、置換込みで、描画されるものを正確に測ります。このループが行わないのは、禁則処理です。列の頭に来る閉じ括弧や「。」、列の下で取り残される開き括弧は、日本語の読者には組版エラーであり、直すには描画の前に、区切りの前後で文字を引っ張るか押し込む必要があります。サロゲートペアにも同じ注意が要ります。Text[I]のインデックス操作は、基本多言語面の外の文字を半分に割るため、テキストに含まれ得るならコードポイント単位で進めてください

データから組み立てるドキュメントでは、テキスト検索とページ要素の列挙の解説が、各ページに着地したものを読み戻す方法を示します。すべての列が意図した文字を運んでいることを確認する手早い方法です

縦書きドキュメントはどのフォントを使うべきか

答えは、独自の縦書きメトリクスとvertかvrt2のフィーチャーを持つCJKフェイスです。それらがなければ、文字は1emずつ送られる正立のままで、形の変化は一切起こりません。縦書きテキストが微妙におかしく見えるなら、コードを調べる前にフォントを調べてください。フェイスのGSUBフィーチャーとvhea・vmtxテーブルを一覧表示するテーブルビューアなら、そのフェイスが仕事をこなせるかは1分で分かります

DrawVerticalTextとGetVerticalTextHeightは、インストール済みフェイスを名前経由でWindowsから開きます。他プラットフォーム向けのLazarusビルドでは、縦書きはSetVerticalWritingModeとDrawTextを通ります。ロードして選択したフォントのプログラムから動作するため、そのフォントこそ意図したCJKフェイスである限り、同じ縦書き字形とメトリクスが適用されます

埋め込みは、いつもの規則と、いつものコストに従います。完全なCJKフェイスは数メガバイトに達するため、自分のマシンの外へ出るドキュメントでは、サブセット化が堅実なデフォルトです。PDFファイルサイズ最適化とフォントサブセット化のノートが期待値を扱い、既存PDFへの欠落フォント埋め込みの解説が、フォントなしで届いた縦書きドキュメントの修復ケースを扱います

縦書きCJKは、Delphi、C++Builder、Lazarus向けのネイティブPascal PDFライブラリであるPDF Library for Delphiの、他のテキスト関数と同じ描画APIの中に座っています。フォントとテキストの全機能は、PDF Library for Delphi製品ページに一覧されています