印刷に対応した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でよい。役割が違う
「それらしい結果が返ってくる検証方法」は、返ってこない検証方法より危ない。動いているように見えるぶん、疑うきっかけが無い。