PDFから太字や斜体のテキストを抽出しようとしたことがあれば、おそらく正規表現(regex)から始めたことでしょう。それが当然の選択肢に思えます。フォント名の中に「Bold」という単語があるか探し、テキストにフラグを立てて次に進む。その戦略は、一見うまくいっているように見えて、あなたに誤った自信を与えます。しかし、誰かが異なるバージョンのAcrobatやLibreOffice、あるいはPrint-to-PDFドライバーから同じドキュメントを書き出した途端、あなたがコードに組み込んだすべての前提が崩れ去ります。
なぜフォント名は嘘をつくのか
PDF.jsは、ABCDEF+TimesNewRomanPS-BoldMTのような文字列を返してきます。最初の6文字はエクスポート時に挿入されるランダムなプレフィックスであり、ファイルが再生成されるたびに変わります。そのプレフィックスにパーサーを依存させることは、ノイズに賭けるようなものです。他のエクスポーターはさらに厄介です。Font12やF1のような、単なる識別子を出力するものもあります。これらのラベルには意味的な意味は全く含まれていません。それらは、ファイルが書き込まれた際にたまたま近くにあった内部リソースのタグに過ぎません。
PDF(Portable Document Format)は、後続のテキスト抽出を容易にすることを目的に設計されていないため、フォント名は単に埋め込まれた、あるいはサブセット化されたリソースへの参照に過ぎません。スタイル検出のための安定したAPIとして機能することは想定されていなかったのです。BoldやItalicという部分文字列を探す正規表現を書くとき、あなたは作成アプリケーションが自由にフォーマットできるラベルをスクレイピングしていることになります。あなたが読み取っているのは実際のタイポグラフィ特性ではなく、ファイル命名規則です。そして、規則は契約ではありません。
ラベルではなく記述子を読み取る
真実(Ground truth)は別の場所にあります。PDF.jsでは、すべてのページオブジェクトがcommonObjsを公開しています。これは、ページをレンダリングするために必要な実際のフォント記述子(font descriptors)を保持するマップです。ライブラリがページを解析すると、このマップに本物のフォントオブジェクトが格納されます。それらのオブジェクトは、boldとitalicのブール値プロパティを公開しています。これらのブール値は、名前の文字列から推測されるものではありません。PDF内に埋め込まれたフォント記述子に由来しており、グリフのメトリクス、OS/2テーブルのフラグ、そしてタイプセッターがドキュメントに書き込んだシンボリックな記述子から導き出されます。
つまり、推測をやめることができるのです。ページ上のテキストアイテムを走査する前に、page.commonObjsを反復処理してfontStyleMapを作成します。各フォントIDに対して、フォントオブジェクトによって提供される実際の.boldおよび.italicプロパティを記録するオブジェクトを保存します。その後、各テキストアイテムを処理する際に、マップからそのフォント参照を検索し、事前計算されたフラグを読み取ります。これにより、エクスポート間で変化することのない、決定論的な回答を突然得られるようになります。
フォールバックを用意しておくことも可能です。もし記述子がどういうわけか欠落していたり不完全だったりする場合は、フォント名からプレフィックスを取り除き、ランダムなタグを削除してクリーンアップした上で、残った文字列に対して保守的な正規表現を実行します。しかし、これはあくまで最終手段であるべきで、主要なロジックにしてはいけません。信頼性の差は劇的です。名前ベースの解析がエクスポーターによって崩壊する一方で、記述子ベースの解析は、ファイルが実際に何を含んでいるかを直接尋ねるため、安定して動作します。
フォントも嘘をつくとき:合成スタイル
記述子でさえ、見落とすことがあります。一部のPDF作成ソフトは、わざわざ別の斜体(italic)書体を埋め込もうとしません。その代わりに、直立したローマン体のフォントを取り出し、変換行列(transform matrix)を使って傾けます。これは、タイポグラフィの純粋さよりもファイルサイズを優先するデザインツールや古いワードプロセッサによって生成されたファイルによく見られます。
PDF.jsのすべてのテキストアイテムは、グリフの座標系をページの座標系にマッピングする6要素のアフィン行列であるtransform配列を持っています。その配列の3番目の要素が、水平方向のせん断(shear)を制御します。その値がゼロでない場合、テキストはレンダラーによって機械的に傾けられています。フォント記述子だけを信頼していると、このテキストを直立したローマン体として分類してしまいます。行列を検査すれば、合成された斜体を検出し、正しくマークすることができます。オーバープリントによって作成された合成太字にも同じロジックが適用できますが、幾何学的な情報だけで検出するのはより困難です。傾いたテキストの場合、せん断の値が決定的な証拠(smoking gun)となります。
下線は宣言されるものではなく、描画されるもの
太字や斜体はフォントのプロパティですが、下線はそうではありません。PDFにおいて、下線はベクトルパスです。レンダラーは、テキストのベースライン付近に配置された細い水平セグメントに対して描画コマンドを発行します。それは、たまたまグリフの下に配置されているグラフィカルな要素であり、cmapに格納されている文字属性ではありません。
この区別が重要なのは、フォント記述子をいくら読み取ったとしても、下線(アンダーライン)を判明させることはできないからです。ページ上の生の描画オペレータ、あるいは簡潔に言えばジオメトリレベルの出力を確認し、ベースラインに適切な近さで並行して走る短い水平線セグメントを探す必要があります。抽出エンジンがテキストランの下にそのようなセグメントを検知したときに、そのテキストランに下線のタグを付けます。これを別の検出レイヤーとして扱うことで、データモデルの整合性が保たれます。太字(bold)とイタリック(italic)はフォントに固有の属性ですが、下線はドキュメントによって描画される外的な装飾なのです。
実用的なパイプライン
クリーンな抽出システムは、関心事を明確なレイヤーに分離します。まず、ジオメトリワーカーがページを走査します。page.commonObjs をクエリして fontStyleMap を組み立て、各テキスト項目の transform array を検査して合成イタリックを捕捉し、近くのベクターパスをスキャンして下線を特定します。その出力は、textMeta と呼ぶクリーンな中間構造であり、そこではすべてのテキストランが bold、italic、underline という3つの単純な真偽値(boolean)を持っています。
次に、テキストリビルダーがその構造を取り込み、マークアップされた出力を生成します。ここではネストの順序が重要です。正しい階層は、最も外側に underline、次に italic、そして最も内側に bold を配置します。つまり、完全にスタイルが適用されたテキストランは <u><i><b>text</b></i></u> となります。この順序により、無効なHTMLの重なりを防ぎ、ブラウザやドキュメントコンバーター間でのレンダリングの一貫性を維持できます。また、これはタイポグラフィの論理とも一致しています。装飾は意味的な強調を包み込み、意味的な強調は構造的なウェイトを包み込むのです。
このアプローチの強みは、PDFがすでに自身について知っている情報のみを使用する点にあります。OCRは使用せず、クラウドのビジョンサービスも、ラスタライズされたピクセルからスタイルを推測する機械学習モデルも介在しません。作成アプリケーションがすでに計算済みの、ジオメトリとメタデータを通じて公開されているファイル自身のセマンティックレイヤーを読み取っているのです。その結果、PDF生成器が混在する混沌とした環境においても、高速で、決定論的かつ正確な結果が得られます。
真の教訓
フォント名に対する正規表現(Regex)は罠です。テストした1つのファイルで動作するため、近道のように感じられますが、2つ目のエクスポートというわずかな負荷がかかっただけで崩壊します。真の情報はすでにPDFの中にあり、記述子、行列(matrices)、ベクターパスの中に存在しています。それらの事実に基づいてパイプラインを構築してください。フォントオブジェクトに対して太字かどうかを問い、transform matrix を確認して傾斜(shear)をチェックし、描画されたセグメントを見て下線を探してください。表面的なラベルをスクレイピングするのではなく、ドキュメント自体のエンジニアリングをクエリすれば、PDF生成器が変わっても維持されるスタイルを取得できるのです。
