印刷に対応したWebページを作ったら、印刷結果を検証したくなる。Playwrightにはこういうものがある。

await page.emulateMedia({ media: "print" });
await page.screenshot({ fullPage: true, path: "print.png" });

@media print のスタイルが適用された状態でスクリーンショットが撮れる。これで印刷の見た目を確認できそうに見える。

確認できない。 この方法には決定的な穴がある。

実測してみた

Markdownプレビューのツールで試した。A4縦、上下18mm・左右16mmの余白を @page で指定してある。十分に長い本文を流し込んで、2つの方法を比べた。

① emulateMedia({media:"print"}) + fullPage screenshot
   1枚の画像: 1280x13847 px / 画像サイズ 1154KB
   → ページの境目という概念が存在しない
② page.pdf({format:'A4'})
   PDF ページ数: 15 / サイズ 53KB

①は高さ13847pxの1枚の画像になった。②では同じ内容がA4で15ページに分かれている。

emulateMedia がやっているのは「印刷用CSSを適用する」ことだけで、紙のサイズに切り分ける処理は一切走らない。だから出力は縦に長い1枚の画像になる。ページの境目がどこにあるのか、画像を見ても分からない。

何を見逃すのか

印刷レイアウトのバグの大半は、ページの境目で起きる。

  • 見出しがページの最終行に来て、本文だけ次のページに送られる
  • 表が紙をまたいで分断され、ヘッダ行が2ページ目に無い
  • 図の下半分が切れて次のページに続いている
  • page-break-inside: avoid を指定したのに効いていない
  • 最後に空白だけのページが1枚余計に出る

これらは全部、ページの境目が存在しないと再現しない。 1枚の長い画像には境目がないので、どれも起きない。検証したいバグが、検証方法の側で消えている。

さらに悪いことに、この方法は成功しているように見える。印刷用CSSは確かに適用されているので、「印刷のときは不要な要素が消えている」「文字サイズが変わっている」といったことは正しく確認できる。部分的に機能しているぶん、機能していない部分に気づきにくい。

page.pdf() を使う

答えは単純で、実際にPDFにする。

const pdf = await page.pdf({
  format: "A4",
  printBackground: true,
  margin: { top: "18mm", bottom: "18mm", left: "16mm", right: "16mm" },
});

page.pdf() はChromiumの印刷パイプラインを本当に通すので、ページ分割も @page も page-break-* も全部効く。印刷ボタンを押したときに出てくるものと、ほぼ同じものが得られる。

注意が3つある。

  • Chromiumでしか動かない。 WebKitやFirefoxのプロジェクトでは使えない
  • page.pdf() は既定でprintメディアを使う。 emulateMedia({media:"screen"}) を先に呼んでいると、画面用のスタイルでPDFが出てしまう。両方を試すコードでは、PDFを撮る前に emulateMedia({media: null}) で戻しておく
  • margin を指定しないと既定の余白が入る。 CSS側の @page { margin } と二重にかかることがあるので、どちらで持つかを決めておく

ページ数を数える

PDFを作れたら、一番安い検証はページ数を数えることだ。バイナリを解析しなくても、雑にこれで数えられる。

const txt = pdf.toString("latin1");
const pages = [...txt.matchAll(/\/Type\s*\/Page[^s]/g)].length;

/Type /Page を数える。[^s] を入れているのは /Type /Pages(ページツリーのルート)を除外するためだ。圧縮されたオブジェクトストリームの中に入っていると数え漏らすので、厳密にやるなら pdf-lib などを使ったほうがいい。回帰検査の用途なら、これで足りる。

ページ数が分かると、こういうテストが書ける。

// A4に1枚で収まる想定のラベルシート
expect(pages).toBe(1);

// 「空白ページが1枚余計に出る」の回帰テスト
expect(pages).toBe(expectedPages);

「ページ数が想定と違う」は、印刷バグのかなりの割合を捕まえる。 余白の計算を間違えて1行だけ溢れると、ページ数が1増える。page-break-before を付けすぎると増える。要素が消えると減る。数字1つなので、テストとして安定していて速い。

境目の中身まで見たいとき

ページ数だけでは足りない場合は、PDFをページごとの画像にして目で見る。

pdftoppm -png -r 100 print.pdf page

poppler-utils に入っている。これで page-01.png、page-02.png … と出るので、境目に何が来ているかを確認できる。スクリーンショット比較の対象にするなら、こちらを使う。

ここまでやるのは、本当に印刷が主目的のツールだけでいい。ラベルシート・値札・組み立て図のような「印刷して使うもの」なら境目まで見る価値がある。記事ページの印刷対応くらいなら、ページ数の検査で足りる。

emulateMedia の使いどころ

誤解のないように書いておくと、emulateMedia({media:"print"}) が無意味なわけではない。ページ分割に依存しない検証には使える。

  • 印刷時に隠す要素(ナビゲーション、広告、共有ボタン)が消えているか
  • リンクのURLを content: attr(href) で展開する処理が効いているか
  • 背景色が抜けて文字が読めなくならないか
  • ダークモードのまま印刷されて真っ黒にならないか

これらはページの境目と関係ないので、スクリーンショットで確認できる。うちにも「全画面ツールの法務リンク行を印刷に出さない」という変更があるが、これは要素の出し分けなので emulateMedia の守備範囲だ。

役割が違うだけだ。要素の出し分けは emulateMedia、レイアウトとページ分割は page.pdf()。

まとめ

  • emulateMedia({media:"print"}) は印刷用CSSを適用するだけで、紙への分割はしない
  • 実測: 同じページが emulateMedia + fullPageで 1280×13847pxの1枚、page.pdf({format:'A4'}) で 15ページ
  • 印刷バグの大半はページの境目で起きるので、境目が存在しない画像では検証にならない
  • page.pdf() を使う。 Chromium限定。emulateMedia({media:"screen"}) を呼んだ後は null で戻す
  • 一番安い検証はページ数を数えること。/Type /Page を数えれば足りる。余白ミスも余分な空白ページも、ページ数の変化として出る
  • 境目の中身まで見たいときは pdftoppm でページごとの画像にする
  • 要素の出し分けの検証には emulateMedia でよい。役割が違う

「それらしい結果が返ってくる検証方法」は、返ってこない検証方法より危ない。動いているように見えるぶん、疑うきっかけが無い。