
PlaywrightでSVGラベルのはみ出しを計測する — 静的チェックが届かないi18nの不具合
Playwrightで翻訳後のSVGラベルを枠と突き合わせて計測します。何も計測しないまま37ページを合格と報告した2つのバグも解説します。
目次
はじめに
私はこのブログのツールチェーンになぜブラウザがいるのかを尋ねました。テストのない静的サイトに、95 MBのChromiumのダウンロードが突然現れたのですから、当然の疑問です。
その答えは、きっかけになった機能そのものよりも面白いものでした。このサイトの図版はコンポーネントで、テキストはロケールごとのレコードに置かれています。だから1つの絵が英語・日本語・繁体字中国語で描画され、座標は一度だけ書かれます。枠に合わせて決めた幅より長い日本語のラベルは、日本語サイトでだけはみ出します。そしてプロジェクトが持っていたどのチェックも、それを見つけられませんでした。チェックが弱かったからではありません。判断に必要な事実が、何かが描画されるまで存在しないからです。
本記事では、その隙間を整理します。型チェッカーとリンターが届く不具合はどれか、レイアウトエンジンでなければ届かない不具合はどれか、そして私の描画時チェックが何も計測しないまま37ページを合格と報告した2つのバグについてです。
既存のチェックが見えていたもの
プロジェクトにチェックが不足していたわけではありません。pnpm checkはastro check、tsc --noEmit、そしてロケール間の整合性を見るスクリプトを実行します。図版のラベルは次のようになっています。
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を見ながら決められたものです。フレックスアイテムがその中に収まるかどうかについて、ツールチェーンは何の意見も持ちません。
これはツール側の欠落ではありません。静的チェックは書いたものを読み、この不具合はブラウザが描くものの性質です。両者の間にはフォント読み込み、グリフ整形、レイアウトがあり、そのどれもまだ実行されていません。
文字列の幅はフォントを解決しないと決まらない
描画を飛ばして見積もる近道は魅力的です。文字数を数え、CJKを全角幅として扱い、枠と比べるだけで済みます。しかし、このサイトに固有の理由で、その方法はここでは間違っています。
html[lang^='ja'] { --font-sans: … 'Hiragino Sans', 'Noto Sans JP', … }html[lang^='zh'] { --font-sans: … 'PingFang TC', 'Noto Sans TC', … }このスタックが分かれているのは意図的です。漢字はUnicodeで統合されていますが、言語によって描かれ方が違います。1つにまとめたスタックにすると、繁体字中国語のページが日本語のグリフ形状で描画されてしまいます。計測にとっての帰結は、同じ文字でもjaとzh-TWではメトリクスが違うことです。幅は文字列だけの関数ではありません。文字列と、描画するマシンで実際に解決されたフォントの、両方の関数です。
SVGにはすでに答えがあります。<text>ノードに対するgetBBox()は、整形後の実際の描画ボックスをユーザー単位で返します。ただしそれはレイアウトエンジンの外には存在せず、jsdomはレイアウトもテキスト整形も行いません。
つまりブラウザはUIを操作するためにあるのではなく、定規としてそこにあります。この捉え直しによって、依存の正当化は簡単になりました。Playwrightがページを開き、そこに求めるのはボックスの算術だけです。
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>がその枠なのかを決めなければなりません。最初の版は、ラベルの中心を含む最小の矩形を選んでいました。もっともらしく聞こえますし、コードとしても正しく読めます。
カードから飛び出したラベルは、もうそのカードの内側にはいません。コンテナは候補リストから消え、比較はSVGのviewBoxにフォールバックし、枠の外に大きく出たラベルが図版の外形と比較されました。そこには余裕で収まります。故障の向きが逆転していたのです。はみ出しがひどいほど、報告されにくくなりました。
修正は、包含関係ではなく重なりを問うことです。ラベルが最も大きく重なっている矩形が、そのラベルの属する矩形です。ラベルの一部でも枠に重なっている限り、この判定は成り立ちます。
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で、そのx・y・width・heightは自身のプロパティではなくプロトタイプのアクセサです。オブジェクトスプレッドは自身の列挙可能なプロパティだけを写すので、何も写しません。境界の値はすべてundefinedになりました。
NaNはそれをfalseにします。算術に混ざったundefinedはNaNを生みました。そしてNaN > 2はfalseです。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として走り、公開ステップがそれをブロック条件にしています。壊れた日本語ラベルが実際に世に出るのは、その瞬間だからです。
この種の不具合に対する許容度は非対称です。記事をレビューする全員には見えず、記事を読む全員には見えます。レビューはソース言語で行われ、そのソース言語こそが収まっている言語だからです。
まとめ
- 型チェッカーは翻訳が存在することを証明できますが、それが収まることは証明できません。前者はソースの性質で、後者はフォントが解決されテキストが整形されて初めて存在します。
- テキストの幅は文字列だけの関数ではありません。言語ごとにフォントスタックが違うため、同じ文字でも
jaとzh-TWではメトリクスが異なり、描画を迂回する算術的な近道は存在しません。 - ブラウザは計測器としても使えます。テストランナーとしてだけではありません。チェックの中身は
getBBox()と少しの引き算です。 { ...someSVGRect }は空のオブジェクトです。それらのフィールドがプロトタイプのアクセサだからです。その後の算術はNaNを返し、NaNに対するどの比較もfalseになるため、失敗経路には到達できなくなります。- フェイルオープンする検証は、検証がないよりも悪い結果を招きます。未知を偽の安心に変えてしまうからです。ここでは無関係な2つのバグが、まったく同じ「問題なし」の出力を生みました。
2回の全件合格は、修正後に出た本物の全件合格とまったく同じ見た目でした。両者を区別できたのは、チェックをわざと失敗させることだけでした。