Files
docview/docs/font-broker-plan.md
2026-09-21 13:41:40 +09:00

301 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 計測で
確定する必要がある。