技術記事

DelphiレンダラーでのPDFテキスト送りとq/Qクリップ復元

HotPDF Delphi Componentのページレンダラーはいまや、各グリフの変位をテキスト空間で計算してテキストを送ります。tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th、ISO 32000-1 §9.4.4の定義どおりです。そしてテキスト行列を、その線形部分を通してHPDFTranslateTextMatrixで動かします。クリッピングはqフレームごとに保存され、Qで復元されます。ただしGDIリージョンが取り込まれるのは、そのフレームが実際にクリップを変えるときだけです。両方の修正はHotPDF 2.754.0に入り、どちらも、潰れた単語や、Qを越えて漏れるクリップ領域とともに描画された実世界のページから来ました。1つ目のバグは、プロデューサーがフォントサイズを行列へ書き込むまで正しく見える算術です。2つ目は、並列描画の高速化をほぼ失いかけた正しさの修正で、速度を取り戻したやり方は、GDIベースのPDFデバイスを書く人なら知っておく価値があります

PDFがTf 1を使うとテキストが塊に潰れるのはなぜか

旧送りコードが、テキスト空間の距離をTmの並進成分へ直接足していたからです。テキスト空間とユーザー空間が常に同じスケールであるかのように。実世界のプロデューサーの多くは、Tfでフォントサイズを1に設定し、本当のサイズをテキスト行列に運ばせます。/F1 1 Tfと12 0 0 12 72 700 Tmのもとでは、500単位幅のグリフはテキスト空間で0.5送られ、これはTmがスケールして初めてページ上の6ポイントになります。旧レンダラーはTm.e := Tm.e + Advを実行し、ペンを0.5ポイント動かしました。すべてのグリフが、前のグリフから文字の12分の1のところに着地します。だから本文の1行は左マージンの黒い染みとして描画され、同じファイルは他のどのビューアーでも完璧に見えたのです

HotPDFレンダラーでTf 1のもとでテキストが潰れる理由。12 0 0 12 72 700 Tmのもとでは500単位のグリフは0.5テキスト空間単位を送るべきで、Tmがそれを6ポイントへスケールします。旧コードは0.5をTm.eへ直接足し、本文の1行をグリフごとに文字の12分の1の染みとして描画しました
フォントサイズをテキスト行列へエンコードするプロデューサーのもとでは、すべてのグリフが前のグリフから文字の12分の1のところに着地しました。ライブラリー自身の出力では見えない欠陥です
// サイズをTfでなくTmへエンコードするプロデューサーのコンテンツストリーム:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// 旧送り(簡略化):ユーザー空間であるかのようにTm.eへ足される距離
Adv := W * FontSize / 1000;                  // 500単位グリフなら0.5
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // 幅にだけTh
Adv := Adv + CharSpace;                      // TcはThでスケールされない
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // TwがTfsで誤ってスケールされる
Tm.e := Tm.e + Adv;                          // Tm.a、Tm.b、Tm.c、Tm.dを無視

// 旧TJ調整:Thなし、やはりTm.eだけ
Tm.e := Tm.e - NumValue * FontSize / 1000;

Tm.eの近道は、そのブロックの唯一の欠陥ではありませんでした。ワードスペースのTwはスケールされていないテキスト空間単位で表現されるのに、旧コードはこれにFontSize / 1000を掛けていたので、Tf 12のもとでは、両端揃えの行が単語間の隙間をほぼすべて失いました。水平スケーリングのThはグリフ幅に適用されるのにTcやTwには適用されず、TJのカーニング調整はこれを完全に飛ばしていました。そして、OCRテキストレイヤーが使う種類の、レンダリングモード3の不可視テキストを送る非描画経路と、隠れたoptional contentの中のテキストは、同じ算術のプライベートなコピーを運んでいたので、不可視の実行の後に描かれたものはすべて、間違った位置から始まっていました。レンダラーのテキスト状態バグは、大声で失敗することはめったにありません。かつて1つのエラーもなくTc、Tw、Tzをゼロにしたオペランドインデックスとリソース名のバグと同じく、これらはライブラリー自身の出力上ではもっともらしいページを生み、他のプロデューサーのファイルでだけ壊れました

ISO 32000-1 §9.4.4はグリフ送りをどう定義するか

ISO 32000-1 §9.4.4は送りを完全にテキスト空間で定義し、それを並進行列としてテキスト行列へ適用します。だから答えは、txを先に計算し、スケーリングと回転とスキューはTmに任せることです。横書きでは、txは((w0 − Tj/1000) × Tfs + Tc + Tw) × Thに等しく、w0はemの千分単位でのグリフ幅、TjはTJ調整、ThはTzを100で割ったものです。新しいTmは[1 0 0 1 tx 0] × Tmで、HotPDFではこれがヘルパーHPDFTranslateTextMatrixです。eとfへ直接書く代わりに、行列係数a、b、c、dを通してXとYを足します。§9.3.3により、Twはシングルバイトの文字コード32にだけ適用されるので、マルチバイトのCIDコードは横書きの経路でワードスペースを拾うことは決してありません。同じヘルパーがいまや、Td、TD、T*、'と"のオペレーター、TJ調整、そして隠しテキストの経路を駆動するので、1つの関数がこの規則を所有します

HotPDFレンダラーにおけるISO 32000-1 9.4.4のグリフ送り。txはw0、Tj、Tfs、Tc、Tw、Thからテキスト空間で計算され、HPDFTranslateTextMatrixを通して適用されます。変位はa、b、c、dの行列係数を通って渡り、Td、TD、TJ、隠しテキスト経路が1つの規則を共有します
送りをTm.eへ直接足すのは、テキスト空間がユーザー空間と等しいときにしか動きません。行列係数を通して経路付ければ、スケールされ、回転し、スキューされたテキストも正しく保たれます
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// 横書きのグリフ送り、ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // テキスト空間のTw、スケールされない
Adv := Adv * State.Text.HorizScale / 100;    // Thは合計全体に適用される
HPDFTranslateTextMatrix(Tm, Adv, 0);

// TJ数値要素:同じ空間、同じTh
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

グリフの配置も同じ論理に従わねばなりませんでした。埋め込みアウトラインが利用できず、レンダラーがGDIのTextOutWへフォールバックするときは、いまや、Thを含め、CTM × Tm × rise × emスケールから完全なグリフ行列を組み立て、SaveDC/RestoreDCペアの中で、GM_ADVANCEDモードのSetWorldTransformで取り付けます。GDIフォントは固定の1000単位の高さで作られ、サイズ決定は変換が担うので、回転し、スキューされたテキストは、変換された原点で直立して描かれる代わりに、自分の向きを保ちます。縦書きモードは唯一の意図的な非対称です。WMode 1のフォントは垂直メトリックでy軸を下へ送り、水平スケーリングはその軸には適用されません

q/QはPDFグラフィックス状態に実際何を保存するか

ISO 32000-1 §8.4.2はカレントのクリッピングパスをグラフィックス状態の一部として列挙するので、Qは、数値パラメータだけではなく、クリップを、対応するqのときとまったく同じ状態へ復元しなければなりません。HotPDFはすでに、CTM、色、線パラメータ、テキスト状態を持つグラフィックス状態スタックを保っていました。しかしGDIはクリップをデバイスコンテキストの中に、そのスタックの外に保持します。だから数値状態のコピーは、クリップ以外のすべてを復元し、q ... Qブロックの中でW nで取り付けられたクリップは、ページ上の後続のすべての操作を切り抜き続けました。Form XObjectは同じ失敗への2つ目の経路を足しました。§8.10はフォームに、内容を囲む暗黙のsaveとrestoreを与えますが、実世界のフォーム内容は、仕様がペアになることを要求しているにもかかわらず、自分のqオペレーターを不均衡のまま残すことがあるからです。レンダラーはいまや、フォームを実行する前にCaptureClipBeforeChangeとSaveDCを呼び、フォームが終わった後で、入り口の深さより深い保存済みリージョンをすべて破棄し、RestoreDCを呼びます。だから保存された各HRGNは、きっかり1つの解放経路を持ちます

THPDFSavedClipStateによる遅延クリップ取り込み

出荷された修正は、qごとに1つのTHPDFSavedClipStateレコードを保存しますが、高価な部分は、フレームが初めてクリップを変えるまで先送りします。レコードは、リージョンハンドル、それが属するスタック深さ、取り出されたデバイスコンテキスト、そしてCapturedフラグを保持します。DevPushStateは深さとDCを埋めるだけで、フレーム配列は16から倍増しながら育ちます。だからq 1 0 0 1 x y cm ... Qだらけのコンテンツストリームは、GDIオブジェクトをまったく割り当てません。クリッピングを変えようとするオペレーター、つまり保留中のWやW*付きのパス描画、nオペレーター、パターン塗り、フォーム入り口は、最初にCaptureClipBeforeChangeを呼びます

HotPDFレンダラーでの遅延GDIクリップ取り込み。DevPushStateはqごとに深さとDCだけを記録し、CaptureClipBeforeChangeはW、n、フォーム入り口がクリッピングを変える直前にリージョンを読み、DevPopStateはQで復元して削除します。毎q取り込んでいた先行版は、並列スループットをシングルスレッドの約1.15倍まで落としました
qごとのGDIリージョン作成は描画スレッドを飢えさせました。だから取り込みはいまや、オペレーターがクリッピングを変えようとするときだけ起き、1.5倍の高速化ゲートは再び通ります
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // すでに保存済み、または自分のものではない
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0はクリップがまったくないこと
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0はクリップを取り除く
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

先行版の測定されたコストこそ、この設計が存在する理由です。最初の正しい実装は、qごとにGDIリージョンを作り、読んでいました。数値変換が大半を占めるページでは、レンダラースレッドは、ラスタライズする代わりに、GDIリージョンオブジェクトの奪い合いに時間を費やしました。並列描画パイプラインは、期待された利得から、シングルスレッドスループットの約1.13〜1.20倍へ落ち、ベンチマークスイートの1.5倍高速化ゲートに落ちました。遅延取り込みと再利用されるフレーム容量で、同じベンチマークは元の1.5倍ゲートを再び通ります。小さいTrueTypeグリフのアンチエイリアスは同じリリースに入っており、目立つ容疑者でした。しかしリグレッションはリージョン割り当てに遡りました。最新機能を責める前に測るべきだという、良い思い出しです

このアプローチの限界はどこにあるか

保存されたクリップはデバイスピクセルのGDIリージョンなので、描画されているビットマップに対しては正確ですが、他のどのターゲットに対しても無意味です。だから各フレームは自分のデバイスコンテキストを記録し、DCが変わったとき、たとえばtransparency groupが自分のレイヤービットマップへ描画している間は、DevPopStateは復元を飛ばします。GetClipRgnがゼロを返すのは、クリップなしを意味する正当な結果であり、それをSelectClipRgn(FDC, 0)で復元することこそ、対応するqの時点で存在しなかったクリップを正しく取り除くやり方です。テキスト側では、修正は各グリフがどこへ行くかを直しますが、幅をでっち上げはしません。フォントが/Widths配列を省き、埋め込みプログラムが利用できないなら、送りは幅のフォールバックと同じく善し悪しです。この領域をリグレッションテストするときは、Tf 1とスケールされたTmを持つフィクスチャーを1つ、非ゼロのTzとTwを持つものを1つ、そしてq ... Qの中のクリップの後に外の内容が続くものを1つ、最低でも保持してください。ライブラリー自身が生成した文書には、これらのどれも現れないからです

アプリケーションコードからレンダラーを駆動するなら、PDFページをビットマップへ描画するで述べた呼び出しパターンは何も変わりません。そして以前は染みた行や切り抜かれた内容を示していたページは、2.754.0以降で、単純に正しく描画されるはずです。コンポーネントの詳細、対応するDelphiとC++Builderのバージョン、ライセンスは、HotPDF Delphi PDF Componentの製品ページにあります