要确定浏览器文本预览的尺寸,请在预期视口宽度下测量已完成布局的 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

解读结果并判断文本是否适配

先查看 Available text width,然后比较两种视口尺寸下的卡片高度。换行增加一行时,卡片高度会变化,而 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 字形高度则是第三种测量值,用途不同。

接下来检查字体是否就绪。如果引入了已声明的 Web 字体,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 度量保留为单行文本问题的参考,并在需要呈现其效果的每种浏览器中重复测量。

来源