要确定浏览器文本预览的尺寸,请在预期视口宽度下测量已完成布局的 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 度量保留为单行文本问题的参考,并在需要呈现其效果的每种浏览器中重复测量。
来源
- CSSOM View Module — W3C。
- HTML Standard: The canvas element — WHATWG。
- TextMetrics — MDN Web Docs。
- Element: getBoundingClientRect() method — MDN Web Docs。
- Document: fonts property — MDN Web Docs。
- FontFaceSet: load() method — MDN Web Docs。
- white-space — MDN Web Docs。
- line-height — MDN Web Docs。
- Simulate mobile devices with device mode — Chrome for Developers。
- Emulation — Playwright。