HotPDF Delphi Componentは、THotPDF.ExtractLoadedPageTextで、スペース文字からではなくグリフのジオメトリから単語スペースと改行を再構築します。スペースは、グリフ自身の幅の後のギャップがテキスト高さの0.15を超えるときに入り、新しい行は、テキスト原点が書字方向を横切ってテキスト高さの半分より多く移動したときだけ始まります。v2.768.3からは、ページテキストはForm XObjectを通って描かれたテキストも含み、可視クロップ領域の外のグリフは外します。この残りでは、各ルールがなぜその形をしているかを説明します。どれも、実在の文書でもっともらしいのに間違った出力を出していた、より単純なルールの置き換えだからです
症状は、PDFテキストを検索インデックスに流し込んだことのある人にはお馴染みです。表紙がPDFReferenceManualNovember4,1998として抽出され、税務フォームが156行に分裂し、斜めの透かしが1行1文字で届き、断ち落とし済みのゲラは、どのビューアーも表示しないプリンターのスラッグ行から始まります。どのファイルも壊れていません。それぞれが、素朴なエクストラクターが誤読する、完全に合法なテキスト配置の方法を使っているのです
抽出したPDFテキストはなぜ単語スペースを失うのか
抽出テキストが単語スペースを失うのは、PDFがスペースを含むよう要求されないからです。プロデューサーはスペース文字を表示して単語を分けられますが、TJ配列の中の数値(ISO 32000-1 §9.4.3)や新しいTd(§9.4.2)でペンを動かしてもよく、TeXの出力、多くのDistillerファイル、ほとんどのジャスティファイド組版がまさにそれをします。v2.766.76より前、HPDFAssemblePageTextは垂直移動しか見ていなかったので、配置で作られた単語境界は黙って消えていました。アセンブラーは現在、先行グリフの書字方向に沿って、そのグリフ自身の幅の終わりから現在のグリフの原点までの距離を測り、距離が現在のグリフのボックス高さの0.15を超えるときにスペースを1つ挿入します。高さはユーザー空間でアセントからディセントまで測ります。どちらかの側がすでに空白ならスペースは加えず、CJK文字同士の間でも加えません。ジャスティフィケーションは漢字を引き伸ばしますが、その引き伸ばしは単語境界を意味しないからです。グリフレコードは同じジオメトリを公開しているので、特定のファイルが謎のとき、判断を再現できます
uses
SysUtils, HPDFDoc, HPDFContentStream;
procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
Glyphs: THPDFGlyphArray;
I: Integer;
Height, Gap: Double;
begin
if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
Exit;
for I := 1 to High(Glyphs) do
begin
// グリフボックスのアセントからディセントまでの高さ、ユーザー空間で
Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
// 水平テキスト:先行グリフ自身の幅の終わりからのギャップ
Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
if (Height > 0) and (Gap > 0.15 * Height) then
Writeln(Format('U+%.4x gap %.2f height %.2f: space',
[Glyphs[I].Unicode, Gap, Height]));
end;
end;
ペン位置でなくグリフ自身の幅から測る理由
HotPDFが単語ギャップをGlyphEndX/GlyphEndYから測るのは、グリフの後のペン位置に、ギャップでないスペーシングがすでに含まれているからです。ISO 32000-1 §9.4.4は水平変位を、グリフ幅 × フォントサイズ、文字間隔Tc、単語間隔Twの合計をTzでスケールしたものと定義します。BaselineEndX/BaselineEndYはその全変位を保持し、GlyphEndX/GlyphEndYはフォントのアドバンスとTzだけを保持します。負のTcでトラッキングを詰め、グリフごとにTJの調整でスペースを返すプロデューサーでは、違いが効いてきます。ペン位置から測ると、返しはギャップに見え、中国語の「95后」が「9 5 后」として抽出されました。しきい値がTfサイズでなくグリフボックス高さに結びついているのも、同じような理由からです。Wordのエクスポートはしばしば1 Tfを書き、実サイズをスケール済みのTmに持たせます。Tfsは1と言うのにテキストは10ポイントの高さで、Tfsに鍵をかけたルールは、同じページの2つの綴りを別々に扱うことになります
このルールには正直な縁があります。非常に緩いトラッキングで組まれた見出し、つまりTcだけで文字間にテキスト高さの0.15を超える隙間が開くものは、文字ごとにスペースを入れて抽出されます。ページがそう見えるのであって、インデックスしたかったものではおそらくないのですが。1つのベースライン上に順不同で描かれた断片は負のギャップを出し、スペースなしで連結されます。どちらも本文では珍しくありません。テストコーパスでは、この変更で参照エクストラクターに対する単語マッチが28ページで上がり、下がったページはありませんでした
抽出テキストでHotPDFが新しい行を始めるのはいつか
v2.766.79から、新しい行は、先行グリフの原点から現在のグリフの原点への移動を、先行書字方向の法線へ射影したものが、2つのグリフの大きい方のボックス高さの半分を超えたときに始まります。旧ルールは生のY移動をTfsの半分と比べていましたが、2方向で失敗しました。1 Tfとスケール済みTmではしきい値が半ユニットまで縮み、テキストライズ0.4で持ち上げられた上付き文字や、平凡なベースラインの揺れが行を切りました。ルールはXも完全に無視していたので、回転したTmの下のテキストはグリフごとにページを下り、1行1文字で出てきました。方向の法線への射影は回転したランを水平のランのように振る舞わせ、2つの高さの大きい方を取ることは、ベースラインを共有する大きなサンプル語とその小さなキャプションを1行に保ちます。上で触れた税務フォームでは、行数は156から97へ落ちました。書字モード1(§9.7.4.3)の縦書きテキストは別の経路を辿ります。それらのグリフは列にグループ化され、右から左、上から下に読まれ、列が変わるごとに改行が入ります
ExtractLoadedPageTextが含めるテキストと外すテキスト
ExtractLoadedPageTextは、ビューアーが表示するテキストを返します。v2.766.80からは可視グリフだけから動作し、ボックス中心がGetLoadedPageVisibleBox、つまりMediaBoxへクリップされたCropBox(§14.11.2)の外に落ちるグリフをすべて落とします。これがスラッグ行と、断ち落とし領域の外にテキストとして組まれた他のプリンターマークを取り除きます。ExtractLoadedPageGlyphsは意図的に、ページコンテンツストリームの全グリフを返し続けます。必要なときにはその素材も見つけられるようにです。フィルターはボックステストであって可視性テストではありません。クリッピングパスで隠されたテキスト、白で描かれたテキスト、画像で覆われたテキストは、やはり抽出されます
var
Pdf: THotPDF;
Glyphs: THPDFGlyphArray;
PageText: UnicodeString;
L, B, R, T: Single;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('trimmed-proof.pdf');
if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
// ページコンテンツストリームの全グリフ。スラッグ行も含む
if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
Writeln(Length(Glyphs), ' glyphs in the page content stream');
// ページが表示するものだけ。Form XObjectテキストを織り込み済み
if Pdf.ExtractLoadedPageText(0, PageText) then
Writeln(PageText);
finally
Pdf.Free;
end;
end;
Form XObjectを通って描かれたテキストは、v2.768.3からページテキストの一部です。ヘッダー、スタンプ、透かしは非常にしばしばフォームに住んでおり、一部の規格文書はこの変更の前に文字の30〜35%を失っていました。THotPDF.InterpretContentWithFormsは、有効なCTMとともに各Doを記録し、フォームをその/Matrix × そのCTM(§8.10.1)で解釈し、フォームのグリフをDoの位置へ織り込みます。ネストしたフォームにも再帰します。自分の/Resourcesを持たないフォームは、§7.8.3が許すとおり、それを描くストリームのものを借ります。フォームグリフはTokenIndex = -1を運び、ExtractLoadedPageGlyphsは今もページストリームのグリフだけを返します。検索、置換、リダクションは変更をTokenIndex経由で書き戻すため、フォームグリフが混入すると間違ったバイトを編集することになるからです。知っておく価値のある2つの単純化があります。フォームテキストはフォームの/BBoxへクリップされないこと、そして再帰はサイクル検出でなく12レベルで止まることです。自分自身を描く不正なフォームは、上限に達するまでテキストを繰り返します
Q演算子の後のテキストはなぜ文字化けしたのか
v2.766.73より前、Qの後のテキストは誤ってデコードされることがありました。エクストラクターがqのときにCTMだけを保存していたからです。テキスト状態のパラメーター、つまりフォント、サイズ、Tc、Tw、Tz、TL、レンダリングモード、ライズは、グラフィックス状態に属します(§9.3.1)。だからQは、スタック上の他のすべてと一緒にそれらを復元しなければなりません(§8.4.2)。ある業界レポートは、q … Qの中で2バイトのIdentity-Hフォントを選び、その後、自分のTfなしで1バイトのWinAnsiテキストを表示しました。エクストラクターは内側のフォントを保持し、目次のリーダーと「Adobe」という語を2バイトコードとして読み、ページの文字の15%を落としました。インタープリターのq/Qスタックは現在、テキスト状態全体を保持します。ここで述べた抽出ルールはすべてのページに適用されるので、文書全体が1回の呼び出しでファイルへ行けます
var
Output: TFileStream;
Pages: Integer;
begin
Output := TFileStream.Create('report.txt', fmCreate);
try
// 空の範囲 = 全ページ。ページ間はフォームフィード。UTF-8 BOM付き
Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
Writeln(Pages, ' pages extracted');
finally
Output.Free;
end;
end;
どのHotPDFテキストAPIを使うべきか
ExtractLoadedPageTextはコンテンツストリーム順を保ちます。検索とインデックスにはこれが正しいデフォルトです。その下のデコードチェーンはHotPDFでロード済みPDFからテキストを抽出するで扱います。作成順序が意味を持つタグ付き文書には、構造順テキスト抽出がジオメトリから推測する代わりに構造ツリーを辿り、テーブルに閉じ込められたデータには、ページまたぎの型付きテーブル抽出が行でなくセルを返します。APIリファレンス全文とトライアルダウンロードは、HotPDF Delphi PDF Component product pageにあります