社内の管理画面からエクスポートされた PDF が回ってきて、その中の SKU を一つ確認してほしいと言われた。Ctrl+F で番号を検索しても引っかからない。表の上をドラッグしても選択範囲ができない。400% まで拡大したら文字の輪郭がギザギザだったので、ページ全体が一枚のビットマップだと分かった。送ってきた本人は気づいていない。向こうの画面では「PDF で書き出す」というボタンを押しただけだから。
PDF 生成は自分の担当ではない。普段はクライアント側のコーデックとモデル読み込みをやっている。以下は成果物とリクエストから測って分かったことで、実装コードを読んだ話ではない。
越境倉庫の帳票を模した HTML を七つ手書きした。中に出てくるブランド「潮汐盒 Tidebox」は実在しない。SKU も倉庫名も金額も全部でっち上げで、実在の企業や実在の帳票とは無関係。この七つを三つの経路で書き出した。一つ目は Playwright から page.pdf() を呼ぶ、いわゆるブラウザ印刷の経路。二つ目は ImgIng(https://imging.ai/)の HTML → PDF。三つ目は自分で組んだ対照群で、ページ全体を 2 倍のスクリーンショットにして、同じ寸法の PDF に一枚だけ貼り込むというもの。三つ目は特定の製品を指しているわけではなく、html2canvas と jsPDF の組み合わせが出す成果物と形が同じなので、「ページを画像にする」ことの代償を数値化するために置いた。
二つ目の処理場所については、後のネットワーク観測の前提になるので先に書いておく。インポート、ディレクトリ読み取り、リソースのマッピング、即時プレビュー、そして PDF を受け取った後の可逆的な最小化は、すべてブラウザのローカルで完結し、この区間の上り通信はゼロ。変換をクリックした後にだけ、スクリプトを除去しリソースをすべてインライン化した自己完結型の HTML スナップショットを、一回の POST で同一オリジンの imging.cn のエンドポイントへ送り、サーバー側の Chromium / Skia が実際の PDF を返す。これがこのサービスで唯一サーバーを経由する文書機能になる。ページ内の fetch と XHR と sendBeacon を包んで記録したところ、クリック前の非 GET リクエストは 0 回、クリック後にちょうど 1 回の POST、図版入りサンプルのリクエストボディは 1,604,339 バイト、その中の <script> タグは 0 個、セッション中に触れたドメインは一つだけだった。
数字を並べる。同じ 46 行の帳票で、レイアウトを組み直す経路の成果物は 156,370 バイト、抽出できる文字は 2,996 文字。スクリーンショット経路は 995,232 バイトで、抽出できる文字は 0。容量が 6.4 倍になって、文字は一つも残らない。
気づかれにくいのは残りの二つのほうだ。図版サンプルに入れた SVG グラフは、レイアウト経路の成果物では 819 個のベクター描画オブジェクトとして残っていて、同じ領域を 900 dpi でレンダリングしても軸ラベルの輪郭はきれいなまま。スクリーンショット経路ではベクターオブジェクトは 0 個。本文にある 4 つのリンク注釈(外部リンク 3 つとページ内アンカー 1 つ)は、レイアウト経路では URI まで一致してそのまま残り、スクリーンショット経路では 0 になる。PDF のリンクは矩形に結び付いた独立したオブジェクトで、青い文字を撮影してもそのオブジェクトは生まれない。
一方でビットマップは劣化しなかった。960×600 の PNG は元ファイルとレイアウト経路の成果物で sha256 が一致し、ピクセル単位の最大差は 0。埋め込み後に 817,313 バイトから 1,432,687 バイトに増えるが、これは Chromium が PNG を包み直した結果で、レイアウトを通る二つの経路で増え方は同じだった。
もう半分の不満、つまり「文字が小さい」「ページ数が合わない」のほうは、スクリーンショットとは無関係で、用紙の扱いの問題になる。
印刷 CSS を一切書いていない 46 行の帳票は、ブラウザ印刷だと 2 ページ、用紙は 612×792pt。A4 ではなく Letter だった。長年 A4 だと思い込んでいたが、この版の Chromium の既定は Letter。幅 1180px の表を幅 816px の紙に収めるために縮小がかかり、画面上 13px の表の文字が PDF では 6.74pt になる。レンダリング後の実寸を読んで 1080×1536pt のカスタムページを一枚出す経路では、同じ文字が 9.75pt。この差は縮小の有無であって描画品質ではない。同じ領域を比べても、字形の鮮明さに測定可能な差は出なかった。
ページ側が @page を宣言していれば、二つの経路は同じ結果に収束する。もっと長い表に @page とヘッダー行の繰り返し、行内改ページの禁止を足したところ、どちらも A4 7 ページ、ヘッダー 7 回の繰り返しで、ページ数・ページ寸法・文字サイズ・抽出文字数まで一項目ずつ一致した。@page は @media print { } の中に書いても同じように認識される。そこだけが違う二つのサンプルの成果物は 30 バイトしか違わなかった。
身構えていたのに起きなかったこともある。表の行がページ境界で真っ二つになる現象だ。各行に二か所ずつ固有のマーカーを埋めて、同じマーカーが二ページにまたがるかを調べたが、七つのサンプルの全経路で 0 行。この版は入りきらない行を丸ごと次ページへ送り、thead も自動で繰り返す。よく貼られる thead { display: table-header-group } の一行は冗長だった。検出の仕込みに半日使ったが、結論としては今どき心配する問題ではない。
実際にデータが消えるのは一か所だけだった。height: 520px; overflow: auto の中に入れた表は、46 行のうち 8 行しか書き出されない。二つの経路とも同じ 8 行で、どちらの画面にも警告は出ない。コンポーネントライブラリの表は既定でスクロールコンテナを持つので、管理画面ではこれが普通の書き方になっている。書き出す前にコンテナを解除して、表を本来の高さまで伸ばすしかない。
未解決のものを一つ残しておく。同じ図版ページで、手元の印刷とサーバー側のレンダリングとで中国語の改行位置がずれる(試したのは中国語のサンプルだけなので、日本語でも同じかは未確認)。リード文の四文字が次の行に送られていた。レンダリング側に手元の中国語フォントがなく、代替字形の字幅で組まれるためだ。ページにインライン化したラテン書体の部分は一字ずつ一致していたので、影響を受けるのは CJK の側だけになる。そのフォントも一緒にインライン化すれば揃うはずだが、二回試して成功していない。そのまま置いてある。

