# PDF font broker 実装計画 調査日: 2026-09-19。対象: PDFium 155.0.8057.0、固定 commit `a5a7089234f121990b336b3841008009dca143bf`、Qt 6.11.2。 この文書は実装前の調査・計画を保存したものである。現在の実装契約は[font-broker-contract.md](font-broker-contract.md)、実行結果は[VALIDATION.md](VALIDATION.md)を参照。以下の「現行」「未実装」は調査時点を指し、Windowsの実行確認を示すものではない。 ## 結論 最小の推奨構成は、Main 側の FontBroker が OS font 索引と選択を管理し、 PDFWorker の公開 `FPDF_SYSFONTINFO` version 2 callback から、既存 IPC を使う 限定的な逆方向 RPC で選択済み font のバイト列を取得する方式である。 font の表・glyph の解析は PDFWorker 内に残す。Main に PDFium をリンクしない。 新しい socket、継承 handle、任意パス指定、font ディレクトリーの Worker 読取り権限は追加しない。 バイト列方式なら Linux の SCM_RIGHTS と Windows の起動後 handle 複製を別実装にせず、 現在の socket/匿名 pipe で共通化できる。全 font の先行転送ではなく、要求された font のみを 64 KiB ごとの明示的な read 要求で取得する。巨大な TTC の一括 IPC frame も避けられる。 現行 `src/pdf/main.cpp:16` は `/usr/share/fonts`、`/usr/local/share/fonts`、 `/etc/fonts`、`/var/cache/fontconfig` を Worker に許可している。これは削除対象である。 固定版 Linux PDFium の既定実装は fontconfig ではなく、フォルダー列挙と独自の CJK 代替候補を使う。したがって Broker を fontconfig に替えるだけで、同一 Linux 上でも 未埋込み font の選択が変化し得る。 [固定版 Linux 実装](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/linux/fx_linux_impl.cpp) ## PDFium 公開 API の適用 ローカル `.deps/pdfium/include/fpdf_sysfontinfo.h` の 49–52 行に version 2 の per-request matching があり、86–87 行では EnumFonts を呼ばないことが明記されている。 version 2 は experimental のため、この固定版への依存と更新時の回帰試験を明記する。 private header の include や private symbol の利用は不要である。 | Callback/公開関数 | 推奨実装 | | --- | --- | | `FPDF_SetSystemFontInfo` | `FPDF_InitLibraryWithConfig` 後、文書読込み前に Worker 専用 adapter を登録 | | `MapFont` | weight/italic/charset/pitch-family/face を Broker へ送り、選択済みバイト列を得て Worker 内 `FontBlob` を返す | | `GetFont` | version 2 では主経路でないが、同じ選択処理への限定 wrapper として用意 | | `GetFontData` | Worker の immutable blob から全体または選択 face の table を返す。パスを開かない | | `GetFaceName`/`GetFontCharset` | Broker の選択結果を返す。要求名を実際の選択名として偽装しない | | `DeleteFont` | Worker の callback handle を破棄。PDFium が保持するコピーとは寿命を分離 | | `Release` | `FPDF_DestroyLibrary` まで adapter 自体を生存させる。二重解放しない | `MapFont` の face は公開 header 上「system local encoding」であり、無条件に UTF-8 と 解釈しない。callback 側で最大 256 bytes まで NUL を探し、raw bytes を RPC に載せる。 Broker が当該 OS の同一規約で解釈し、別名表に照合する。埋込み font の処理は既存 PDFium のまま維持する。custom adapter が欠けた状態で既定 filesystem font mapper に戻す経路は設けない。 [公開 API と固定版 wrapper](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/fpdfsdk/fpdf_sysfontinfo.cpp) 想定する内部 API は次の程度に限定する。名前は実装時に確定する。 ```cpp struct FontRequest { QByteArray faceLocal; // <= 256 bytes, no embedded NUL int weight; // 0..1000 bool italic; int charset; // supported FXFONT_* values int pitchFamily; // accepted pitch/family bits only }; struct SelectedFont { QByteArray fontId; // opaque, scoped to this worker session/generation QByteArray actualFaceLocal; int charset; quint64 size; QByteArray sha256; int faceIndex; // Linux FC_INDEX for a supported static face quint64 faceOffset; // Windows GDI-derived TTC face offset; otherwise unset bool substituted; }; // Broker-owned asynchronous service; no PDFium dependency. // select(request), read(fontId, offset, length), release(fontId), revoke(session) // Worker-owned synchronous callback adapter over the routed async transport. // fetchSelected(request) -> immutable FontBlob ``` ## RPC 契約 新 operation は `font.map`、`font.read`、`font.close` の三つとする。 既存 envelope の `protocolVersion/sessionId/generation/requestId/operation` と `payload` XOR `result/error` を維持する。`requestId` は現在実行中の親 PDF 要求の ID、 各 payload と result/error の `fontRequestId` は Worker が単調増加させる副要求 ID とする。 同時に保留する副要求は一つだけである。 | Operation | Worker → Broker payload | Broker → Worker result | 主な照合 | | --- | --- | --- | --- | | `font.map` | `fontRequestId, faceLocal, weight, italic, charset, pitchFamily` | `fontRequestId, found`。found=true のとき `fontId, actualFaceLocal, charset, size, sha256, faceIndex/faceOffset, substituted` | PDFWorker にだけ有効。現在の親 requestId/session/generation 一致。文字列をパスや fontconfig pattern 構文として解釈しない | | `font.read` | `fontRequestId, fontId, offset, length` | `fontRequestId, fontId, offset, data` | ID は同セッションに発行済み、`1 <= length <= 65536`、加算 overflow と `offset+length <= size` を確認。返却サイズは要求どおり | | `font.close` | `fontRequestId, fontId` | `fontRequestId, released:true` | 発行済み ID を一度だけ解放。revoke 後の ID は無効 | 全応答は control frame のまま 1 MiB 以下とし、64 KiB を超える data は拒否する。 `more` は使わず、一回の read に一回の応答を返す。最終 size と SHA-256 が一致してから blob を PDFium に公開する。CRC 等の font 内容検証を Main で行う意味ではなく、転送を 途中で使わないための完整性確認である。 Broker の受信側は通常 PDF 応答を照合する前に、`payload` を持つ三つの font operation を 専用分岐へ送る。通常の `active_` を完了させず、キューを進めず、親 deadline を延長しない。 不正な role/ID/offset/連続要求は既存の protocol failure として Worker を停止する。 ArchiveWorker と試験用一般 worker には、このサービスを明示的に登録しない。 ファイル名や fontconfig の `FC_FILE` は応答に含めない。 font が存在しない場合は `found:false` として PDFium 内部代替と UI の代替警告を許す。 通信破損、資源上限超過、timeout は font 不在と区別し、当該 PDF 操作を失敗させる。 新規 error code を増やす場合は data contract に登録し、未定義の code を先行実装しない。 ## 同期 callback、deadlock、取消 現在の `src/pdf/main.cpp` は `IpcChannel::messageReceived` の direct slot 内で PDFium を呼ぶ。そのまま callback 内で `QEventLoop` を回して font を待ってはいけない。 Qt の `readyRead` は再帰的に再送されず、Windows の現 `WindowsPipeDevice::completeRead` も `emit readyRead()` の後に次の `beginRead()` を置いている。いずれも受信処理の stack を抜ける前に次の受信を待つ構成が停止の原因になる。 [QIODevice の通知規約](https://doc.qt.io/qt-6/qiodevice.html#readyRead) 1. Worker の受信 slot は envelope と状態を検証する router にする。通常の PDF 要求は 一件だけ保存し、`Qt::QueuedConnection` または queued invocation で後から実行する。 queued 実行が始まる前から reserved 状態とし、二件目の通常要求を通さない。 2. PDFium 処理は受信通知の stack を抜けた後、引き続き同じ Worker thread で実行する。 PDFium の並列呼出しや新 Worker thread は導入しない。 3. callback の同期待ちは、該当 font 応答・transport failure・副要求 deadline だけで解決する。 router は font 応答を通常要求の handling guard より先に処理する。待機中に他の PDF 操作を実行しない。callback から C++ exception を C ABI 越しに送出しない。 4. Main の選択/ファイル読取りは専用 Broker thread に非同期で渡す。HDC と fontconfig config はその thread が所有する。Main UI thread で同期待ちをせず、font 選択処理から Worker の応答を待たない。送信時に Worker の生存・session・generation を再確認する。 5. Worker 停止/新 generation/staging 取消で Broker の ID と待機処理を revoke する。 進行中 OS API の終了を GUI が待つ必要はない。遅れて来た結果は捨てる。既存の process kill と親 deadline が最終的な中断手段になる。単なる検索キュー破棄では、現在の render に必要な font を revoke しない。 QueuedConnection は受信側 event loop へ制御が戻ってから slot を実行する。 同じ thread で `BlockingQueuedConnection` を使う方式は採らない。 [Qt connection type](https://doc.qt.io/qt-6/qt.html#ConnectionType-enum) ## OS ごとの Broker 選択 ### Linux Broker が `FcInitLoadConfigAndFonts`/`FcFontList` または `FcConfigGetFonts` で許可索引を作り、 font のファミリー・style・charset coverage・`FC_FILE`・`FC_INDEX` を内部に保存する。 Worker の値は `FcPatternAddString` 等の typed API の値として渡し、`FcNameParse` に流さない。 固定 alias と優先順位を先に適用し、`FcConfigSubstitute`、`FcDefaultSubstitute`、 `FcFontMatch`/必要なら限定した `FcFontSort` の順で選ぶ。OpenType weight は `FcWeightFromOpenType` で変換し、固定幅/serif/italic と charset に対応する言語を保持する。 `FcFontMatch` は事前 substitution が必要であり、`FC_INDEX` はファイル内の face index である。 [Fontconfig 一次リファレンス](https://fontconfig.pages.freedesktop.org/fontconfig/fontconfig-devel/) 結果は Broker が構築した索引内の候補に再照合する。Worker からファイル、ディレクトリー、 fontconfig config、`FC_FT_FACE`、任意 pattern オブジェクトを受け取らない。選ばれた索引の ファイルを Broker が read-only で開き、通常ファイルとサイズを確認して bounded snapshot を作る。索引作成時と file identity が変わっていれば、その候補を再索引または拒否する。 font 更新との競合でバイト列が壊れても、font 内容を解析するのは隔離 Worker である。 索引の入力は OS/アプリの font 設定だけであり、PDF 内の添付 font を fontconfig に登録しない。 単に `sans:lang=ja` に委ねることは固定順序の代わりにならない。今回の環境では `fc-match 'sans:lang=ja'` と `serif:lang=ja` が両方 `FORM UDPGothic` を返した。 これは環境の観測であり一般的な fontconfig の保証ではない。 ### Windows Broker が `EnumFontFamiliesExW` で名前・style・charset の索引を作る。候補を `LOGFONTW` と `CreateFontIndirectW` で作成し、Broker 専有 HDC へ `SelectObject` して `GetTextFaceW`/`GetTextMetricsW` で実際の選択を検証する。要求名がそのまま選ばれる保証は ないので、許可索引と alias 規則に照合する。HDC と HFONT は Broker thread だけで使い、 元の選択 object を戻してから `DeleteObject`/`DeleteDC` する。 [列挙](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-enumfontfamiliesexw)、 [論理 font の選択](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createfontindirectw)、 [LOGFONTW](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-logfontw) `GetFontData` で選択済み font をバイト列にする。collection は GDI の `'ttcf'` `0x66637474` で全体を取得し、table 0 のサイズとの差から選択 face の offset を求める。 通常の TTF/OTF は table 0。`GDI_ERROR`、0、上限超過を失敗として扱う。Worker に HFONT や HDC は渡さない。font ファイルパスの探索も Worker へ移さない。必要追加 library は Broker target の `gdi32` だけである。 [GetFontData の全体・collection・サイズ取得](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getfontdata) OS font のバイト列はプロセス内の表示用途とし、文書への再埋込みや配布用ファイル生成を この機能に含めない。同梱 font は別途、配布許諾・LICENSE・hash を固定する。 ## TTC、table、short buffer の具体的な注意 公開 API に faceIndex を直接 PDFium へ渡す欄はない。固定版 `GetCachedTTCFace` は `ttc_size - data_size` を face offset とし、それを collection 内の face index に戻している。 そのため Linux の `FC_INDEX` も Windows の選択済み HFONT も、下表の callback の振舞いへ 変換しなければならない。全ファイルだけを table 0 として返す実装では collection の 選択 face が失われる。 [固定版 mapper の TTC 経路](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/cfx_fontmapper.cpp#784) | 入力/状況 | Worker adapter の契約 | | --- | --- | | 単独 TTF/OTF、table 0 | ファイル全体の size/bytes | | 単独 TTF/OTF、`0x74746366` (`ttcf`) | 0、collection ではない | | TTC、`ttcf` | collection 全体の size/bytes | | TTC、table 0 | `collectionSize - selectedFaceOffset`。必要な bytes は selectedFaceOffset から末尾まで | | 通常の table tag | 選択 face の directory を参照。tag は PDFium 側の big-endian 数値、offset/length は checked arithmetic で blob 内に制限 | | `buffer == nullptr` または size 0 | 必要サイズだけ返す | | buffer が足りる | 全対象 bytes をコピーし、コピーしたサイズを返す | | 小さい非 NULL buffer | 最大 buf_size だけ prefix を埋め、公開 header に従い必要サイズを返す。後述の固定版 1024-byte 読取りを試験する | TTC header の offsets と各 SFNT table directory を Worker の bounded reader で解析する。 offset は collection の先頭を基準とする。face count、table count、offset + length、重複 tag、 選択 index を検証し、Main にこの font parser を置かない。 [OpenType collection と table directory](https://learn.microsoft.com/en-us/typography/opentype/spec/otff#font-collections) 固定版 `GetChecksumFromTT` は 1024 bytes の buffer を渡し、返却サイズを使わず、その buffer から checksum を計算する。したがって「短い buffer では何も書かず必要サイズだけ 返す」という解釈では未初期化 bytes が残る。adapter は buffer 範囲を初期化し、存在する prefix を必ず埋める。短い collection の場合の余りはゼロにする。この挙動は公開 API 記述だけから推定せず、固定版実装を対象とする回帰試験にする。 [固定版 checksum 呼出し](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/cfx_fontmapper.cpp#333) Windows GDI は tag の整数 byte order が異なる。PDFium の既定 Windows adapter も `FromBE32(table)` で変換してから GDI に渡している。Worker の SFNT reader と GDI の `GetFontData` を同じ tag 値で無条件に呼ばない。 [固定版 Windows adapter](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/win32/cwin32_platform.cpp#456) 初期対応は static TTF/OpenType/TTC/OTC とする案を推奨する。variable font の named instance、色付き font、Type1 の扱いを実装せずに対応済みとはしない。索引にはあっても 未対応なら別の静的候補を選び、代替を記録する。variable instance を黙って face 0 として 渡す方式は採らない。これらは既存 mapper に対する潜在的な互換性縮小である。 ## 上限と lifetime の初期案 以下は設計書に既存の値ではなく、font broker 向けの提案値である。CJK collection の実測と 性能試験で妥当性を確認してから contract に固定する。 | 対象 | 初期上限案/扱い | | --- | --- | | face 名 | raw bytes 256、embedded NUL 拒否 | | 1 font/collection snapshot | 64 MiB。超過時は別候補、なければ明示的制限 | | 1 read | 64 KiB、同時副要求 1、write queue に全体を先積みしない | | font 選択 handle | session 内 16、通常は map→read 全体→close で短く保持 | | Broker snapshot cache | 全体 256 MiB、参照中を避ける LRU。複数 session 合計で計測 | | Worker font blob cache | 128 MiB。callback handle が参照中の blob を捨てない | | session の異なる選択数/転送累計 | 256/512 MiB。repeated malicious names による無制限列挙を防ぐ | | RPC 待機 | 1応答 5秒、1 font 取得全体 15秒。成功 chunk ごとに全体期限を延長しない | | 全体の処理 | 既存親要求期限と 1.5 GiB Worker 制限を維持。font 待機もその中に含める | 64 MiB は未検証の大きな CJK collection を切り捨て得るため、上限到達を「font が無い」と 黙って扱わない。PDFium 自身の font cache も bytes を複製するので、adapter cache のみを 数えて 1.5 GiB を守れたと判断しない。Broker の snapshot と OS index は Worker から 呼び出せる任意ファイル読み取りサービスに拡張しない。 ## 固定 alias と回帰 corpus fontconfig の全ホスト設定を置き換えるのではなく、アプリが管理する候補順を先に試す。 例として Base14 の Courier/Helvetica/Times と各 bold/italic を個別の entry にし、 承認した同梱書体、既知の metrically compatible OS 書体、最後に charset/family に沿う OS 選択という順を固定する。Symbol/ZapfDingbats は generic sans に置換せず、既存の PDFium 内蔵経路または専用の承認候補を維持する。 日本語は Gothic/Mincho、等幅/比例、weight/italic、英語名/ローカル名を分ける。 `@` で始まる縦書き face、Shift-JIS に由来する face 名、CJK の charset を別ケースとして 試験する。alias の具体的な font を決める前に、対象 OS に存在することと配布条件を確認する。 試験用 BIZ UDPGothic の埋込み許諾が確認済みでも、それだけで製品の全 fallback 資産が揃った ことにはならない。 | Corpus/試験 | 固定する観測と合格条件 | | --- | --- | | 既存埋込み日本語/画像/透明合成/CropBox PDF | font broker を入れる前後で選択・glyph・配置が変わらない。埋込み font を system font に置換しない | | 既存 `unembedded-font.pdf` | 現在は Standard-14 の限定 corpus。選択 family/file hash/style を記録し、Poppler 対照と文字位置・幅を比較 | | 新規未埋込み日本語 | 正しい CID encoding を持つ horizontal/vertical の原本を生成。単に subset font stream を削除する方式は glyph ID が変わるため用いない | | alias/weight/italic/symbol | exact installed name、Base14 alias、不在名、日本語ローカル名、記号をそれぞれ検証。Broker が選んだ実名と hash も期待値に含める | | TTC の複数 face | 同一 collection の異なる faceIndex を描画し、face 0 固定にならないこと。table offset/collection read/1024-byte prefix を単体試験 | | malformed font bytes | truncated directory、過大 table count、範囲外 offset、加算 overflow、重複 tag、短い buffer を Worker adapter 単体で拒否 | | sandbox 攻撃 | font broker 正常動作中でも Worker から font directory/設定/無関係ファイルを open できない。path 風 face 値を使っても任意パスを読まない | | RPC/取消 | 偽 fontId、別 generation、重複応答、途中切断、読取り範囲超過、response timeout、font 取得中の close/kill、遅い応答を投入。UI が待ち合わせで停止しない | | lifecycle/メモリー | 一覧にない要求の連打、font 名の大量変化、TTC 再利用、長時間読書を測定。Broker と Worker 両方のメモリー、送信 queue、UI 応答を記録 | | 両 OS | alias policy、選択結果、PDFium adapter が同じでも OS font の bytes が違う点を記録。Windows は native 環境で実施するまで未検証 | この検証は固定 corpus に対する互換性範囲を増やすもので、欠けた元 font の完全再現や 任意 PDF の一致を保証しない。既存 Linux/Poppler 比較は embedded 日本語と Standard-14 を 中心にしており、未埋込み日本語・TTC・variable instance の新経路の合格証拠にはならない。 ## 必要変更箇所と実装順 | 場所 | 変更 | | --- | --- | | 新 `src/common/font_contract.*` | font RPC の型・値・サイズ・role・ID 検証。QtCore のみ | | 新 `src/broker/font_broker.*` | OS index、alias、選択、snapshot と quota。PDFium 非依存。OS backend は別ファイルに分割 | | 新 `src/pdf/system_font_adapter.*` | 公開 SYSFONTINFO、immutable blob、checked SFNT/TTC reader。PDFWorker/adapter test にだけリンク | | `src/common/ipc.*` | 三 operation の許可と envelope の限定拡張。control/data 上限は増やさない | | `src/common/worker_process.*` | font request の role 分岐、親 active 保持、応答送信、deadline、stop 時 revoke | | `src/pdf/main.cpp` | router と queued PDF 実行、font response 待機、font directory 権限の全削除 | | `src/pdf/pdf_document.*` | adapter を init 時登録し destroy まで保持。テストは explicit in-memory font provider を注入可能にする | | `src/app/controller.*` | PDF session だけに Broker を登録。代替・制限警告と寿命管理 | | `CMakeLists.txt` | Broker Linux backend に fontconfig、Windows backend に gdi32。Main に `docview_pdf`/PDFium を追加しない | | tests/fixtures/results | 契約、callback、実 IPC、sandbox denial、両 OS 描画比較を追加 | 実装順は、(1) memory-only callback と TTC 単体試験、(2) bounded font 契約+偽 provider で 同一 transport の応答/取消/deadlock 試験、(3) Linux index と alias、(4) font directory 権限を外した PDFWorker で回帰、(5) Windows GDI backend と native gate とする。 最初の callback 実装と IPC 試験は独立して進められるが、権限削除だけを先行して 既定 font mapper の失敗を内部 fallback で隠さない。 ## 調査の検証範囲 公開 local header、固定 commit の PDFium 公式ソース、Fontconfig・Qt・Microsoft の一次資料を 読んで確認した。Linux の fontconfig 2.18.3 と現在の `fc-match` 選択を読み取りで確認した。 この調査では production code を変更せず、callback prototype の実行、逆方向 RPC、GDI の native 実行は行っていない。上限値・alias の最終集合・描画閾値は、実装と corpus 計測で 確定する必要がある。