若要設定瀏覽器文字預覽的尺寸,請在預定的視埠寬度下測量已排版的 DOM 元素。單行文字的度量值請使用 Canvas。本指南將示範如何在 Mac 上比較兩者,並了解寬度和高度為何不同。

實際上,這項工作是要找出合成卡片標籤在瀏覽器視埠變動時需要多少空間。實用的結果會一併記錄視埠、可用文字寬度、字型設定和測量出的方框。若沒有這些條件,單獨的寬度數值就難以重現。

應該測量哪種文字寬度和高度?

瀏覽器 API 可回答多種不同的尺寸問題。開始計算前,請先選擇符合你要做之決策的測量方式。

你的問題 測量方式 所代表的內容
單行字串不換行時,水平延伸多遠? Canvas measureText(text).width 文字推進寬度
Canvas 字形形狀的邊界在哪裡? Canvas actualBoundingBox… 屬性 相對於對齊點和基線的字形邊界
卡片佔用多少排版空間? 元素 getBoundingClientRect() 其邊界方框,包含內距和邊框
換行後文字片段的位置在哪裡? Range getClientRects() 與所選文字相關的矩形
包住這些文字片段的矩形是什麼? Range getBoundingClientRect() 合併後的外接矩形

Canvas 的推進寬度可能與字形寬度不同:斜體形狀可能超出其推進寬度。若要取得字形邊界,寬度請計算為 actualBoundingBoxLeft + actualBoundingBoxRight,高度則計算為 actualBoundingBoxAscent + actualBoundingBoxDescent。這些測量值使用 CSS 像素。TextMetrics — MDN

元素測量值包含內距和邊框,而僅測量文字的 Range 會使用字型度量值測量所選文字。兩者都不應被描述為實際繪製像素的精確矩形。W3C 規格定義,水平 Range 文字矩形是使用升部、降部和文字推進寬度計算。Element: getBoundingClientRect() method — MDN、CSSOM View Module — W3C

若要預覽瀏覽器中的尺寸,請使用元素方框來分配空間,並用 Range 矩形檢查文字流向。Canvas 測量值則另作為未換行字串的參考。

先設定視埠和文字條件

在 Mac 上的 Chrome 中,開啟 DevTools,啟用 Toggle device toolbar,並在 Dimensions 下選擇 Responsive。在視埠欄位中輸入預定的寬度和高度。Chrome 說明了數值輸入和拖曳調整視埠大小的方法。若要預覽桌面版,當此控制項可用時,請使用桌面裝置類型。Simulate mobile devices with device mode — Chrome for Developers

請區分視埠寬度和文字寬度。卡片可能有最大寬度、外側間距、內距和邊框。瀏覽器寬度增加後,一旦卡片達到最大寬度,其文字區域就不會再增加。

例如,假設卡片的邊界方框寬度為示意用的 360px,左右內距各為 16px,且有兩條 1px 邊框。其可用內容寬度為 360 − 32 − 2 = 326px。這就是可供文字換行的寬度。元素的邊界矩形仍會回報 360px。這符合文件所述的邊界方框測量行為。Element: getBoundingClientRect() method — MDN

請符合預期的字型系列、字重、大小、樣式、行高、字距和換行規則。特別是,white-space: normal 會合併空白並允許換行,而 pre-wrap 會保留空白和原始碼中的換行,同時仍允許換行。使用其中一種設定的預覽,無法可靠地代表另一種設定。white-space — MDN

也要區分字型大小和行高。在水平書寫模式中,line-height 會參與行方框高度的計算;它並不代表字母可見部分的高度。因此,16px 字型搭配 24px 行高時,所需的高度計算會不同於 Canvas 字形邊界的計算。line-height — MDN

執行完整的合成文字預覽

開啟空白分頁,開啟 DevTools,選取 Console,並執行以下完整的瀏覽器指令碼一次。它會建立一張中性卡片和一份測量報告。無須使用程式庫、外部字型、既有選取器或 HTML 檔案。再次執行前,請重新載入空白分頁。

卡片會隨視埠調整,直到邊界方框寬度達到 480px。視窗或模擬視埠尺寸變更時,報告也會更新。此範例刻意限定為樣式一致的純水平文字區塊。瀏覽器相依的程式碼已經檢查,但準備期間未在瀏覽器中執行。

(async () => {
  const text =
    "Sample collection: round forms, soft gradients, and simple patterns.";

  document.body.style.margin = "0";

  const card = document.createElement("div");
  card.textContent = text;
  card.lang = "en";
  card.dir = "ltr";
  card.style.cssText = [
    "box-sizing: border-box",
    "width: min(480px, calc(100% - 48px))",
    "margin: 24px",
    "padding: 16px",
    "border: 1px solid #777",
    "font: normal 400 16px/24px Arial, sans-serif",
    "font-kerning: normal",
    "letter-spacing: 0px",
    "word-spacing: 0px",
    "white-space: normal",
    "overflow-wrap: normal",
    "word-break: normal",
    "hyphens: none",
    "color: #222",
    "background: #f4f4f4"
  ].join(";");

  const report = document.createElement("pre");
  report.style.cssText =
    "margin: 24px; white-space: pre-wrap; overflow-wrap: anywhere;";
  document.body.append(card, report);

  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    report.textContent = "A Canvas 2D context is unavailable.";
    return;
  }

  // Establish layout before waiting for the fonts used by that layout.
  card.getBoundingClientRect();
  await document.fonts.ready;

  const format = value =>
    Number.isFinite(value) ? value.toFixed(2) : "unavailable";

  function measure() {
    const style = getComputedStyle(card);
    const box = card.getBoundingClientRect();
    const horizontalExtras =
      parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) +
      parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);

    const range = document.createRange();
    range.selectNodeContents(card);
    const textBox = range.getBoundingClientRect();
    const fragments = Array.from(range.getClientRects());

    ctx.font = "normal 400 16px Arial, sans-serif";
    ctx.fontKerning = "normal";
    ctx.direction = "ltr";
    ctx.textAlign = "left";
    ctx.textBaseline = "alphabetic";
    const metrics = ctx.measureText(text);
    const inkWidth =
      metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
    const inkHeight =
      metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;

    report.textContent = [
      "All dimensions below are CSS pixels.",
      `Layout viewport: ${document.documentElement.clientWidth} x ${document.documentElement.clientHeight}`,
      `Card border box: ${format(box.width)} x ${format(box.height)}`,
      `Available text width: ${format(box.width - horizontalExtras)}`,
      `Range bounds: ${format(textBox.width)} x ${format(textBox.height)}`,
      `Range fragment count: ${fragments.length}`,
      `Canvas unwrapped advance: ${format(metrics.width)}`,
      `Canvas glyph bounds: ${format(inkWidth)} x ${format(inkHeight)}`,
      `Computed line height: ${style.lineHeight}`
    ].join("\n");
  }

  measure();
  window.addEventListener("resize", measure);
})().catch(error => console.error("Preview failed:", error));

元素和 Range 呼叫會使用瀏覽器的實際排版結果。Canvas 則將相同的合成字串當作單一未換行文字列測量。HTML 標準的 Canvas 文字準備程序會將 ASCII 空白字元替換為空格;將換行字元傳給 measureText() 並不會建立具有多行的 CSS 段落。HTML Standard: The canvas element — WHATWG

此指令碼會在建立排版後等待 document.fonts.ready。MDN 指出,此 Promise 會在已使用字型的載入和排版作業完成後履行。這不代表每個已宣告的字型都已載入,也不代表畫面顯示的一定是原先優先指定的字型。Document: fonts property — MDN

閱讀結果並判斷文字是否放得下

先查看「可用文字寬度」,再比較兩種視埠尺寸下的卡片高度。換行增加一行時,卡片高度會改變,但 Canvas 未換行文字的推進寬度可能維持不變。這種組合有助於辨別是換行方式改變,還是字型改變。

以下寬度是示意計算值,並非實測字型結果。假設排版視埠為指定寬度,且指令碼的樣式保持不變。

排版視埠寬度 卡片邊界方框寬度 可用文字寬度
360px 312px 278px
800px 480px 446px
1200px 480px 446px

最後兩列說明了為何加寬瀏覽器後,文字換行方式可能不變:卡片已達最大寬度。只改變視埠無法增加卡片的內容空間。

若是只含樣式一致文字的簡單區塊,示意中的三行版面搭配 24px 行高,會佔用 72px 的行方框空間。再加上垂直內距 32px 和邊框 2px,卡片高度示意值為 106px。這個算式有助於理解結果;當內容或樣式變得更複雜時,仍應以測得的 DOM 高度為準。line-height — MDN

不要自動將 Range fragment count 當成通用的行數。Range API 會依照其選取規則回傳文字和元素矩形。豐富文字、巢狀元素和分段排版可能產生更複雜的一組矩形。此範例只選取單一純文字節點,讓診斷結果容易理解。CSSOM View Module — W3C

同樣地,避免使用 ceil(unwrappedWidth / availableWidth) 估算換行後的高度。這個公式假設文字能在任意水平位置斷行。瀏覽器換行必須遵循設定的空白和斷行行為,因此應直接測量換行後的區塊。white-space — MDN

測量結果不一致時應檢查什麼?

先檢查測量範圍。全寬的卡片矩形和較窄的 Range 矩形可能同時正確。卡片會預留排版空間;Range 描述的是所選文字。Canvas 字形高度則是用途不同的第三種數值。

接著檢查字型是否就緒。如果加入已宣告的網頁字型,document.fonts.load(fontSpecification, sampleText) 可以明確要求先載入相符的字型,再進行測量。請處理 Promise 拒絕情況,避免默默使用替代結果。選用的文字引數會依 Unicode 範圍篩選;它不會驗證個別字形是否存在。FontFaceSet: load() method — MDN

計算標籤寬度時,請保留塑形邊界。測量完整字串,而不是將分別測得的字元寬度相加。Canvas 規格會使用目前的字型和文字設定,準備一組定位後的字形;其結果才是該字串適用的測量單位。此範例的 Canvas 比較僅限於所述的字型設定,不應假設它能重現所有 CSS 字體排印功能。HTML Standard: The canvas element — WHATWG

若要處理既有頁面,請直接測量原位的實際元件。除非同時重現相關樣式和容器環境,否則將文字移至通用的隱藏方框會改變排版條件。沒有相關排版方框的元素無法提供有用的一般方框測量值,而 CSS 轉換會影響這些 API 回報的矩形。此合成範例避開了這兩種複雜情況。CSSOM View Module — W3C

如何在不同瀏覽器尺寸下重複預覽?

偶爾檢查時,可以手動輸入各種視埠尺寸並記錄報告。請比較換行方式改變時附近的寬度,而不是只檢查極窄和極寬的版面。

若要重複檢查,Playwright 支援明確設定視埠尺寸,也支援獨立的 deviceScaleFactor 設定。其文件也允許覆寫裝置預設集的視埠。這提供了受支援的自動化流程:設定視埠、載入頁面、等待字型和相關內容就緒,然後在瀏覽器中收集 DOM 測量值。Emulation — Playwright

請將 CSS 尺寸與顯示像素密度分開處理。Playwright 將視埠和縮放係數列為不同設定,而 Canvas 文字度量值以 CSS 像素表示。顯示器的像素密度較高,本身並不代表需要將分配給標籤的 CSS 寬度加倍。Emulation — Playwright、TextMetrics — MDN

因此,實用的預覽結果是一份簡短的條件紀錄:瀏覽器、視埠、文字、字體排印、內容寬度和測得的卡片高度。使用 DOM 結果來設定換行元件的尺寸,保留 Canvas 度量值以回答單行文字的問題,並在每個需要呈現其渲染結果的瀏覽器中重複測量。

資料來源