標籤
白色卡片上的 Playwright 標誌,紅色皺眉面具與綠色微笑面具相互重疊,旁邊是黑色的 Playwright 文字標誌

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

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

本頁目錄

引言

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

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

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

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

這個專案並不缺檢查。pnpm check 會執行 astro 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 就會失敗,而這正是你要的,因為少了一個語系會算繪出一張空白的圖表。一致性指令碼負責的是另一類問題:各語系之間 tags 不一致的文章,或是只被其中一個語言檔案引用、另一個沒有引用的圖表。

接著是幾何,在同一個檔案裡:

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

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

型別檢查能證明翻譯存在,卻證明不了它放得下兩欄之間以虛線的縱向邊界分隔。左欄標題為「原始碼說了什麼」,列出型別檢查單靠原始碼就能確認的三件事,各自打勾:LABELS 記錄中三個語系齊全、型別彼此吻合、每個語系檔案都引用了這張圖。分隔兩欄的邊界標示著字型解析、字符排版與版面配置,代表靜態分析到此為止。右欄標題為「瀏覽器畫出什麼」,把同一個小方框畫了兩次:一次裝著英文字串「flex item」,綽綽有餘,標記為放得下;另一次裝著日文字串「フレックスアイテム」,越過方框右緣,標記為溢出。原始碼說了什麼LABELS 記錄中三個語系齊全型別彼此吻合每個語系檔案都引用了這張圖這些是你寫下的文字本身的性質靜態分析到此為止字型解析 →字符排版 →版面配置瀏覽器畫出什麼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 裡是統一編碼的,畫出來的形狀卻依語言而異,所以單一組合併過的堆疊會讓繁體中文頁面帶著日文的字符形狀算繪出來。對量測而言,後果就是同一個字元在 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> 比較。兩個單位的容許值吸收掉四捨五入的誤差,在這個字級下遠小於一個字元。

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

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

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

被弄丟的容器

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

以標籤的中心點挑選容器,會在最關鍵的時候把它弄丟三個面板呈現同一張卡片與同一個標籤。第一個面板中標籤位於卡片內,中心點也在卡片內,因此找得到容器,比較結果正確。第二個面板中標籤已越過卡片右緣,但中心點仍在卡片內,所以容器仍然找得到,溢出也有被回報。第三個面板中標籤完全離開卡片,以圓點標示的中心點落在卡片之外,沒有任何矩形包含這個中心點;檢查因此改用整張圖的邊界比較,而標籤在那個範圍內綽綽有餘,於是完全沒有回報溢出。標籤放得下中心點卡片矩形標籤找到容器,比較結果正確標籤稍微溢出中心點卡片矩形標籤仍然找到容器,溢出有被回報標籤完全跑出去中心點卡片矩形標籤沒有任何矩形包含中心點,檢查因此改用整張圖比較,並回報為沒有溢出溢出越嚴重,就越不容易被回報。
只有在標籤還放得下的時候,包含關係才成立。一旦它跑出去,原本所屬的方框就不再是候選,比較也就退回整張圖表。

已經跑出卡片的標籤,就不在卡片裡面了。容器從候選清單中消失,比較退回到 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];

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

什麼都沒複製到的展開

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

// 改之前:可以運作。
const bounds = { x: holder.box.x, width: holder.box.width };
// 改之後:默默回傳一個空物件。
const bounds = { ...holder.box };

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

什麼都沒複製到的展開,以及它產生的綠色結果一條四步的連鎖由左至右進行。展開 SVGRect 什麼都沒複製到,因為 x、y、width 與 height 是原型上的存取器,而不是自有屬性。因此從結果讀取寬度會得到 undefined,而這並不是錯誤。對 undefined 做算術會產生 NaN,NaN 與容許值的比較結果為 false,溢出的分支永遠到不了。每一步都標著「沒有錯誤」。連鎖最後落在一個綠色的結果方框,回報量測了 37 頁、所有標籤都放得下,離開代碼是 0。1{ ...svgRect }x/y/width/height 是原型上的存取器,展開什麼都沒複製到沒有錯誤2bounds.width=== undefined沒有錯誤,只是少了一個值沒有錯誤3spill = NaN對 undefined做算術的結果沒有錯誤4NaN > 2 === false溢出的分支永遠到不了沒有錯誤所有標籤都放得下。量測了 37 頁。exit 0從一個手誤到綠色結果只有四步,沒有任何一步會拋出錯誤。
從一個展開到一個綠色結果只有四步,沒有任何一步會拋出錯誤。最後那個比較是它唯一可能浮上檯面的地方,而 NaN 讓它變成 false。

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

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

刻意把它弄壞

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

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 執行,而發布步驟會卡在它上面,那正是一個壞掉的日文標籤真的會上線的時刻。

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

總結

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

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

參考連結

分享這篇文章