跳到正文

CSS 下划线为什么会在字母下断开

我在 Markdown 阅读器里审一篇 TypeScript 文章时,发现目录链接的下划线总在 y 和 p 下面断开。切到博客,悬停时出现的下划线却能从字尾下方连着画过去。我以为博客用的是底边框,打开开发者工具才发现,两处都用原生下划线。当时阅读器的偏移是 0.17em,博客引入的样式用了 0.2rem。

悠哉字体下的三种下划线。第一行在字尾处断开,第二行从字尾下方连续通过,第三行与字尾相交。

这三行用的是同一套环境。Windows Chrome 157、设备像素比(DPR)2、悠哉常规体、16px。前两行只改偏移。第三行把偏移换回 0.17em,关闭笔画避让,字尾就和线贴在了一起。

下划线为什么会断开

g、j、p、q、y 伸到基线下方的笔画叫下伸部。线画到这里,默认的 text-decoration-skip-ink: auto 会在交点两侧留空。看图里第二行,线挪到了字尾下面,原来的断口也就连上了。第三行关掉避让,线留在原来的高度,可以看到 y 的字尾接到了线上。

汉字笔画常延伸到字面下缘。如果对贴线的汉字也逐字挖空,长链接可能只剩几段短线。CSS 规范因此建议浏览器在中日韩文字片段上跳过这种避让,实际画法由浏览器决定。调好英文的 y 后,别忘了回头看同一行的汉字。具体规则见 CSSWG 的笔画避让说明。

CSS 下划线的属性

text-decoration: underline solid currentColor 0.08em 一次设定画哪种线、线型、颜色和粗细。想把线往下挪,或者让线穿过字尾时保持连续,需要单独写 text-underline-offset 或 text-decoration-skip-ink。定位方式 text-underline-position 也独立于简写。详见 MDN 的 text-decoration 简写。

同样的 text-underline-offset 配上不同的 text-underline-position,线会落到不同高度。偏移量到线的上缘,线宽再从上缘量到下缘。text-decoration-inset 沿着线的方向移动端点,可以单独调线的长短。

auto 和 under 的偏移分别从基线和文字下缘量到线的上缘。线宽量到线的下缘,正值 inset 把端点向内收。

如果线的位置已经合适,还可以单独改外观。图中最下面两条线的位置一样,auto 在 y 的字尾附近挖出空隙,none 仍沿同一高度画过去。

装饰线的种类、五种线型、两种设色方式,以及同一高度下 auto 避让和 none 连续的对照。

想调什么 CSS 属性 常用值与效果
画哪种线 text-decoration-line underline 是下划线,也可以组合 overline、line-through。none 表示当前元素不发起装饰线。
线长什么样 text-decoration-style solid 实线、double 双线、dotted 点线、dashed 虚线、wavy 波浪线。
线是什么颜色 text-decoration-color 默认 currentColor 跟随文字颜色,也可以单独设色。
线有多粗 text-decoration-thickness auto 由浏览器决定,from-font 优先读字体度量,也可以给长度或相对字号的百分比。
从哪里起算 text-underline-position 水平文字中,auto 的显式偏移以字母基线为起点,under 从文字下缘起算,from-font 优先用字体给出的下划线位置。left、right 用于控制竖排中的下划线侧别。
离起点多远 text-underline-offset 默认 auto 由浏览器决定。也可以给长度或相对字号的百分比,0.25em 会随链接字号变化。这个属性只移动下划线。
碰到字形要不要断 text-decoration-skip-ink auto 让浏览器按文字系统决定哪里避让,none 保持线条连续。all 要求避让所有相交的字形,包括汉字。使用前查目标浏览器对 all 的支持。

要让相邻两段下划线的端点分开,可以用 text-decoration-inset。正值缩短线,负值延长线。一个值同时控制两端,两个值分别控制起点和终点,使用前要查兼容性。见 MDN 的 text-decoration-inset。

旧代码里可能见到 text-decoration-skip: edges,MDN 已把这套旧语法标记为弃用。Level 4 草案用同名简写来组织笔画、元素、盒边和空白处的跳过规则,分别对应 text-decoration-skip-ink、text-decoration-skip-self、text-decoration-skip-box 和 text-decoration-skip-spaces。控制链接的笔画避让时,直接用支持更广的 text-decoration-skip-ink。见 MDN 的旧版 text-decoration-skip和 CSSWG 的断点规则。

如果只想给一句话中的某几个词加下划线,就把 underline 加到包住这些词的元素上。外层元素画出的线会延伸到其行内内容,在子元素上写 text-decoration: none 也去不掉。

线应当离字尾多远

当链接和根元素的字号都是 16px,0.17em 是 2.72px,0.2rem 是 3.2px。这两条线的高度只差 0.48px,却可能刚好跨出相交区域。把同样的 0.2rem 放到 20px 的标题,3.2px 又只相当于标题的 0.16em。用根字号适配屏宽时,rem 还会随布局变化。正文和标题共用样式时,可以用 em 让偏移跟着各自的字号走。Tailwind 的 underline-offset-4 是固定的 4px,也可以直接写 underline-offset-[0.25em]。相关例子见 webpack README 的 Plugins 标题和 Tailwind 的偏移工具类。

Chrome 把笔画避让列为原生下划线的优点,给出的偏移试值是 0.15em 到 0.25em。GOV.UK 提醒了另一个问题。断口太多或太宽,一条链接会看起来像几条相邻的链接。两种观察见 Chrome 排版指南和 GOV.UK 的断口讨论。

昆士兰政府设计系统把线放在最低字尾下方约 1px,为自家的字体和字号选了 0.3em,还留了可调的变量。阅读器可以沿用这个留白目标,在用户换字体后重测偏移。设计细节见 昆士兰政府设计系统的下划线指南。

text-underline-position: under 从文字下缘起算,这条参考线由字体度量或排版决定。某些字尾或附标会伸过它。下图把 under 再往下移 0.1em,悠哉的断口消失了,Noto Serif SC 仍有断口。起算位置的定义见 CSS 下划线定位。

同一字号下比较 0.30em、under 和 under 加 0.1em。Noto Serif SC 在后两种写法下仍有断口。

用 Canvas 按实际字形校准

想让线跟着实际字形走,可以把 Canvas 的 textBaseline 设为 alphabetic,再从 measureText() 读取 actualBoundingBoxDescent。这个值表示样文最低的笔画离字母基线有多远。text-underline-position: auto 下的显式偏移也从这条基线起算,所以可以直接在实测下伸度上加留白。两个起点的定义见 Canvas 文字度量和 CSS 下划线偏移。

gyjpq 的字形轮廓。橙色量尺从基线量到最低字尾,蓝色量尺从基线量到下划线上缘,线与字尾间留出约 1px。

图中以 16px 的悠哉为例,将字形和距离等比放大。actualBoundingBoxDescent 从基线量到最低字尾,text-underline-offset 在 auto 的定位方式下从基线量到下划线上缘。两段距离之差就是字尾下方的留白。Canvas 度量的定义见 MDN 的 actualBoundingBoxDescent 说明。

measureUnderlineOffset 可以直接复制到浏览器的 JavaScript 模块。它接收 CSS 的 font 简写和用于校准的样文,默认留出 1 CSS px,返回值也是 CSS 像素。

export const measureUnderlineOffset = async (font, sample, gap = 1) => {
  if (!Number.isFinite(gap) || gap < 0) {
    throw new RangeError('gap 必须是有限的非负数');
  }
  // 空白没有可用于估计字尾的墨迹,保留调用方的 CSS 默认值。
  if (!sample.trim()) {
    return undefined;
  }

  const context = document.createElement('canvas').getContext('2d');
  if (!context) {
    return undefined;
  }
  try {
    // 用样文请求匹配的网页字体,等它们加载后再量,避免量到临时回退字形。
    await document.fonts.load(font, sample);
  } catch {
    return undefined;
  }

  context.font = font;
  // 与 text-underline-position: auto 的显式偏移共用字母基线。
  context.textBaseline = 'alphabetic';
  const descent = context.measureText(sample).actualBoundingBoxDescent;
  if (!Number.isFinite(descent)) {
    return undefined;
  }

  // 下伸度可能为负,例如样文只有位于基线上方的中点。留白至少从基线算起。
  const minimumOffset = Math.max(0, descent) + gap;
  // 换成设备像素后向上取整,避免对齐时把线抬进预留的空隙。
  const scale = window.devicePixelRatio;
  return Math.ceil(minimumOffset * scale) / scale;
};

用 fontBoundingBoxDescent 也能从同一条基线量出一个距离,终点是字体度量的下缘。固定 64px 悠哉,在 Chrome 里量 ABC 和 gyjpq,两组的字体下伸度都是 18px,但样文的最低笔画分别在基线处和基线下约 12px。校准用 actualBoundingBoxDescent,取的就是这个会随样文变化的最低点。字体度量的定义见 MDN 的 fontBoundingBoxDescent。

同一字体和字号下,ABC 的实际下伸度为 0px,gyjpq 的实际下伸度为 12px,两段样文的字体下伸度均为 18px。

样文最好同时包含常见汉字和拉丁字尾。这次用的是「gjpqy TypeScript Webpack 中文链接,阅读预览。」。字体缺字时,Canvas 也会按字体栈回退。document.fonts.load(font, sample) 负责等匹配的网页字体加载,不保证单个字体覆盖样文里的每个字。详见 MDN 的 FontFaceSet.load()。

如果某次字尾下伸 3.2px,加 1px 后的最小偏移就是 4.2 CSS px。在 DPR 2 下,这等于 8.4 个设备像素。向上取到 9 个,再换回 4.5 CSS px。这样取整不会把算出的偏移往回收。字体加载失败或无法读取度量时,undefined 会让调用方继续使用 CSS 初始偏移。

校准后,不同字体的线在哪里

打开字体与下划线试验页,可以填本机的 CSS 字体栈、字号、字重和对照文字。两行文字共用同一个输入,分别使用固定值和校准值。把 HTML 保存到本地,也可以离线运行里面的脚本。

宋体、悠哉、思源黑体和思源宋体的 16px 链接。每组上行固定 0.25em,下行按实际字形校准。

固定 0.25em 时,16px 正文都是 4px。校准后,宋体抬到 3px,悠哉和霞鹜文楷仍是 4px,思源黑体 SC 和微软雅黑降到 5px,思源宋体 SC 和 Noto Serif SC 到 6px。两种设置都用 text-decoration-skip-ink: none 保持连续。开源字体取自 霞鹜文楷、思源黑体和思源宋体的上游发布。

还可以放大查看 14 组字体的完整对照(SVG),或对照 原尺寸实拍(PNG)。

图里的苹方来自 Apple 官网。Windows Chrome 用苹方 SC 的网页字体画汉字,用 SF Pro Text 画英文,这一组算出 4px。macOS 界面的拉丁系统字体是 SF Pro,简体中文通常由苹方 SC 补齐。网页请求 system-ui 时会使用这套系统字体回退,具体字形仍取决于语言、系统版本和页面字体栈。SF Pro 也会按字号调整视觉尺寸。对应设备上的偏移可以用 Safari 打开试验页查看。系统字体的定义和选取见 Apple 排版指南和 WebKit 的系统字体回退实现。

复测另外用了三条中英混排链接,包含简繁汉字和折行。14 组字体分别测四档常规字号(14px、16px、22px、26px)和两档较重字形(16px、24px),再各跑 DPR 1 和 2。按截图里的字形和线条像素判断,固定偏移的 504 组样例中有 5 组重叠、107 组贴线。校准后的线和字形都分开了,最窄的空隙是 0.5 CSS px。同一段链接还可能混有行内代码、粗体和上下标。用样文算出的偏移适合给同一档正文共用,单条链接的间距仍得看它最后怎样画出来。

底边框能留出多少空隙

普通行内链接折成两行,底边框也跟着分段。给三行正文中间的链接加 3px 底部内边距和 2px 底边框,段落仍是 60px 高。换成 inline-block 后,整个链接成了一个盒子,段落升到 65px,链接的折行位置也变了。布局细节见 CSS 行内盒尺寸和片段边框规则。

同一段窄栏文字分别用原生下划线、行内底边框和 inline-block 底边框绘制。前两种链接可以从前文末尾开始折行,第三种整块挪到下一行。

padding-bottom: 2px 留的是内容盒底边到边框的距离。用 16px 的 gyjpq 测,悠哉的字尾本来就离盒底约 2px,加完约有 4px。宋体更接近盒底,加完约有 2px。给宋体里的 g 加一个组合下点,浏览器回退到 Times New Roman,下点就碰到了线。阿拉伯文等带下方附标的文字也可能伸出盒底,适合留作中英正文的边界样例。更多例子见 W3C 的跨文字系统实测。

底边框嫌低,可以先减小内边距。减到 0 还想把线抬高,就得改用能在盒子内部定位的画法。Medium 当年用了随行内链接折行的背景渐变,过程见 Medium 的下划线设计记录。

原始实拍保留了像素上的断口、贴线和折行。可对照三种避让效果、固定偏移与 under、四组字体校准和原生线与底边框。

在阅读器和博客里应用

在已有原生下划线的正文链接上,用 CSS 变量接收偏移。脚本测完之前先用 0.25em。

.markdown-body a {
  /* JS 量的是从字母基线到下划线上缘的距离。 */
  text-underline-position: auto;
  /* 首次渲染或测量失败时,先用随链接字号缩放的固定偏移。 */
  text-underline-offset: var(--prose-underline-offset, 0.25em);
  /* 即使实际链接有比样文更深的字尾,也把线连续画过去。 */
  text-decoration-skip-ink: none;
}

在同一个 JavaScript 模块里,等正文节点挂载后读它的实际样式,把测得的 CSS 像素除以正文字号,再写成 em。正文会用到测得的距离,字号较大的标题则按自己的字号放大这段偏移。

const prose = document.querySelector('.markdown-body');
if (prose) {
  // 读取已经应用的字号、字重和字体栈,让 Canvas 按正文的设置测量。
  const { fontStyle, fontWeight, fontSize, fontFamily } = getComputedStyle(prose);
  const font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`;
  const offset = await measureUnderlineOffset(font, 'gjpqy TypeScript Webpack 中文链接,阅读预览。');
  if (offset !== undefined) {
    // CSS px 除以正文字号,得到字号比例。由每个链接按自己的字号解析 em。
    const relativeOffset = offset / parseFloat(fontSize);
    prose.style.setProperty('--prose-underline-offset', `${relativeOffset}em`);
  }
}

本博客在每篇文章挂载时测一次。Markdown Marker 允许更改正文字体和字号,设置变动时再测一遍。两处都把这个函数放在 sheng-tool 里共用。

验收时我会在窄栏里放一条真的折成两行的中英链接,同时看字尾和下一行。线离字尾近一点没关系,移到快贴着下一行时,读者就得多看一眼才能确定它属于哪行。