/F1 1 Tf を設定し、次に 12 0 0 12 72 700 Tm、そして (Hello world) Tj を表示するコンテンツストリームは、まったく合法です。手作りのレンダラーは、11文字すべてを5ポイント半ほどの範囲に描きがちです — 66ポイントの単語があるべき場所に、左マージンの黒い染みとして。あの染みの裏にある算術は1行です。ISO 32000-1 §9.4.4 は各グリフの送りをテキスト空間で定義するため、変位はページに届く前にテキスト行列を通らねばならず、q ... Q の中で設定されたクリップは、線幅と同じくグラフィックス状態に属します
PDFがTf 1を使うとテキストが塊に潰れるのはなぜか
テキストが潰れるのは、レンダラーがテキスト空間の距離を Tm の並進成分へ直接足すからです。テキスト空間とユーザー空間が同じスケールを共有している間しか動かないやり方です。実世界のプロデューサーの多くは、フォントサイズを 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のところに着地し、§9.4.4 に従うビューアは同じファイルを普通の本文テキストとして表示します
この近道が長く生き延びるのは、人々がテストに使うジェネレーターの大半が /F1 12 Tf とスケールなしの Tm を書くためです。その場合、テキスト空間とユーザー空間は本当に同じサイズです。壊れるコンテンツストリームをコメントとして上に書いた、素朴版の全体がこちらです:
// サイズを 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 のもとの 3 Tw が 0.036 単位になります。両端揃えの段落は単語間の隙間をほぼすべて失います。水平スケーリングの Th は Tc と Tw にも、TJ のカーニング数にも適用されねばなりません。Code = 32 のテストは、2バイトコードの中の値 32 のバイトにも発火し、CJK テキストに無作為の隙間をばら撒きます。この種のテキスト状態のミスは、大声で失敗することはめったにありません。描かれるグリフが1つもない前に Tc、Tw、Tz をゼロにし得るオペランドインデックスとリソース名の罠と同じく、あるプロデューサーからはもっともらしいページを、次のプロデューサーからは壊れたページを生みます
ISO 32000-1 §9.4.4はグリフ送りをどう定義するか
ISO 32000-1 §9.4.4 は送りを完全にテキスト空間で計算し、並進行列としてテキスト行列へ適用します。だからスケーリング、回転、スキューはすべて Tm から来て、送り自身からは決して来ません。横書きでは、tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th です。w0 は em の千分単位でのグリフ幅、Tj は TJ 調整、Th は Tz を100で割ったものです。新しい行列は [1 0 0 1 tx 0] × Tm です。展開すると、この積は e に a·tx を、f に b·tx を足します。だからテキスト空間の並進が e への平の加算で済むのは、a が 1 で b が 0 のときだけです。§9.3.3 により、Tw はシングルバイトの文字コード 32 にだけ適用されます
HotPDF Delphi Component はこの式を1つの場所に保ちます。ユニット HPDFContentStream は、グリフごとの送りの HPDFTextGlyphDisplacement と、TJ 数値のための HPDFTextAdjustmentDisplacement をエクスポートし、ページレンダラーとテキスト抽出インタプリタの双方がこれらを呼ぶため、正しくレンダリングされるページは、見えているものと一致するグリフ位置も報告します。ライブラリのパーサーの上に自前のコンテンツストリームコンシューマを書くなら、同じ2つのルーチンを呼び、行列ステップは自分で供給できます:
uses
HPDFContentStream; // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement
type
// 自前のテキスト行列レコード、HotPDF の型ではない
TTextMatrix = record
a, b, c, d, e, f: Double;
end;
// 自前のヘルパー: [1 0 0 1 Tx Ty] x Tm、a、b、c、d を通して動く
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
M.e := M.e + M.a * Tx + M.c * Ty;
M.f := M.f + M.b * Tx + M.d * Ty;
end;
// W0 は 1/1000 em 単位のグリフ幅、Tz は Tz オペランド(100 = スケールなし)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
Tx, Ty: Double;
begin
HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
(Code = 32) and not TwoByteCodes, // Tw はシングルバイトのスペースだけ
Tc, Tw, Tz, Tx, Ty);
TranslateInTextSpace(Tm, Tx, Ty);
// Tf 1、Tm 12 0 0 12 72 700、W0 = 500: Tx = 0.5、Tm.e は 6 増える
end;
procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
Tx, Ty: Double;
begin
HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
TranslateInTextSpace(Tm, Tx, Ty); // Th はカーニングにも適用される
end;
レンダラー内部では、同じ行列ステップがテキスト位置を動かすすべてのオペレーターを駆動します。Td、TD、T*、'、" はライン行列をその係数を通して並進させるため、12× のテキスト行列のもとの 0 -1.2 Td は、ベースラインを 1.2 ではなく 14.4 ポイント下げます。決して描かれないグリフも同一の道を行きます。OCR テキストレイヤーが使う不可視モードのレンダリングモード 3 も、隠れた optional content の中のテキストも、描かれるテキストとまったく同じように Tm を送ります。これらに算術のプライベートコピーを与えるのが古典的な間違いです。同じ BT ブロックで不可視の実行に続く可視テキストが、わずかに違う位置から始まることになるからです
グリフそのものの配置
グリフの配置も同じ論理に従います。埋め込みアウトラインが利用できず、レンダラーが GDI の TextOutW へフォールバックするときは、Th を含め、CTM × Tm × rise × em スケールから完全なグリフ行列を組み立て、SaveDC / RestoreDC ペアの中で、GM_ADVANCED モードの SetWorldTransform で取り付けます。GDI フォントは固定の 1000 単位の高さで作られ、サイズ決定はすべてワールド変換が担います。だから回転し、スキューされたテキストは、変換された原点で直立して描かれる代わりに、自分の向きを保ちます。ストローク系のレンダリングモードは、ストロークの前にワールド変換をリセットします。1000 単位のデザインサイズに対して測ったペン幅は、さもなければほぼゼロまで縮むからです。縦書きは唯一の意図的な非対称です。WMode 1 のフォントは垂直メトリックで y 軸を下へ送り、Tc と Tw はその軸に沿って適用され、Th はまったく適用されません
q/QはPDFグラフィックス状態に実際何を保存するか
ISO 32000-1 §8.4.2 はカレントのクリッピングパスをグラフィックス状態の一部として列挙するため、Q はクリップを、対応する q のときのままに復元しなければなりません。当然に聞こえます。GDI バックエンドのレンダラーでは見落としやすいのです。CTM、色、線パラメータ、テキスト状態はレンダラー自身の状態スタックに住み、クリップはデバイスコンテキストに住む — そのスタックの外に。数値状態のコピーはクリップ以外のすべてを復元します。すると q ... Q ブロックの中の W n は、ページ上の後続のすべての操作を切り抜き続け、症状は、原因のオペレーターから遠く離れた場所の欠落コンテンツに見えます
Form XObject は同じ失敗への2つ目の道を開きます。§8.10 はフォームに、内容を囲む暗黙の save と restore を与え、/BBox へクリップしますが、実世界のフォーム内容は、仕様がペアになることを要求しているにもかかわらず、自分の q と Q を不均衡のまま残すことがあります。HotPDF はフォームを実行する前にクリップを記録し、デバイスコンテキストを保存し、フォームが終わると、フォームが開きっぱなしにしたクリップフレームをすべて破棄してデバイスコンテキストを復元するため、保存された各リージョンはきっかり1回解放されます。呼び出し側の状態を弾き出してしまうようなフォーム内の迷子の Q は、適用される代わりに拒否されます。RenderFallbackPolicy を rfpReport に設定すると、その拒否は診断に現れます。杜撰なプロデューサーとレンダリング問題を見分けるのが楽になります:
// 診断: THPDFRenderFallbackDiagnostics(HPDFPageRenderer で宣言)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
Diags := Pdf.GetLastRenderFallbackDiagnostics;
for J := 0 to High(Diags) do
if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
Log.Add(Format('page %d: unbalanced Q refused - %s',
[PageIndex + 1, string(Diags[J].Reason)]));
finally
Bmp.Free; // nil に対する Free は安全
end;
フレームがクリップを変えるときだけ取り込む
素直な修正は、q ごとに GDI クリップリージョンを読み、Q ごとに戻すことです。それは正しい。そして、q を最も多く使うページで最も高くつきます。ジェネレーターは、原点を動かすためだけに、小さな描画を1つ1つ q 1 0 0 1 x y cm ... Q で包むのが大好きだからです。その1組1組が GDI リージョンを作り、クリップをコピーし、また削除することになります。複数ページが並列でレンダリングされると、スレッドはラスタライズの代わりに GDI リージョンオブジェクトの奪い合いに終始し、並列描画パイプラインの高速化はそれに応じて縮みます
HotPDF は高価な部分を先送りします。q はスタック深さと、それが属するサーフェスを記録するだけです。フレーム配列は 16 から倍増して育ち、ページを横断して再利用されるため、数値変換の連なりは GDI オブジェクトをまったく割り当てません。クリップは、そのフレームの中の何かがそれを変えようとする最初の時点で取り込まれます。パス描画や、保留中の W / W* 付きの n、パターン塗り、テキストレンダリングモード 4 から 7 が残すクリップ、フォームへの入り口です。Q では、取り込み済みのフレームはリージョンを戻して解放し、クリップを変えなかったフレームは数値状態だけを弾きます。ありふれたページはリージョンをまったく割り当てず、クリップするページは実際のクリップ変更1回につききっかり1つのリージョンを払います。PDF インタプリタのために自前の GDI デバイスを作るなら、同じ形が数十行に収まります:
// 自前の GDI バックエンドデバイスのためのスケッチ。どれも HotPDF API ではない
// uses 節には Windows、SysUtils、Math が必要
type
TClipFrame = record
Region: HRGN;
Captured: Boolean;
end;
procedure TMyDevice.PushState; // q: GDI 呼び出しは一切なし
begin
if FCount = Length(FFrames) then
SetLength(FFrames, Max(16, Length(FFrames) * 2));
FFrames[FCount].Region := 0;
FFrames[FCount].Captured := False;
Inc(FCount);
end;
procedure TMyDevice.BeforeClipChange; // W n、W f、テキストクリップ、フォーム BBox の前に呼ぶ
var
R: HRGN;
Res: Integer;
begin
if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
R := CreateRectRgn(0, 0, 0, 0);
if R = 0 then RaiseLastOSError;
Res := GetClipRgn(FDC, R); // 1 コピーした、0 クリップなし、-1 エラー
if Res <= 0 then
begin
DeleteObject(R);
R := 0;
if Res < 0 then RaiseLastOSError;
end;
FFrames[FCount - 1].Region := R;
FFrames[FCount - 1].Captured := True;
end;
procedure TMyDevice.PopState; //procedure TMyDevice.PopState; // Q
begin
if FCount = 0 then Exit; // 不均衡な Q: 無視する
Dec(FCount);
if FFrames[FCount].Captured then
SelectClipRgn(FDC, FFrames[FCount].Region); // Region 0 はクリップを取り除く
if FFrames[FCount].Region <> 0 then
DeleteObject(FFrames[FCount].Region);
end;
このスケッチで重みの大部分を担う細部が2つあります。GetClipRgn が 0 を返すのはエラーではありません。その時点でデバイスコンテキストにクリップがなかったという意味です。Q でゼロリージョンを SelectClipRgn へ渡すことこそ、対応する q の時点で存在しなかったクリップを取り除くやり方です。0 を失敗として扱うと、内側のクリップが置き去りになります。もう1つの細部は Captured フラグです。同じフレーム内の2回目のクリップ変更が、保存済みリージョンを、すでに狭められたもので上書きしないようにします
保存されたクリップと送りの規則はどこで止まるか
保存されたクリップはデバイスピクセルの GDI リージョンです。描画中のビットマップに対しては正確で、他のどのサーフェスに対しても無意味です。だから各フレームは取り出し元のデバイスコンテキストも記録し、コンテキストが変わっていれば、たとえば transparency group が自分のレイヤービットマップへ描画している間は、復元をスキップします
テキスト側では、送りの規則は与えられた幅から各グリフを配置します。シンプルフォントが /Widths 配列を省き、埋め込みフォントプログラムも利用できないとき、送りは幅フォールバックと同じく善し悪しで、§9.4.4 はその幅がどこから来るかについて何も語りません。これのリグレッションテストは、シンプルな生成文書がほぼ決して含まないファイルに住んでいます。Tf 1 とスケールされた Tm を持つもの、両端揃えテキストに非ゼロの Tz と Tw を持つもの、そして q ... Q の中の W n クリップの後に、クリップ領域の外に描かれるコンテンツが続くものです。3つ目の正しいレンダリングは、外のコンテンツを余すところなく示します
アプリケーションコードからは、このどれも設定を要しません。呼び出しはPDFページをビットマップへ描画するで示したもので、両方の規則はそのやり方で描画するすべてのページに適用されます。対応する Delphi と C++Builder のバージョンとライセンスの詳細は、HotPDF Delphi PDF Component のページに一覧があります