PDF出力で①と②が不揃いになる話

Fumihiro Takahashi
スタディスト Tech Blog
11 min readDec 22, 2023

--

スタディスト Tech Blog Advent Calendar 2023 の23日目を担当します。CREチームの高橋です。

Teachme Biz Webブラウザ版には、作成したマニュアルを印刷用にレイアウト指定してPDF出力する機能があります。

Teachme Biz のPDF出力ダイアログ

先日、サービスをご利用中のお客様からのお問い合わせで「マニュアル本文中に①②などの丸囲み数字を使用するとPDF出力時に文字サイズが不揃いになる」という事象が発覚しました。例えば以下の画像のような具合です。

左: Webブラウザ / 右: PDF

左はWebブラウザで表示したもの、右はPDF出力をしたものです。PDF出力した方は①と②のサイズが不揃いになっています。なぜこのようなことが起こるのでしょうか?

仮説

調査を始める前に、いくつかの仮説を立ててみました。

  1. 入力されたテキストデータの問題
  2. 出力されたテキストデータの問題
  3. CSSの問題
  4. ライブラリの問題

順に見ていきます。

1. 入力されたテキストデータの問題

Unicode には丸囲み数字のシリーズがいくつか収録されています。例えば U+2460 から始まるシリーズ (CIRCLED DIGIT) と U+2780 から始まるシリーズ (DINGBAT CIRCLED SANS-SERIF DIGIT) です。

Unicodeに収録されている2系統の丸囲み数字

この2系統の丸囲み数字が実際に混在しており、PDFではその違いが顕在化するもののWebブラウザでは表示環境によって似たような見た目になるのではないか、という仮説です。

これは直近の類似事例で英字の A (U+0041)とキリル文字の А (U+0410) が混在していたことに起因する意図しない挙動があったため、十分あり得る話だと思いました。

2. 出力されたテキストデータの問題

入力されたテキストデータに問題が無かったとすれば、出力時に意図しないデータ変換が起きた可能性があります。Teachme Biz のPDF出力機能はマイクロサービス化されています。マイクロサービスはリクエストによりマニュアルの内容データとレイアウト指定のパラメーターを受け取り、HTMLを生成して適切なスタイルシートを選択します。そうして出来上がったWebページをヘッドレスブラウザの印刷機能を利用してPDF化するという流れです。このHTML生成までの過程でテキストデータの変換が発生した可能性があります。

3. CSSの問題

マイクロサービス側で用意しているスタイルシートに問題がある可能性です。例えば意図しない font-size 指定が適用されていることが考えられます。

4. ライブラリの問題

PDF変換の肝であるヘッドレスブラウザは puppeteer を使用しています。私たちが関与する HTMLとCSS に問題がなければライブラリ側の問題である可能性もありました。

調査開始

まずは入力されたテキストデータの確認です。これはデータベースに登録されているテキストデータ (UTF-8) を16進ダンプすることで簡単に確かめられます。調査の結果、当該マニュアルに使用されている丸囲み数字はすべて U+2460 シリーズであることが分かりました。

次に出力データの確認です。マイクロサービスには開発モード限定のデバッグ機能で、PDF変換前のHTML(CSSを含む。以下同様)を出力する機能がありました。これにより、PDF変換前の段階では①②のサイズは揃っていることが確認されました。(後述しますが、実はこの確認方法に不備があり問題の根本原因を見落としてしまいました。)

HTMLに問題がないのであれば残るはPDF変換の段階、つまりライブラリの問題ということになります。確認のため以下のような「最小限の再現可能な例」を用意しました。

import puppeteer from 'puppeteer'

const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(`
おいしいもの<br>
<br>
①焼き鳥<br>
②寿司<br>
`)
await page.pdf({
path: 'result.pdf',
format: 'A4'
})
await page.close()
await browser.close()

puppeteer の最新バージョンでも、この最小限の例で①②のサイズが不揃いになることが確認されました。またこの作例の途中で、当該事象がどのようなテキストでも同じように再現するわけではないことが分かりました。丸囲み数字の前後の文字によって表示が変わるようです。

左: どちらも大きいサイズ / 右: どちらも小さいサイズ

これをもって puppeteer 側の不具合と判断し、本家に Issue を起票しました。

余談ですが、この段階で問題の長期化が予想されたため暫定対応を講じる必要がありました。幸運にも U+2780 シリーズの丸囲み数字はPDF変換後も不揃いにならないことが(いくつかの入力例で)確認されたので「マイクロサービス側で U+2460 シリーズを U+2780 シリーズに一括置換する」という変更をしました。

原因の判明

puppeteer の Issue 起票からおよそ9時間後(早い!)に返信がありました。「これはPDFの問題ではなくフォントの問題ではないか」「Noto Sans JPを指定すると意図通りに動く」とのことです。

実際CSSで指定していたのは font-family: Noto Sans, sans-serif; でしたが、マイクロサービスにインストールされていた日本語フォントは Noto Sans JP ではなく Noto Sans CJK でした。言われたとおりに Noto Sans JP をインストールして font-family を指定すると、確かに①②のサイズが揃いました!これは丸囲み数字が前後の文字によって異なるフォントで表示されるという問題だったのです。

というわけで、マイクロサービス側の修正はこうです。

+ RUN wget 'https://.../Noto_Sans_JP.zip' \
+ && unzip -j Noto_Sans_JP.zip -d /usr/share/fonts/opentype/noto/ \
+ && fc-cache -f
-  font-family: Noto Sans, sans-serif;
+ font-family: Noto Sans JP, Noto Sans, sans-serif;

Dockerfile に Noto Sans JP のインストールコマンドを追加します。またCSSでは font-family の指定を変更します。可能な文字は優先的に Noto Sans JP で表示、そうでない文字は従来どおりの表示にフォールバックするように明示しています。

反省と追加検証

さて、フォントの問題だったということはPDFに変換する前のHTMLでも同様の事象は起きていたはずです。ところが調査の段階ではこれを見落としてしまいました。それは開発環境の構成に原因があります。

わたしは普段 MacBook で開発をしており、Teachme Biz サービスの本体やマイクロサービスは Docker Desktop for Mac 上のコンテナで動かしています。コンテナには GUI がありません。そのためコンテナ上で生成されたデバッグ用のHTMLや最終成果物のPDFは、ホストである MacBook のWebブラウザで閲覧していました。MacBook にはNoto Sans (CJK) がインストールされていないので、コンテナ側とは異なる見え方になっていたようです。これで「PDF変換前の段階では①②のサイズは揃っている」かのように誤認してしまいました。

そこで確認のため、MacBook に X Window System (ビットマップの転送プロトコル)サーバーを用意し、変更を切り戻したコンテナでHTMLがどのように表示されるかを見てみます。以下は MacBook 側の手順です。

  1. XQuartzをインストール https://www.xquartz.org/
  2. XQuartzを起動し 設定 > セキュリティ > 「ネットワーク・クライアントからの接続を許可」にチェック
  3. XQuartzを再起動

次にコンテナ側で次のコマンドを実行します。

DISPLAY=host.docker.internal:0 google-chrome debug/base.html

環境変数 DISPLAY つきで Google Chrome を起動します。debug/base.html はその名の通りデバッグ用に出力しているファイル名です。これでPDF変換前のHTMLをコンテナにインストールされたフォントで表示できるはずです。

コンテナの Google Chrome でデバッグ用HTMLを開いて X Server に転送した様子

たしかに!この段階で、すでに①②のサイズが不揃いになっていました。

せっかくなのでもう少し詳しく見てみましょう。DevTools を開くと実際に表示に使用されたフォント名が確認できます。注目は Elements > Styles > Rendered Fonts (画像右下)です。まずは「②寿司」の方。

3 glyphs が Noto Sans CJK JP Regular で表示されています。次は「①焼き鳥」の方です。

なんと 1 glyph は DejaVu Sans で表示されていました。文字単位で確認することは出来ませんでしたが、おそらく①のことで間違いないでしょう。このように複数のフォントで表示可能な文字が実際にどのフォントで表示されるかは、未知のルールがありそうです。なので統一感を持たせるためには汎用的すぎない font-family を明示するのが無難なのかもしれません。

さいごに

本件は文字コード、CSS、ヘッドレスブラウザ、マイクロサービス、PDF、フォントと被疑箇所が多岐にわたり、問題の切り分けの難しさと楽しさを改めて感じました。また個人的に OSS リポジトリに Issue を起票したのが初めてだったので、これも貴重な経験になりました。(結果としては puppeteer のバグではありませんでしたが、メンテナの方が優しくて良かったです。)これが今年最も印象に残ったデバッグです。年末っぽい締めくくりになりましたね。それではメリークリスマス!

We’re hiring!

スタディストは一緒に働く仲間を募集しています。デバッグ大好きだよという方、一緒にお話しましょう◎

--

--