# 把 Playwright 當成量測儀器 — 靜態檢查碰不到的 i18n 缺陷

> 用 Playwright 量測翻譯後的 SVG 標籤有沒有超出方框，以及讓這個檢查在什麼都沒量到的情況下回報 37 頁全數乾淨的兩個錯誤。

- Source: https://oharu121.com/zh-tw/blog/playwright-i18n-svg-label-overflow-render-check/
- Published: 2026-08-13T10:21:47+09:00
- Tags: 國際化, 測試, Playwright

---
## 引言

我問過，為什麼我的部落格工具鏈裡會有一個瀏覽器。這是一個沒有測試的靜態網站，而一份 95 MB 的 Chromium 下載才剛剛出現在裡面，所以這個問題問得很合理。

答案比引出它的那個功能更有意思。這個網站上的圖表是元件，文字放在一份依語系區分的記錄裡，所以同一張圖能算繪成英文、日文與繁體中文。幾何只寫一次。**一個比它當初量身的方框更寬的日文標籤會溢出，而且只在日文網站上溢出，專案裡原有的每一項檢查都看不見它**。這不是因為檢查太弱，而是因為它們需要的那個事實，在有東西被算繪出來之前根本不存在。

本文談的就是這道落差：哪些缺陷是型別檢查器與 linter 碰得到的，哪些只有版面配置引擎碰得到，以及讓我那個算繪階段的檢查在**什麼都沒量到的情況下回報 37 頁全數乾淨**的兩個錯誤。

## 原本就有的檢查已經看得見什麼

這個專案並不缺檢查。`pnpm check` 會執行 `astro check`、`tsc --noEmit`，以及一支跨語系的一致性指令碼。一張圖表的標籤長這樣：

```astro title="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` 就會失敗**，而這正是你要的，因為少了一個語系會算繪出一張空白的圖表。一致性指令碼負責的是另一類問題：各語系之間 `tags` 不一致的文章，或是只被其中一個語言檔案引用、另一個沒有引用的圖表。

接著是幾何，在同一個檔案裡：

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

上面每一項檢查都會通過。記錄裡三個語系齊全，型別彼此吻合，引用也解析得到。**而那個 `124` 是看著 `flex item` 決定的，那是三個字串裡最短的一個。** 工具鏈裡沒有任何東西對 `フレックスアイテム` 放不放得進去有任何意見。

*Figure — StaticVsRendered: 型別檢查器看得到某個語系存在。它的字串放不放得下，根本不是原始碼的性質。*

這不是工具的缺口。**靜態檢查讀的是你寫下的東西；這個缺陷是瀏覽器畫出來的東西的性質。** 隔在兩者之間的是字型載入、字符排版與版面配置，而這些都還沒有執行。

## 要量一個字串，就得先解析它的字型

誘人的捷徑是跳過算繪直接估算：數字數、把 CJK 當成兩倍寬、再拿去和方框比。在這裡這樣做是錯的，理由是這個網站特有的。

```css title="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 裡是統一編碼的，畫出來的形狀卻依語言而異，所以單一組合併過的堆疊會讓繁體中文頁面帶著日文的字符形狀算繪出來。對量測而言，後果就是**同一個字元在 `ja` 與 `zh-TW` 下有不同的度量**。寬度不是字串的函數。它是字串，加上在負責作畫的那台機器上實際解析到哪一套字型，兩者共同決定的結果。

SVG 本身就把答案暴露出來了。對一個 `<text>` 節點呼叫 `getBBox()`，會以使用者單位回傳它排版之後真正算繪出來的方框。只是這個東西在版面配置引擎之外並不存在，而 `jsdom` 既不做版面配置，也不排版文字。

**所以瀏覽器不是為了驅動 UI 而存在的，它在那裡是一把尺。** 正是這個重新定位，讓這個相依套件變得好交代：Playwright 打開頁面，而它被要求做的只有對方框做算術。

```ts title="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>` 比較。兩個單位的容許值吸收掉四捨五入的誤差，在這個字級下遠小於一個字元。

它什麼都沒找到。**37 頁，全數乾淨。** 這是正確的答案，同時也是在那個當下，這支指令碼唯一給得出來的答案。

## 讓這個檢查什麼都放行的兩個錯誤

兩個都是由建置這個檢查的智能體寫出來的，而且形狀一樣：比較悄悄地不再發生，而**沉默和成功長得一模一樣**。

### 被弄丟的容器

每個標籤都是拿它所在的方框來量的，所以指令碼必須決定那是哪一個 `<rect>`。第一個版本挑的是**包含標籤中心點**的最小矩形，這聽起來合理，讀起來也合理。

*Figure — ContainerLost: 只有在標籤還放得下的時候，包含關係才成立。一旦它跑出去，原本所屬的方框就不再是候選，比較也就退回整張圖表。*

已經跑出卡片的標籤，就不在卡片裡面了。容器從候選清單中消失，比較退回到 SVG 的 viewBox，於是一個明明遠在自己方框之外的標籤，被拿去和整張圖表的外框比較，而它在那個範圍內綽綽有餘。**失敗模式是反過來的：溢出越嚴重，就越不容易被回報。**

修法是不要再問包含關係，改問重疊。標籤和哪個矩形重疊得最多，它就屬於哪一個，而且只要標籤還有任何一部分蓋在方框上，這個判斷就成立：

```ts title="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];
```

那個決勝條件比看起來重要。一個在 chip 裡的標籤，同時也在裝著這個 chip 的卡片裡，以及裝著卡片的面板裡，而這三個重疊面積都等於標籤自己的面積。**在平手時以較小的矩形排序，才會挑中 chip。**

### 什麼都沒複製到的展開

為了加入垂直方向的檢查而重構時，寫這段程式的智能體把原本明確讀取容器欄位的寫法，換成了展開：

```ts
// 改之前：可以運作。
const bounds = { x: holder.box.x, width: holder.box.width };

// 改之後：默默回傳一個空物件。
const bounds = { ...holder.box };
```

`getBBox()` 回傳的是 `SVGRect`，而**它的 `x`、`y`、`width` 與 `height` 是原型上的存取器，不是自有屬性**。物件展開複製的是自有的可列舉屬性，所以它什麼都沒複製到。每一個邊界值都變成了 `undefined`。

*Figure — NanCascade: 從一個展開到一個綠色結果只有四步，沒有任何一步會拋出錯誤。最後那個比較是它唯一可能浮上檯面的地方，而 NaN 讓它變成 false。*

算術裡出現 `undefined` 就產生了 `NaN`。**而 `NaN > 2` 是 `false`**，你能對它寫出的其他每一個比較也都是，所以溢出的分支永遠到不了。沒有例外，也沒有警告。這支指令碼造訪了全部 37 頁，把每一頁都量成 `NaN`，拿去和容許值比較，得出所有東西都放得下的結論，然後以 0 離開。

**兩個錯誤都是失敗時放行。** 這是它們共有的性質，也是其中任何一個都危險的原因：失敗時擋下來的檢查會產生你會去追查的雜訊，而失敗時放行的檢查會產生一個你會相信的綠色結果。

## 刻意把它弄壞

抓到這兩個問題的是同一條紀律，只是套用得太晚：**在相信一個檢查做得到之前，先讓它失敗一次。** 一個刻意加長的標籤被貼進一張圖表，然後把指令碼跑上去。

```text
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
```

這是水平方向的情況。垂直方向需要它自己刻意製造的破壞：多加四行文字，把內容推出卡片底部，於是回報了 `overflows its box vertically by 7u`。**每一個軸向都需要自己的負向測試**，因為展開那個錯誤把兩個軸向都停掉了，而先前那個包含關係的錯誤只影響其中一個，光看一次通過的執行結果，你分不出眼前是哪一種。

順序才是重點。兩次全數乾淨的掃描都發生在負向測試*之前*，而且兩次都沒有意義。一個失敗路徑從來沒有執行過的檢查所給出的綠色結果，不是關於程式碼的證據，只是關於它自己的證據。

## 它被接到哪裡

刻意不是 `pnpm check`。那份工作把關的是正式環境的部署，而圖表只在一小部分提交裡會變動，所以在每一次發布前面放一個瀏覽器下載，是用大量的可靠性換取極少的涵蓋率。這個檢查以 `pnpm figures:fit` 執行，而發布步驟會卡在它上面，那正是一個壞掉的日文標籤真的會上線的時刻。

對這類缺陷的容忍度是不對稱的。**校閱這篇文章的每個人都看不到它，而閱讀這篇文章的每個人都看得到它**，因為校閱發生在來源語言，而來源語言正好就是放得下的那一個。

## 總結

- **型別檢查器能證明一份翻譯存在，卻證明不了它放得下。** 前者是原始碼的性質；後者要等字型解析完、文字排版完之後才存在。
- **文字寬度不是字串的函數。** 在依語言區分的字型堆疊之下，同一個字元在 `ja` 與 `zh-TW` 下有不同的度量，所以沒有任何算術捷徑可以繞過算繪。
- **瀏覽器可以當成量測儀器來用**，而不只是測試執行器。這個檢查就是 `getBBox()` 加上幾次減法。
- **`{ ...someSVGRect }` 是一個空物件**，因為那些欄位是原型上的存取器。接著算術會得出 `NaN`，而任何與 `NaN` 的比較都是 false，於是失敗路徑變得永遠到不了。
- **失敗時放行的驗證比沒有驗證更糟**，因為它把一個未知轉換成一份假的保證。這裡兩個彼此無關的錯誤，產生了同樣乾淨的輸出。

兩次乾淨的掃描，和修好之後那次真正乾淨的掃描長得一模一樣。始終只有刻意讓這個檢查失敗一次，能把它們區分開來。

## 參考連結

- [MDN：SVGGraphicsElement.getBBox()](https://developer.mozilla.org/en-US/docs/Web/API/SVGGraphicsElement/getBBox)
- [MDN：展開語法，只會複製自有的可列舉屬性](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)
- [SVG 2：SVGRect 介面與它的 animated value 語意](https://www.w3.org/TR/SVG2/types.html#InterfaceSVGRect)
- [Playwright：在頁面中執行 JavaScript](https://playwright.dev/docs/evaluating)
- [Unicode Standard Annex 38：漢字統一與依語言區分的字符形狀](https://www.unicode.org/reports/tr38/)
