タグ
白いカードに、赤い泣き顔の仮面と緑の笑い顔の仮面が重なるPlaywrightのロゴと、黒のPlaywrightのロゴタイプ

PlaywrightでSVGラベルのはみ出しを計測する — 静的チェックが届かないi18nの不具合

Playwrightで翻訳後のSVGラベルを枠と突き合わせて計測します。何も計測しないまま37ページを合格と報告した2つのバグも解説します。

目次

はじめに

私はこのブログのツールチェーンになぜブラウザがいるのかを尋ねました。テストのない静的サイトに、95 MBのChromiumのダウンロードが突然現れたのですから、当然の疑問です。

その答えは、きっかけになった機能そのものよりも面白いものでした。このサイトの図版はコンポーネントで、テキストはロケールごとのレコードに置かれています。だから1つの絵が英語・日本語・繁体字中国語で描画され、座標は一度だけ書かれます。枠に合わせて決めた幅より長い日本語のラベルは、日本語サイトでだけはみ出します。そしてプロジェクトが持っていたどのチェックも、それを見つけられませんでした。チェックが弱かったからではありません。判断に必要な事実が、何かが描画されるまで存在しないからです。

本記事では、その隙間を整理します。型チェッカーとリンターが届く不具合はどれか、レイアウトエンジンでなければ届かない不具合はどれか、そして私の描画時チェックが何も計測しないまま37ページを合格と報告した2つのバグについてです。

既存のチェックが見えていたもの

プロジェクトにチェックが不足していたわけではありません。pnpm checkastro checktsc --noEmit、そしてロケール間の整合性を見るスクリプトを実行します。図版のラベルは次のようになっています。

src/content/blog/<slug>/_figures/Example.astro
const LABELS = {
en: { flexItem: 'flex item' },
ja: { flexItem: 'フレックスアイテム' },
'zh-tw': { flexItem: 'flex 項目' },
} as const satisfies Record<Locale, unknown>;

このsatisfiesはきちんと仕事をしています。jaのキーを消せばastro checkが失敗します。ロケールが欠けた図版は空白で描画されるので、これはまさに望ましい挙動です。整合性スクリプトは別の種類の問題を捕まえます。ロケール間でタグが食い違っている記事や、片方の言語ファイルからしか参照されていない図版です。

同じファイルの座標を見てみます。

<rect class="pill" x="316" width="124" />
<text x="378">{t.flexItem}</text>

ここまでのチェックはすべて通ります。レコードには3つのロケールがそろい、型は一致し、参照も解決します。そして124という値は、3つの文字列のうち最も短いflex itemを見ながら決められたものですフレックスアイテムがその中に収まるかどうかについて、ツールチェーンは何の意見も持ちません。

型チェッカーは翻訳の存在を証明できても、収まることは証明できない破線の縦の境界で分かれた 2 つの列がある。左の列は「ソースが語ること」と題され、ソースだけで確かめられる 3 つの事実にチェックが付く。すなわち LABELS に 3 つのロケールがそろっていること、型が一致していること、すべてのロケールのファイルから図版が参照されていることである。列を隔てる境界にはフォント解決、グリフ整形、レイアウトと記され、静的解析がここで止まることを示す。右の列は「ブラウザが描くもの」と題され、同じ小さな枠を 2 回描く。1 つは英語の「flex item」が余裕をもって収まり、収まると記される。もう 1 つは日本語の「フレックスアイテム」が枠の右端をはみ出し、はみ出すと記される。ソースが語ることLABELS に 3 つのロケールがそろっています型が一致していますすべてのロケールのファイルから図版が参照されています書いたテキストそのものの性質です静的解析はここで止まりますフォント解決 →グリフ整形 →レイアウトブラウザが描くものflex item収まりますフレックスアイテムはみ出します描画されて初めて存在します型チェッカーが証明できるのは、翻訳が存在することだけです。それが収まるかどうかを知っているのはブラウザだけです。
型チェッカーはロケールが存在することを見られます。その文字列が収まるかどうかは、そもそもソースの性質ではありません。

これはツール側の欠落ではありません。静的チェックは書いたものを読み、この不具合はブラウザが描くものの性質です。両者の間にはフォント読み込み、グリフ整形、レイアウトがあり、そのどれもまだ実行されていません。

文字列の幅はフォントを解決しないと決まらない

描画を飛ばして見積もる近道は魅力的です。文字数を数え、CJKを全角幅として扱い、枠と比べるだけで済みます。しかし、このサイトに固有の理由で、その方法はここでは間違っています。

src/styles/global.css
html[lang^='ja'] { --font-sans: … 'Hiragino Sans', 'Noto Sans JP', … }
html[lang^='zh'] { --font-sans: … 'PingFang TC', 'Noto Sans TC', … }

このスタックが分かれているのは意図的です。漢字はUnicodeで統合されていますが、言語によって描かれ方が違います。1つにまとめたスタックにすると、繁体字中国語のページが日本語のグリフ形状で描画されてしまいます。計測にとっての帰結は、同じ文字でもjazh-TWではメトリクスが違うことです。幅は文字列だけの関数ではありません。文字列と、描画するマシンで実際に解決されたフォントの、両方の関数です。

SVGにはすでに答えがあります。<text>ノードに対するgetBBox()は、整形後の実際の描画ボックスをユーザー単位で返します。ただしそれはレイアウトエンジンの外には存在せず、jsdomはレイアウトもテキスト整形も行いません。

つまりブラウザはUIを操作するためにあるのではなく、定規としてそこにあります。この捉え直しによって、依存の正当化は簡単になりました。Playwrightがページを開き、そこに求めるのはボックスの算術だけです。

scripts/check-figure-fit.ts
const box = (textEl as SVGGraphicsElement).getBBox();
const spillX = Math.max(bounds.x - box.x, box.x + box.width - (bounds.x + bounds.width));
const spillY = Math.max(bounds.y - box.y, box.y + box.height - (bounds.y + bounds.height));

スクリプトの残りは事務作業です。図版を持つ記事をすべて走査し、実際にファイルが存在するロケールを訪れ、<text>をそれが属する<rect>と比較します。2ユーザー単位の許容値が丸め誤差を吸収します。このサイズでは1文字の幅にはるかに満たない値です。

何も見つかりませんでした。37ページ、すべて合格でした。それは正しい答えでしたが、同時にその時点でスクリプトが出せる唯一の答えでもありました。

検査を全部通してしまった2つのバグ

どちらもチェックを構築していたエージェントが書いたもので、2つは同じ形をしていました。比較が静かに行われなくなり、沈黙と成功が見分けられなくなっていたのです。

見失ったコンテナ

各ラベルは、それが載っている枠と比較して計測されます。そのためスクリプトは、どの<rect>がその枠なのかを決めなければなりません。最初の版は、ラベルの中心を含む最小の矩形を選んでいました。もっともらしく聞こえますし、コードとしても正しく読めます。

ラベルの中心でコンテナを選ぶと、肝心なときに見失う同じカードと同じラベルを 3 つのパネルで示す。1 つ目ではラベルがカードの内側にあり、中心も内側にあるため、コンテナが見つかり比較も正しく行われる。2 つ目ではラベルがカードの右端をはみ出しているが、中心はまだ内側にあるため、コンテナは見つかりはみ出しも報告される。3 つ目ではラベルがカードから完全に離れ、点で示した中心も外側にあるため、中心を含む矩形が存在しない。検査は図全体にフォールバックし、ラベルはそこには余裕で収まるため、はみ出しは一切報告されない。ラベルが収まる中心カードの矩形ラベルコンテナが見つかり、正しく比較されますラベルが少しはみ出す中心カードの矩形ラベルコンテナは見つかり、はみ出しが報告されますラベルが完全に外へ出る中心カードの矩形ラベル中心を含む矩形がないため、検査は図全体にフォールバックし、収まっていると報告しますはみ出しがひどいほど、報告されにくくなります。
包含関係が成り立つのは、ラベルがまだ収まっている間だけです。外に出てしまうと、本来属していた枠は候補から消え、比較は図版全体にフォールバックします。

カードから飛び出したラベルは、もうそのカードの内側にはいません。コンテナは候補リストから消え、比較はSVGのviewBoxにフォールバックし、枠の外に大きく出たラベルが図版の外形と比較されました。そこには余裕で収まります。故障の向きが逆転していたのです。はみ出しがひどいほど、報告されにくくなりました

修正は、包含関係ではなく重なりを問うことです。ラベルが最も大きく重なっている矩形が、そのラベルの属する矩形です。ラベルの一部でも枠に重なっている限り、この判定は成り立ちます。

scripts/check-figure-fit.ts
const holder = rects
.map((r) => {
const ox = Math.min(box.x + box.width, r.box.x + r.box.width) - Math.max(box.x, r.box.x);
const oy = Math.min(box.y + box.height, r.box.y + r.box.height) - Math.max(box.y, r.box.y);
return { ...r, area: ox > 0 && oy > 0 ? ox * oy : 0 };
})
.filter((r) => r.area > 0)
.sort((a, b) => b.area - a.area || a.box.width * a.box.height - b.box.width * b.box.height)[0];

同点処理は見た目以上に重要です。チップの中にあるラベルは、そのチップを載せたカードの中にもあり、そのカードを載せたパネルの中にもあります。3つの重なり面積は、どれもラベル自身の面積と等しくなります。同点を小さい矩形の順に並べることが、チップを選ばせています

何も返さなかったスプレッド

縦方向のチェックを足すリファクタリングの際に、それを書いていたエージェントが、コンテナのフィールドを明示的に読む処理をスプレッドに置き換えました。

// 変更前: 動いていた
const bounds = { x: holder.box.x, width: holder.box.width };
// 変更後: 黙って空のオブジェクトを返す
const bounds = { ...holder.box };

getBBox()が返すのはSVGRectで、そのxywidthheightは自身のプロパティではなくプロトタイプのアクセサです。オブジェクトスプレッドは自身の列挙可能なプロパティだけを写すので、何も写しません。境界の値はすべてundefinedになりました。

何も写さないスプレッドと、そこから出てくる緑の結果4 段階の連鎖が左から右へ進む。SVGRect をスプレッドしても何も写らない。x・y・width・height が自身のプロパティではなくプロトタイプのアクセサだからである。そのため結果から幅を読むと undefined になるが、これはエラーではない。undefined を含む算術は NaN を生み、NaN と許容値の比較は false になるため、はみ出しの分岐には決して到達しない。どの段階にも「エラーなし」の印が付く。連鎖は緑の結果ボックスで終わり、37 ページを計測してすべてのラベルが収まっていると報告し、終了コードは 0 になる。1{ ...svgRect }x/y/width/height はプロトタイプのアクセサで、スプレッドは何も写しませんエラーなし2bounds.width=== undefinedエラーは出ず、値が欠けるだけですエラーなし3spill = NaNundefined を含む算術の結果ですエラーなし4NaN > 2 === falseはみ出しの分岐に到達しませんエラーなしすべてのラベルが収まっています。37 ページを計測しました。exit 0タイプミスから緑の結果まで 4 段階、どれも例外を投げません。
スプレッドから緑の結果まで4段階、そのどれも例外を投げません。表面化しうる場所は最後の比較だけですが、NaNはそれをfalseにします。

算術に混ざったundefinedNaNを生みました。そしてNaN > 2falseですNaNに対して書けるほかのどの比較も同じなので、はみ出しの分岐には到達できません。例外もなく、警告もありません。スクリプトは37ページすべてを訪れ、それぞれをNaNとして計測し、許容値と比較し、すべて収まっていると結論して、終了コード0で終わりました。

2つのバグはどちらもフェイルオープンでした。それが両者に共通する性質であり、どちらも危険だった理由です。フェイルクローズするチェックは調べる気になるノイズを出しますが、フェイルオープンするチェックは信じてしまう緑の結果を出します。

わざと壊してみる

両方を捕まえたのは、遅れて適用された同じ規律でした。チェックが失敗できると信じる前に、実際に失敗させるという規律です。わざと長すぎるラベルを図版に貼り付け、スクリプトを走らせました。

over playwright-i18n-svg-label-overflow-render-check [en]
"Right CSS, wrong box, and a deliberately overlong heading to prove it fires"
overflows its box horizontally by 176u

これが横方向の場合です。縦方向にはそれ専用の意図的な破壊が必要でした。4行を余分に足してカードの下端から押し出すと、overflows its box vertically by 7uと報告されました。軸ごとに専用のネガティブテストが必要でした。スプレッドのバグが両方を無効にしていたのに対し、先の包含のバグは片方にしか影響しておらず、通っただけの実行結果からは、そのどちらを見ているのか判別できないからです。

重要なのは順序です。2回の全件合格はどちらもネガティブテストのに出たもので、どちらも無意味でした。失敗経路が一度も実行されていないチェックの緑の実行結果は、コードについての証拠ではなく、そのチェック自身についての証拠にしかなりません。

どこに組み込んだか

意図的にpnpm checkには入れていません。あのジョブは本番デプロイの関門であり、図版が変わるのはごく一部のコミットだけです。すべてのリリースの前にブラウザのダウンロードを置くのは、わずかなカバレッジのために多くの信頼性を差し出す取引になります。チェックはpnpm figures:fitとして走り、公開ステップがそれをブロック条件にしています。壊れた日本語ラベルが実際に世に出るのは、その瞬間だからです。

この種の不具合に対する許容度は非対称です。記事をレビューする全員には見えず、記事を読む全員には見えます。レビューはソース言語で行われ、そのソース言語こそが収まっている言語だからです。

まとめ

  • 型チェッカーは翻訳が存在することを証明できますが、それが収まることは証明できません。前者はソースの性質で、後者はフォントが解決されテキストが整形されて初めて存在します。
  • テキストの幅は文字列だけの関数ではありません。言語ごとにフォントスタックが違うため、同じ文字でもjazh-TWではメトリクスが異なり、描画を迂回する算術的な近道は存在しません。
  • ブラウザは計測器としても使えます。テストランナーとしてだけではありません。チェックの中身はgetBBox()と少しの引き算です。
  • { ...someSVGRect }は空のオブジェクトです。それらのフィールドがプロトタイプのアクセサだからです。その後の算術はNaNを返し、NaNに対するどの比較もfalseになるため、失敗経路には到達できなくなります。
  • フェイルオープンする検証は、検証がないよりも悪い結果を招きます。未知を偽の安心に変えてしまうからです。ここでは無関係な2つのバグが、まったく同じ「問題なし」の出力を生みました。

2回の全件合格は、修正後に出た本物の全件合格とまったく同じ見た目でした。両者を区別できたのは、チェックをわざと失敗させることだけでした。

参考リンク

この記事をシェア