昨天把 inline 軸的方向講完了:writing-mode 決定行進方向、direction 決定 inline 基準方向、unicode-bidi 決定 bidi 演算法的嵌入層級,三者職責不重疊;邏輯屬性早在 2020–2021 就 Baseline widely available,但只覆蓋盒模型與文字對齊,transformbackground-positionbox-shadow 至今沒有邏輯版本。那一整篇談的都是「一行往哪邊走」。今天要補的是 block 軸上更基礎、也更常爆掉的一件事:一行到底有多高。實務上,把 lang="en" 改成 lang="ar"lang="hi",第一個壞掉的通常不是方向,而是行高——按鈕文字忽然偏上、卡片高度多出 6px、列表的 48px 固定高把降部切掉。方向錯了很明顯,行高錯了很難指認,因為你設的那個數字看起來明明還在。

line-height: 1.5 這個宣告,直覺上會被讀成「這一行的高度是字級的 1.5 倍」。這句話對單一字型、單一語言、純文字的行是成立的;只要行內出現第二種字型、一個 <img>、一個 icon 元件、一段 fallback 的阿拉伯文,它就不再成立。CSS 的行內排版模型裡,line-height 決定的是每一個 inline box 自己的高度,而行框(line box)的高度是「所有 inline box 的最高頂緣到最低底緣」的距離。這兩件事在同質的行裡數值相同,在異質的行裡可以差出好幾個像素,而差多少完全由字型檔裡的數字決定,不由你的 CSS 決定。

更麻煩的是,那些字型檔裡的數字有四組,彼此不一致,而且不同瀏覽器在不同作業系統上歷史性地挑不同組。所以「同一份 CSS 在 Mac 上和 Windows 上行高不同」不是 bug,是規範刻意留白加上引擎各自選擇的結果。今天要做的事,是把這條鏈子從 units per em 一路拆到 text-box-trim:字型度量有哪幾組、誰在用哪一組、content area 和 line box 差在哪、strut 怎麼憑空撐高一行、圖片下方那 4px 的真正來源、CJK 的全形字身為什麼讓 text-box-edge: cap alphabetic 直接切到墨水、以及 size-adjust 家族怎麼把 fallback 的行高跳動壓下來。

📖 學

一個字型裡有四組垂直度量,瀏覽器只挑一組

字型的所有座標都是整數,單位是 font unit,總量由 head 表的 unitsPerEm(upem)定義。TrueType 輪廓習慣用 2048,CFF/PostScript 輪廓習慣用 1000。把 font unit 換成 CSS 像素的公式只有一條:px = fontUnit / upem × font-size。所以看到 ascender = 1854 這種數字,先問 upem 是多少——2048 的話那是 0.905em,1000 的話那是 1.854em,差了兩倍。

真正混亂的地方在於「一行該多高」這件事,OpenType 規格裡有三處來源:

來源欄位原本的意圖
hheaascender / descender / lineGap早期 Mac 的預設行距
OS/2sTypoAscender / sTypoDescender / sTypoLineGap排版意圖上的行距(規格推薦的那一組)
OS/2usWinAscent / usWinDescentWindows 的裁切邊界(墨水不該超出的範圍)
headyMin / yMax整套字型所有字符的實際 bounding box

慣例上:descendersTypoDescender負數(基線以下往下為負),usWinAscentusWinDescent 都是無號正數usWin* 原本的角色是「這個字型的墨水最多長到哪、最深探到哪」,是給光柵化裁切用的保守值,Google Fonts 的度量指引就明確要求 usWinAscentusWinDescent 必須等於整個家族最高的 yMax 與最深的 yMin。問題是有些應用程式歷史上拿 usWin* 當行距用,於是「墨水邊界」被當成「排版行距」,行就被撐得很鬆。OpenType 規格自己也寫得很清楚:拿 usWinAscentusWinDescent 決定預設行距是強烈不建議的做法,該用的是 sTypo*

為了讓字型作者能明確表態,OS/2.fsSelectionbit 7(USE_TYPO_METRICS 存在:這個位元被設起來時,應用程式應該用 sTypoAscender − sTypoDescender + sTypoLineGap 作為這個字型的預設行距。現代變數字型的建議做法是——sTypo* 一定要設好、USE_TYPO_METRICS 一定要開、而 hheaascenderdescenderlineGap 應該設成跟 sTypo* 同值,這樣不管對方讀哪一組都一致。

瀏覽器實際採用哪一組?依 Google Fonts 度量指引與社群長期量測,大致的圖像是:Firefox 尊重 USE_TYPO_METRICS,設了就用 OS/2sTypo*,沒設就用 hhea;Windows 上的瀏覽器歷史上採 usWin*,但同樣尊重 USE_TYPO_METRICS;macOS 上的引擎走 CoreText,實質對應 hhea已標存疑:這條規則來自度量指引與第三方量測,而非任何引擎的規範性文件,且隨引擎版本與平台字型後端變動;請以自己的實測為準。)這也是「Mac 上 line-height: normal 是 1.15、Windows 上變成 1.18」這類報告的來源——不是誰算錯,是兩邊讀的欄位不同。

再往下一層,還有兩個常被忽略的欄位:OS/2 版本 2 以後有 sxHeightsCapHeight。這兩個數字不影響行高,但它們是 excap 單位以及 text-box-edge: cap / ex 的依據;如果字型沒填(或填 0),瀏覽器就得回退到量測「x」與「H」的實際輪廓,結果會隨字型版本漂移。

content area、leading、line box:三個不同的高度

CSS 2.1 §10.8 的行高計算是這整個主題的地基,值得逐句拆。對非替換的 inline 元素,line-height 指定的是「用於行框高度計算的高度」——注意,不是內容的高度,是這個 inline box 的高度。而內容本身佔的區域叫 content area,它的高度規範刻意沒有定義:規格只說 content area 的高度應該基於字型,但本規範不指定如何計算。這一句留白,就是所有跨瀏覽器行高差異的合法來源。

實務上引擎的做法是 content area 高度 = A + D,其中 A 是所選度量組的 ascent、D 是 descent(取絕對值)。然後:

leading L = line-height − (A + D)
half-leading = L / 2
inline box 頂緣(基線之上)= A + L/2
inline box 底緣(基線之下)= D + L/2

leading 上下均分,這是「半行距」(half-leading)的意思,而且它可以是負的:當 A + D > line-height(例如 line-height: 1 配一個 normal 為 1.45 的 CJK 字型),half-leading 是負值,content area 會溢出 inline box。這件事會被你看見的時機是設 background-color 給一個 <span>——背景畫的是 content area,不是 line-height。所以 line-height: 1 的 highlight span 背景會上下相黏甚至重疊,line-height: 2 的背景則明顯比行距窄。這不是 bug,是背景畫 content area、行距畫 line box 的必然結果。

行框(line box)的高度則是另一條規則:取這一行上所有 inline-level box 的最高頂緣與最低底緣之間的距離。vertical-align: baseline 的意思是「把這個 inline box 的基線對齊父 inline box 的基線」——它對齊的是基線,不是頂緣也不是底緣,所以基線對齊完成之後,各個 box 的頂緣底緣自然會參差,行框就得長到能包住所有人。

這裡有個容易記錯的細節:vertical-aligntop / bottom 對齊的是行框的頂/底,而 text-top / text-bottom 對齊的是父 inline box 的 content area 頂/底。前者會被其他元素影響(因為行框是大家一起決定的),後者只看父元素的字型。這也解釋了為什麼 vertical-align: top 有時候看起來「跳來跳去」——它對齊的目標本身是動態的。

line-height: normal 為什麼不能寫進設計系統

normal 的計算值是 UA 依字型決定的「合理值」,規範建議在 1.0 到 1.2 之間,但沒有強制。引擎實作上普遍是 (A + D + lineGap) / upem。以 Arial 為例(社群常引的一組量測,已標存疑,不同版本的 Arial 數值會變):upem 2048、hhea.ascender 1854、hhea.descender −434、lineGap 67。那麼在 font-size: 100px 下:

  • content area = (1854 + 434) / 2048 × 100 ≈ 111.7px
  • line-height: normal = (1854 + 434 + 67) / 2048 × 100 ≈ 115px,也就是 1.15

三個結論從這組數字裡直接掉出來。第一,normal 比 content area 高,差的就是 lineGap——lineGap 是唯一「純行距」的欄位,它不代表任何墨水。第二,normal 的值是字型的性質,不是字級的性質,所以換字型就換行高;Arial 是 1.15,Noto Sans CJK / Source Han Sans 這一類全形字身的字型,實測常落在 1.4–1.5 之間(已標存疑:不同版本與不同引擎採用的度量組會給出不同結果,請實測),差距接近 30%。第三,因為引擎在不同平台挑不同度量組,normal 在 Mac 與 Windows 上就是不同的數,同一份 CSS 產出不同高度。

所以設計系統不能用 normal 當 token,這點大家都同意。但接下來那個「所以我們一律寫 line-height: 1.5」的結論,只解決了一半的問題——這正是第一個要更正的地方。

更正一:固定 line-height 鎖不住行框高度——公式在這裡

常見的錯誤信念是:「只要把 line-height 寫成一個固定倍數,行的高度就固定了,不受字型影響。」這是錯的。 前面的公式攤開來看就知道為什麼。

對行上第 i 個字型 run(一段連續使用同一字型的文字),設它的 ascent 為 Aᵢ、descent 為 Dᵢ(皆已乘上字級、取正值),行高為 LH

halfLeadingᵢ = (LH − Aᵢ − Dᵢ) / 2
topᵢ  = Aᵢ + halfLeadingᵢ = (Aᵢ − Dᵢ)/2 + LH/2
botᵢ  = Dᵢ + halfLeadingᵢ = (Dᵢ − Aᵢ)/2 + LH/2

topᵢ + botᵢ = LH,恆等成立——每個 inline box 自己的高度確實就是 LH。但行框高度是 max(topᵢ) + max(botᵢ)。令 dᵢ = (Aᵢ − Dᵢ)/2(這是該字型「基線偏心量」,ascent 相對 descent 的不對稱程度),代回去得到一條很乾淨的式子:

行框高度 = LH + ( max(dᵢ) − min(dᵢ) )

也就是說:只要行上所有字型的 (A − D)/2 都相同,行框高度就等於 line-height;一旦不同,行框高度就會超出 line-height,超出的量精確等於各字型偏心量的極差。 這條式子把很多零散的現象一次解釋掉:

  • 一段拉丁文裡混一個中文字,行變高——因為 CJK 字型的 A 通常大得多。
  • 一段中文裡插一個 emoji(走的是系統 emoji 字型),行變高。
  • 同一句話裡把 <code> 換成 monospace 字型,那一行比其他行高 2px。
  • 只是在句尾加了一個 <sup>,整段的行距全部被推開(vertical-align: super 會直接改 topᵢ)。

阿拉伯文與天城體是這條公式最極端的案例。 這兩種書寫系統有堆疊記號:阿拉伯文有 hamza、shadda、母音符號可以疊在字身上方,天城體有 matra、nuqta、以及往上長的 shirorekha 相關組合。Google Fonts 的度量指引提到,像越南文、天城體、阿拉伯文這類「高腳本」,如果 bounding box 特別高,在採用 usWin* 當行距的環境裡會導致非常鬆的行高;他們的對策是把 USE_TYPO_METRICS 開起來、並把 sTypo* 設成與 usWin* 同值。同時 Noto 為阿拉伯文、天城體這類腳本額外提供帶 UI 後綴的家族(Noto Sans Arabic UINoto Sans Devanagari UI),它們垂直方向更緊湊,並且與基本 Noto Sans 有相同的行高——這個 UI 家族存在的唯一理由,就是為了在介面裡讓行框高度不跳。

所以「一個阿拉伯文單字讓整行變高」的機制不是玄學:那個單字走 fallback 用了另一個字型,它的 dᵢ 遠大於周圍拉丁文的 dᵢmax(dᵢ) − min(dᵢ) 變大,行框就長高。而且因為變高的量取決於「這一行有沒有那個 run」,同一段文字裡有些行高、有些行不高,垂直節奏直接崩掉。列表項如果用 height: 48px 硬寫,就變成裁切;用 min-height 就變成參差。

順帶更正一個相關的說法:line-height 決定基線在行框裡的位置在正中間」也是錯的。 基線距行框頂緣的距離是 A + halfLeading,不是 LH / 2。以 Arial、font-size: 16pxline-height: 24px 算:A = 1854/2048×16 ≈ 14.48D = 434/2048×16 ≈ 3.39halfLeading = (24 − 17.87)/2 ≈ 3.06,基線在頂緣下方 14.48 + 3.06 ≈ 17.5px 處,不是 12px。這 5.5px 的偏差就是「文字在按鈕裡看起來偏下」的來源——你用 padding: 12px 上下對稱,視覺上並不對稱。

strut:那個看不見、但撐起整行的匿名字

每一個建立 inline formatting context 的區塊容器,都會在行的開頭插入一個看不見的東西:strut。規範的定義是——一個零寬度的隱形字符,帶著這個盒子「first available font」的 AD,並繼承容器的 fontline-height。它不畫任何東西,但它參與行框高度計算。

兩個直接後果。第一,空的區塊容器不是零高。 <div style="font-size:16px; line-height:1.5"></div> 如果裡面有一個文字節點(哪怕只是空白),高度就是 24px。這是「我把內容清空了為什麼還有一條 24px 的空白」的答案。第二,也是更反直覺的:如果一個 inline box 完全不含字符,它會被視為含有一個 strut(帶它自己的 AD)。於是這種東西成立:

<p style="font-size:16px; line-height:1.5">Hello<span style="font-size:64px"></span></p>

那個 <span> 是空的、零寬、不畫任何像素,但它有自己的 strut,AD 按 64px 算,d 值遠大於周圍的 16px 文字,於是整行被撐高。一個什麼都沒有的空元素把行框撐開——這是行內排版最違反直覺的行為,也是 CSS reset 裡「給空 inline 元素設 line-height: 0」這種奇怪招式的由來。

strut 也解釋了 line-height: 0 這個常見技巧為什麼有效又為什麼危險。把父容器設 line-height: 0,strut 的高度被壓成 0(content area 仍是 A + D,但 half-leading 變成 −(A+D)/2topbot 相互抵銷到 0),行框就只由真正的內容決定。有效;但同時你也把所有繼承下去的文字行距歸零了,所以只能用在確定沒有文字的容器(icon wrapper、image wrapper)。

還有一個更精確的補充:strut 用的是「first available font」——也就是 font-family 清單裡第一個已經可用的字型。webfont 還在載入時,first available font 是 fallback;載入完成後換成 webfont。如果兩者的 AD 不同,strut 的高度就在載入完成的那一瞬間改變——這是 CLS 的一個隱形來源,而且它跟「文字寬度變了」是兩件獨立的事,只調 size-adjust 不一定治得好,得連 ascent-overridedescent-override 一起調。

更正二:圖片下方的空隙不是 4px,也不是 margin

普遍的解釋是「<img> 有預設 margin」或「HTML 原始碼裡的換行變成空白」。兩個都錯。 真正的原因是三件事的組合:

  1. <img> 是 inline-level 的替換元素,預設 vertical-align: baseline
  2. 替換元素的基線定義是它的下 margin 邊緣。也就是說,圖片的底邊會被放在基線上。
  3. 行框必須包住 strut,而 strut 的底緣在基線下方 D + halfLeading 的位置。

所以圖片底邊(在基線上)到行框底緣之間,剩下的正是 strut 的降部空間加半行距。這個值不是常數 4px,它等於 D + (LH − A − D)/2。用預設值粗算:font-size: 16pxline-height: normal、典型無襯線字型 D ≈ 0.21em ≈ 3.4pxlineGap 通常很小,於是空隙大約 3–5px;把 font-size 改成 32px,空隙就變成 6–10px。換句話說,這個「4px」是你的字型、字級、行高的函數,改任何一個它就變。 我見過的最好驗證方式:把父容器 font-size 設成 100px,空隙會誇張到 20px 以上,一眼就知道它跟字型度量有關。

對應的修法,以及各自的副作用:

  • img { display: block }——最乾淨,圖片離開 inline formatting context,不再有基線問題。但如果你需要圖文同行就不能用。
  • img { vertical-align: bottom }——把圖片底緣對齊行框底緣而不是基線。有效,但這時候是圖片去適應行框,如果行框因為別的原因很高,圖片會被推下去。
  • img { vertical-align: middle }——常被推薦,但它對齊的是「圖片的垂直中心對齊父元素基線加上半個 x-height」,不是真的居中;空隙通常變小但不會歸零,而且會隨字型的 sxHeight 變動。
  • 父容器 line-height: 0——直接壓掉 strut,但會影響繼承。
  • 父容器 font-size: 0——最暴力,連 AD 都歸零,永遠有效,但把所有文字都毀了,只能用在純 wrapper。

同一套邏輯要延伸到 inline-block,而且這裡有個更陰險的規則差異:inline-block 的基線是它「最後一個 in-flow 行框」的基線;但如果它的 overflow 不是 visible,基線就變成下 margin 邊緣。 這條規則造成一個經典的鬼故事——兩個並排的 inline-block 卡片,你給其中一個加上 overflow: hidden(可能只是為了裁圓角),它就整個往上跳了幾像素,因為它的基線定義換了一套。空的 inline-block(沒有任何行框)也走「下 margin 邊緣」這條,所以空的和非空的 inline-block 並排時對不齊。

順著這條規則,inline-block 之間那個「幾 px 的神秘間隙」才是真的來自 HTML 原始碼裡的空白字元(inline-level 之間的 white space 會被摺疊成一個空格並渲染出來),那個要用 font-size: 0、負 margin,或者根本改用 flex/grid 來解。兩個「神秘間隙」的成因完全不同:圖片下方的是垂直的、來自 strut 降部;inline-block 之間的是水平的、來自空白字元。 把它們混在一起講是很多教材的錯誤。

text-box-trim / text-box-edge:2026 年的實作狀態

text-box-trimtext-box-edge(CSS Inline Layout Module Level 3)的目的很單純:把區塊容器第一行上方最後一行下方那段來自字型度量的半行距切掉,讓你的 padding 從「邊到 box」變成「邊到墨水」。這個功能早期的提案名叫 leading-trim,後來 CSSWG 為命名爭論了很久(csswg-drafts issue #10675),最終定案成 text-box-trim / text-box-edge,加上 text-box 簡寫。

語法上:

  • text-box-trim: none | trim-start | trim-end | trim-both,決定切哪一端。
  • text-box-edge: <text-edge>,決定「切到哪裡」。over(上)邊可用 textcapexideographicideographic-ink;under(下)邊可用 textalphabeticideographicideographic-inkcap 切到大寫字高(拉丁大寫字母 X 的高度),ex 切到小寫 x 高度,text 切到 content area 邊緣。
  • text-box 是簡寫,例如 text-box: trim-both cap alphabetic

支援狀態(已標存疑,版本號來自 MDN 與 Firefox 154 發行說明的二手整理,請以 Baseline 官方資料與自己的實測複核):

引擎text-box-trim / text-box-edgeideographic / ideographic-ink
Chrome / Edge133 起至 2026-08 尚未支援
Safari18.2 起至 2026-08 尚未支援
Firefox154 起(2026-08-18 發行)預設開啟154 起支援

Baseline 狀態:自 2026 年 8 月起為 newly available(Firefox 154 補上最後一塊)。這個日期很新,意味著到 2026 年 9 月的今天,你仍然需要考慮舊版瀏覽器的回退路徑——而回退是好處理的,因為 text-box-trim 不支援時就是「多出原本的半行距」,也就是今天大家已經在忍受的狀態。用 @supports (text-box-trim: trim-both) 包住調整後的 padding 就好。

這裡要更正一個很容易產生的誤解:text-box-trim 不會改變行高。 它只影響區塊容器的第一行上方最後一行下方的邊緣,中間各行之間的距離仍然完全由 line-height 決定。所以它解決的是「標題上下的空隙不對稱」、「按鈕文字沒有真正居中」、「卡片內文與邊框的距離看起來比設計稿大」這一類邊界問題,而不是「段落內部的行距節奏」。如果你期待它讓多行文字變得可以貼著基線網格排,那會失望。

另一個實務細節:text-box-edge 依賴的 capex 來自 OS/2sCapHeightsxHeight。字型沒填這兩個欄位時,瀏覽器會回退到量測實際輪廓,各引擎的量測方式不保證一致——所以 text-box-trim 讓邊界「更可預測」,但在字型 metadata 不完整時並不是「完全確定」。做設計系統時,值得把這件事寫進字型採用的檢查清單。

CJK 的字身、標點與禁則

CJK 字型的設計前提跟拉丁字型不同:漢字被設計成填滿(或幾乎填滿)全形字身(em box)。這帶來三個連鎖後果。

第一,A + D 天然比拉丁字型大。 拉丁字型的 content area 大約 1.1em 上下,CJK 字型常在 1.3–1.5em;line-height: normal 因此落在 1.4–1.5(已標存疑,隨字型與引擎變動)。這是「中文網頁用 line-height: 1.2 看起來很擠、但拉丁文用 1.2 還可以」的度量層原因,不只是視覺密度的問題。

第二,漢字的墨水會伸到 alphabetic baseline 下方。 全形字身的下緣(ideographic descender)通常在基線下方約字身高度的 10–15%。這件事讓 text-box-edge: cap alphabetic 對 CJK 直接切到墨水——alphabetic 這個 under edge 就是拉丁基線,切到那裡等於把「口」「日」「圓」這些字的下半截切掉。這正是 ideographicideographic-ink 兩個關鍵字存在的理由:ideographic 對齊全形字身的上下緣,ideographic-ink 對齊實際墨水邊界。MDN 目前記載這兩個值只有 Firefox 154 以上支援,至 2026 年 8 月 Chrome/Edge/Safari 都不支援(已標存疑),而且它們的精確行為在 csswg-drafts issue #10928 裡還在討論。結論很直接:2026 年,text-box-trim 在純拉丁介面可以放心用,在 CJK 介面只能有條件地用,而混排介面基本上還不能靠它做精確對齊。

第三,全形標點留下大量空白。 「,」「。」「、」「:」在中日文字型裡都佔滿一個全形字身,實際墨水只有其中一小塊,於是連續標點或行首行尾標點會出現視覺上的大洞。text-spacing-trim 就是為此而生:它依 JLREQ(日文排版需求)與 CLREQ(中文排版需求)對 CJK 標點做 kerning。值包括 normal(行首的開引號類與行尾的閉引號類保持全形,其他情況收緊)、space-all(全部保持全形)、space-firsttrim-start。支援狀態:Chrome/Edge 123 起(2024 年 3 月),Firefox 與 Safari 依我查到的資料仍未支援(已標存疑,這個數字來自 caniuse/MDN 的二手整理,2026 年的現況請複核)。

與它成對的是 text-autospace,處理的是另一個問題:CJK 與非 CJK(拉丁字母、數字)相鄰時該不該插入四分之一空格。ideograph-alpha 只在表意文字與非表意字母之間加空隙。Chromium 從 120 起在 flag 後面實作,我查到的資料說它自 2025 年 11 月成為 Baseline newly available(已標存疑)。實務上的重點是:這兩個屬性職責不重疊——text-spacing-trim 減少全形標點內部的空白,text-autospace 增加中西文之間的空白。以前這兩件事都是靠 JS 在 DOM 裡塞 <span> 或 hairline space 做的,現在有原生屬性,而且原生版本不會破壞使用者選取與複製的文字內容。

禁則(kinsoku)走的是第三個屬性:line-break 值有 auto | loose | normal | strict | anywhere,控制斷行規則的嚴格程度——特別是標點與符號能不能出現在行首行尾。這個屬性自 2020 年 7 月起 Baseline widely available,是今天這一整組 CJK 屬性裡唯一可以無條件使用的。要注意它的邊界:line-break 只控制能不能在某處斷,不處理「追い込み/追い出し」(把標點壓進上一行或推到下一行的擠壓調整),也不處理標點懸掛——懸掛是 hanging-punctuation 的事,而那個屬性長期只有 Safari 支援(已標存疑)。所以「中文標點排版做對」在 2026 年仍然是三到四個屬性的組合,而且支援度各不相同。

更正三:font-size-adjustsize-adjust 不是同一件事,以及垂直韻律為什麼守不住

這兩個名字很像的東西經常被混用,但它們作用的層級不同、能解決的問題也不同。

font-size-adjust元素上的 CSS 屬性。現代語法是兩個值:font-size-adjust: <metric> <value><metric> 可以是 ex-heightcap-heightch-widthic-widthic-height<value> 是比例或 from-font。它的作用是「不管實際用了哪個字型,都把該度量調成字級的指定比例」——典型用途是讓 fallback 字型的 x-height 看起來跟 webfont 一樣大,避免 swap 時字忽然變小或變大。它自 2024 年 7 月起 Baseline newly available;from-font 這個值較晚,Chrome 127 起、Safari 17 起(已標存疑,版本號來自 caniuse 的二手整理)。Firefox 其實 2008 年就實作了單值版本,Chromium 與 WebKit 遲到十幾年。

size-adjust@font-face 描述符,不是元素屬性。它是一個百分比,等比縮放那一個字型的所有輪廓與度量。差別在這裡:font-size-adjust 只調整字符繪製的視覺大小;size-adjustADlineGap 一起縮放,因此它會改變 content area 與 line-height: normal要修「fallback 造成的行高跳動」,你要的是 size-adjust 這一族,不是 font-size-adjust

完整的處方是四個描述符一起用:

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107.12%;
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
}

ascent-overridedescent-overrideline-gap-override 直接覆寫該字型回報的度量,百分比是相對 font-size。把 webfont 的 A / upemD / upemlineGap / upem 算出來填進 fallback 的 @font-face,fallback 與 webfont 的 dᵢ 就一致,前面那條 行框高度 = LH + (max dᵢ − min dᵢ) 的極差歸零,行高在 swap 前後完全不動——CLS 裡屬於字型的那一塊就消失了。這正是 Next.js 的 next/font、Fontaine、Fontpie 這類工具在背後自動生成的東西。

三個要注意的界線。第一,這些描述符只對 @font-face 宣告的字型有效;你在 font-family 清單尾端寫的 sans-serif、或者瀏覽器自己選的 fallback 字型,都不經過 @font-face,度量無法覆寫。要蓋,就得用 src: local(...) 把系統字型包成一個 @font-face——而 local() 在不同平台上抓到的字型不同,這件事本身就不可靠。第二,Safari 依我查到的資料不支援 ascent-overridedescent-overrideline-gap-overridesize-adjust 支援狀況需另外複核;兩者都已標存疑,請以實測為準)。所以這套處方在 Safari 上只有部分效果——這是 2026 年做 CLS 優化時很容易踩到的落差。第三,emrem 與無單位 line-height 是基於 computed font-size 計算的,不隨 font-size-adjust 改變(已標存疑,建議實測驗證),所以用 font-size-adjust 調視覺大小時,行高與間距不會跟著調——這通常是你想要的,但如果你以為它會跟著調,就會覺得字級對了間距卻不對。

最後回到垂直韻律。基線網格(baseline grid)在印刷上很有效:每一條基線落在等距的格線上,整頁讀起來有穩定的節奏。在網頁上要維持它,需要同時成立四件事:(a) 每個行框高度是格距的整數倍;(b) 第一條基線距容器頂緣的偏移可預測;(c) 所有 inline-level 的東西(icon、inline-block、表單控件、<img>)的基線都落在格線上;(d) 上下 margin 也是格距的整數倍且不發生意外的摺疊。

多語系產品裡,(a) 被前面那條極差公式破壞——fallback 一進來行框就不是整數倍了;(b) 被 A + halfLeading 破壞——第一條基線的偏移是字型的函數,換語言就變;(c) 被 inline-blockoverflow 規則與替換元素的基線定義破壞;(d) 在 CJK 與拉丁混排、且各語言用不同 line-height token 的情況下幾乎不可能對齊。再加上響應式:容器寬度變化會改變斷行數,而不同語言的斷行數差異可以很大(德文長複合詞、泰文無空格、阿拉伯文較短),同一個卡片在不同 locale 下行數不同,網格就對不上。

所以務實的立場是:放棄真正的基線網格,改成「修邊後的盒子 + 間距刻度」。 具體做法是用 text-box-trim 把每個文字區塊的上下邊界變成墨水邊界(在只有拉丁文的介面上這已經可以做到),然後所有垂直間距用 margin/gap 的 token 表達,而不是靠行高去湊。line-height 則按語言分 token——CJK 一組、拉丁一組、阿拉伯文/天城體一組,並且用 ascent-override 家族把同一語言內的 webfont 與 fallback 度量對齊。這樣你得到的不是完美的節奏,而是可預測的節奏,而在多語系產品裡,可預測比完美值錢得多。

🧠 記

  • 字型座標的換算只有一條式子:px = fontUnit / upem × font-size;upem 常見 2048(TrueType)與 1000(CFF)。
  • 「一行多高」在字型檔裡有三處來源:hheaascender/descender/lineGapOS/2sTypo*OS/2usWin*sTypo* 是規範推薦的排版行距,usWin* 是裁切邊界,拿 usWin* 當行距是規格明文不建議的做法。
  • OS/2.fsSelection bit 7(USE_TYPO_METRICS)是字型作者的表態:設了就該用 sTypoAscender − sTypoDescender + sTypoLineGap 當預設行距。現代變數字型應該設好 sTypo*、開這個位元、並讓 hhea 同值。
  • CSS 2.1 §10.8 刻意不定義 content area 的高度,只說「應基於字型」。所有跨引擎行高差異都合法地源自這句留白。
  • leading = line-height − (A + D),上下均分為 half-leading,可以是負值。背景色畫的是 content area(A + D),行距畫的是 line-height——這是背景重疊/背景過窄的成因。
  • 行框高度 = LH + (max dᵢ − min dᵢ),其中 dᵢ = (Aᵢ − Dᵢ)/2固定 line-height 鎖不住行框高度,只鎖住單一 inline box 的高度。
  • 基線距行框頂緣的距離是 A + halfLeading不是 LH / 2。這是「按鈕文字看起來偏下」的度量層原因。
  • 每個 IFC 都有 strut(帶容器字型與 line-height 的零寬隱形字符);不含任何字符的 inline box 也被視為含有自己的 strut——所以一個空的 <span style="font-size:64px"> 能憑空撐高整行。
  • 圖片下方的空隙不是 4px 常數、不是 margin、不是原始碼空白:<img> 基線是它的下 margin 邊緣,空隙 = D + halfLeading,隨字型/字級/行高變。inline-block 之間的水平間隙才是原始碼空白造成的。
  • inline-block 的基線是最後一個 in-flow 行框的基線;overflow 不是 visible 時改成下 margin 邊緣。加一行 overflow: hidden 會讓元素垂直跳位。
  • text-box-trim / text-box-edge2026 年 8 月起 Baseline newly available(Chrome/Edge 133、Safari 18.2、Firefox 154;已標存疑)。它只修第一行上方與最後一行下方,不改變行距。
  • ideographic / ideographic-ink 依 MDN 記載僅 Firefox 154+ 支援(已標存疑)。對 CJK 用 text-box-edge: cap alphabetic 會切到墨水,因為漢字墨水伸到 alphabetic baseline 下方。
  • CJK 三個屬性職責不重疊:text-spacing-trim 收緊全形標點內部空白(Chrome/Edge 123+,Firefox/Safari 未支援,已標存疑)、text-autospace 增加中西文之間空隙、line-break 控制禁則嚴格度(2020 年 7 月起 widely available)。標點懸掛是 hanging-punctuation,長期只有 Safari。
  • font-size-adjust(元素屬性,調視覺大小)≠ size-adjust@font-face 描述符,等比縮放輪廓與度量)。修行高跳動要用 size-adjust + ascent-override / descent-override / line-gap-override,不是 font-size-adjust
  • 度量覆寫描述符只對 @font-face 宣告的字型有效,蓋不到瀏覽器自選的系統 fallback;Safari 依查到的資料不支援 *-override 系列(已標存疑)。
  • 基線網格在多語系響應式產品裡守不住(fallback 破壞整數倍、第一基線偏移隨字型變、替換元素基線規則不一、各語言斷行數不同)。務實做法是「修邊後的盒子 + 間距 token + 分語言的 line-height token」。

✍️ 實踐

今天做一件事:做一頁量測台,用同一個 line-height 跑五種語言與五種字型,把行框高度、content area 高度、基線位置全部量出來。 15–30 分鐘可以做完,做完之後你對「行高」這個詞的直覺會永久改變。

  1. 建一個 line-metrics.html,寫一個 CSS class:.probe { font-size: 32px; line-height: 1.5; background: rgba(0,128,255,.15); }。背景色故意留著——它畫的是 content area,會直接讓你看見 content area 與行框的落差。

  2. 準備五段文字,各自一行,內容分別是:拉丁文(Hamburgefonstiv)、繁體中文(量測行高的基準線)、阿拉伯文(مرحبا بالعالم)、天城體(नमस्ते दुनिया)、以及一段混排(拉丁 + 中文 + 阿拉伯文擠在同一行)。每段包在一個 <div class="row"><span class="probe">…</span></div> 裡。

  3. 準備五種字型堆疊,用 CSS 變數切換:系統預設(sans-serif)、Arial, sans-serif、一個 CJK 字型("Noto Sans TC", sans-serif 或系統的 "PingFang TC")、monospace、以及一個刻意只給拉丁的 webfont(讓非拉丁字元必然走 fallback)。做成五個區塊,或用一個 <select> 切。

  4. 寫一段 JS 把數字印出來。關鍵是量三個東西,不要只量一個:

document.querySelectorAll('.row').forEach(row => {
  const span = row.querySelector('.probe');
  const lineBox = row.getBoundingClientRect().height;      // 行框高度
  const content = span.getBoundingClientRect().height;      // content area 高度
  const cs = getComputedStyle(span);
  row.dataset.report =
    `lineBox=${lineBox.toFixed(2)} content=${content.toFixed(2)} ` +
    `lh=${cs.lineHeight} fs=${cs.fontSize}`;
});

row 的高度是行框高度,spangetBoundingClientRect().height 是 content area 高度(inline 元素的 client rect 量的是 content area,不是 line-height——這一點自己驗一次)。把 data-report::after 顯示出來,或直接 console.table

  1. 量基線位置。在每一行裡放一個 <span style="display:inline-block;width:1px;height:1px;vertical-align:baseline"></span>,它的 offsetTop 相對 row 就是基線位置(因為 inline-block 的基線是下 margin 邊緣,1px 高的它底邊就在基線上)。把這個數字跟 lineBox / 2 對比。

  2. 加三個開關按鈕,觀察差異:(a) 把 line-height1.5 改成 1,看背景是否開始重疊;(b) 在混排那一行的末尾插一個空的 <span style="font-size:80px"></span>,看行框有沒有變高;(c) 給 .probe 加上 text-box: trim-both cap alphabetic,看第一行上緣有沒有被切掉、以及中文那一行的字有沒有被切到墨水。

  3. 最後一步:把量到的數字反推字型度量。用 content / fontSize 得到 (A + D) / upem;用 baselineOffset 減去 half-leading 得到 A / upem。跟 fc-query(Linux)、Font Book、或線上的 font metrics 檢視工具對一次答案。

自我檢查

  • 五種字型下,同一個 line-height: 1.5,你量到的行框高度是不是至少有兩個不同的數字?如果全部相同,很可能是所有 run 都用了同一個字型——把混排那一行的字型換成只含拉丁的 webfont 再試。
  • 混排那一行的行框高度,是不是大於 32 × 1.5 = 48px?如果是,把超出的量算出來,看它是否接近你反推的各字型 (A − D)/2 極差。
  • 那個空的 <span style="font-size:80px"> 有沒有把行框撐高?如果沒有,檢查它是否被 CSS reset 設了 line-height: 0font-size: inherit
  • 基線位置是不是不等於 lineBox / 2?把差值算出來,這就是你未來調 padding 時要補償的量。
  • 加上 text-box: trim-both cap alphabetic 之後,中文那一行的「量」「線」等字下緣有沒有被切?如果有,記下來——這就是為什麼 CJK 需要 ideographic 而不能用 alphabetic
  • 在至少兩個瀏覽器(例如 Chrome 與 Firefox)跑同一頁,比對數字。差異出現在哪個字型上?那通常就是 USE_TYPO_METRICS 沒設好的那一個。

🔗 延伸學習

💬 問 AI

我在做一個多語系網頁產品(繁中 + 英文 + 阿拉伯文 + 印地文),要處理行高問題。
請針對以下我目前的理解逐條判斷對錯,並在你「查不到可靠來源」的地方直接說查不到,不要推測補齊。
 
我目前的主張:
1. 字型的預設行距在 OpenType 裡有三處來源:hhea 的 ascender/descender/lineGap、
   OS/2 的 sTypoAscender/sTypoDescender/sTypoLineGap、OS/2 的 usWinAscent/usWinDescent。
   規格建議用 sTypo*,並用 fsSelection bit 7 (USE_TYPO_METRICS) 表態。
2. CSS 2.1 §10.8 刻意不定義 content area 的高度,只說「應基於字型」。
3. leading = line-height − (A + D),上下均分;可以為負,導致 content area 溢出 inline box。
   background-color 畫的是 content area,不是 line-height。
4. 行框高度 = line-height + (max dᵢ − min dᵢ),其中 dᵢ = (Aᵢ − Dᵢ)/2 是各字型 run 的基線偏心量。
   所以固定 line-height 並不能鎖住行框高度。請幫我驗證這條推導。
5. 基線距行框頂緣的距離是 A + halfLeading,不是 line-height / 2。
6. 不含任何字符的 inline box 會被視為含有自己的 strut,所以空的
   <span style="font-size:80px"></span> 能撐高整行。
7. <img> 下方的空隙成因是「替換元素的基線是其下 margin 邊緣」+「strut 的降部與半行距」,
   數值 = D + halfLeading,不是常數 4px、不是 margin、也不是原始碼空白。
8. inline-block 的基線是最後一個 in-flow 行框的基線,但 overflow ≠ visible 時改為下 margin 邊緣。
9. 修 fallback 造成的行高跳動要用 @font-face 的 size-adjust / ascent-override /
   descent-override / line-gap-override,而不是元素上的 font-size-adjust。
 
我不確定、需要你查證後給出「現況 + 來源 + 查證日期」的部分:
A. 各引擎實際採用哪一組垂直度量?特別是:Firefox 是否尊重 USE_TYPO_METRICS?
   Windows 上的 Chromium 是否採 usWin*?macOS 上是否實質對應 hhea?
   請指出這些說法有沒有規範性或引擎官方文件的依據,或者只是社群量測。
B. text-box-trim / text-box-edge 在 2026-09 的精確狀態:Chrome/Edge、Safari、Firefox 的最低版本,
   以及 Baseline 是 newly available 還是 widely available、判定日期是哪一天。
C. text-box-edge 的 ideographic 與 ideographic-ink 目前有哪些引擎支援?
   規範上這兩個值的精確定義是否已定案(csswg-drafts 上還有未結的 issue 嗎)?
D. text-spacing-trim 與 text-autospace 在 2026-09 的支援狀態(含 Firefox 與 Safari),
   以及它們各自的預設值與實際生效條件(是否需要 lang 屬性)。
E. ascent-override / descent-override / line-gap-override 與 size-adjust 在 Safari 的支援狀態
   是否已改變?如果 Safari 仍不支援 *-override,有沒有官方或半官方的替代處方?
F. font-size-adjust 是否會影響 em / rem / 無單位 line-height 的計算?
   請引用規範文字回答,不要用直覺。
 
輸出格式:先給「判定表」(每條標 正確 / 部分正確 / 錯誤 / 查不到 + 一句理由),
再給 A–F 的查證結果(每項附連結與你看到的日期),最後列出「你認為我漏掉、
但在多語系產品裡會實際造成行高問題的三件事」。