lucas@blog ~/posts/ascii-art-font-trap[EN]
← 返回文章列表

字体子集陷阱:为什么我的 ASCII Art 在手机上错位了

2026年2月27日2 min
#web-dev#调试#字体#next-js

字体子集陷阱:为什么我的 ASCII Art 在手机上错位了

我做了一个终端风格的个人网站,首页有一个 ASCII art logo。桌面上完美对齐。在 iPhone 上,线条对不齐了。

这是三次错误尝试和一个意想不到的根因的故事。

背景

Logo 使用了 Unicode 制表符字符——就是终端 UI 里常见的那些:

 ██╗      ██╗   ██╗  ██████╗  █████╗  ███████╗
 ██║      ██║   ██║ ██╔════╝ ██╔══██╗ ██╔════╝
 ██║      ██║   ██║ ██║      ███████║ ███████╗
 ██║      ██║   ██║ ██║      ██╔══██║ ╚════██║
 ███████╗ ╚██████╔╝ ╚██████╗ ██║  ██║ ███████║
 ╚══════╝  ╚═════╝   ╚═════╝ ╚═╝  ╚═╝ ╚══════╝

这里有两类字符:

  • 方块元素 (U+2580-U+259F):█ ╗ ╝ 等
  • 制表符/画线字符 (U+2500-U+257F):║ ═ ╔ ╚ 等

桌面上一切对齐。iPhone 上——微妙但可见的错位。双线字符 (║ ═) 的宽度和方块字符 (█) 略有不同。

错误尝试 1:缩小

第一直觉:响应式缩放问题。用 fontSize: 'min(2vw, 0.75rem)' 在小屏上缩小。

还是错位。同样的比例误差,只是更小了。

错误尝试 2:去掉问题字符

把所有制表符字符替换成实心方块。只用 █ 和空格。

对齐了!但很难看。原版的线条字符有视觉层次感。纯方块版本就是一坨。立刻回退。

错误尝试 3:缩放适配

用固定 12px 字号渲染,然后用 CSS zoom 配合 ResizeObserver 在小屏上等比缩放:

useEffect(() => {
  const container = logoContainerRef.current;
  const pre = container.querySelector("pre");
  logoNaturalWidth.current = pre.scrollWidth;

  function updateZoom() {
    const available = container.clientWidth;
    const natural = logoNaturalWidth.current;
    setLogoZoom(Math.min(1, available / natural));
  }

  updateZoom();
  const observer = new ResizeObserver(updateZoom);
  observer.observe(container);
  return () => observer.disconnect();
}, []);

缩放效果很好——logo 漂亮地自适应了。但错位还在。字符宽度还是不一致。

这时候我截了个图,仔细看了看到底发生了什么。

根因

字体是 JetBrains Mono,通过 next/font/google 加载:

const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
});

subsets: ["latin"] 是关键。Google Fonts 把字体拆分成 Unicode 范围子集,只预加载你指定的部分。Latin 子集覆盖 A-Z、0-9、基本标点——但不包括制表符字符 (U+2500-U+257F) 和方块元素 (U+2580-U+259F)。

当浏览器遇到制表符字符时,发现它不在预加载的字体子集里。它会请求额外的子集文件。但在手机上,文件加载前,浏览器回退到系统字体——iOS 上是 SF Mono,它对这些特殊 Unicode 范围的字符有不同的宽度

核心问题:等宽字体中,每个字符必须等宽。但当两种不同的等宽字体渲染同一行中的不同字符时,它们的「等宽」宽度并不相等。

修复

自托管完整字体。下载完整的 JetBrains Mono woff2 文件(1,363 个字形,每个字重约 90KB),使用 next/font/local

import localFont from "next/font/local";

const jetbrainsMono = localFont({
  src: [
    { path: "../../fonts/JetBrainsMono-Regular.woff2", weight: "400" },
    { path: "../../fonts/JetBrainsMono-SemiBold.woff2", weight: "600" },
    { path: "../../fonts/JetBrainsMono-Bold.woff2", weight: "700" },
  ],
  variable: "--font-mono",
  display: "swap",
});

不做子集拆分。一个文件,所有字形,宽度一致。ASCII art 在所有设备上完美对齐。

教训

Google Fonts 的子集拆分是个聪明的优化——只发送你需要的字符,减少首次加载量。对 99% 的网站来说,这是正确的取舍。

但如果你使用了 Latin 范围之外的字符——制表符、数学符号、CJK、emoji——子集边界就成了渲染边界。不同字符可能来自不同的字体文件,在不同时间加载,有可能回退到指标不同的系统字体。

如果字符宽度一致性很重要,自托管完整字体。 大小差异很小(90KB vs Latin-only 的约 30KB),换来的是所有 Unicode 范围的渲染一致性。

第三次错误尝试的 zoom-to-fit?我保留了。它是让 ASCII art 自适应的好方案。只是它修不了字体问题。

番外:另一种对齐 Bug(两轮修复)

修好字体之后,我给项目页加了更多 ASCII art logo——"REDBOOK CLI",比原来的 "LUCAS GU" 宽。K 字母明显错位:顶部横杠比下面的笔画往左偏了一列。桌面和手机上都一样。

这次字体没问题。根因更简单也更低级:各行字符数不一致

第一轮:缺失的空格

ASCII art 有 6 行。第 1、4、5 行是 66 个字符,第 0、2、3 行是 65 个。第 0 行的字母间距少了一个空格——在 B 和第一个 O 之间。这导致间距之后的所有内容(两个 O 和 K)整体左移一列。只影响顶部那一行。

修复就是加一个空格。调试方法是验证每行字符数是否相等,然后和正确的 figlet 输出(npx figlet -f "ANSI Shadow" "REDBOOK")做 diff。

第二轮:更深层的 Bug

几周后,我注意到字母之间的间距看起来仍然不均匀。更彻底的分析揭示了第一次修复只是治标——没治本。

真正的问题:B→O 的字母间距是 2 个空格,而其他所有字母对之间都是 1 个空格

字母间距:  R→E  E→D  D→B  B→O  O→O  O→K
空格数:     1    1    1    2    1    1
                              ^^^ 唯一的异类

怎么会这样?在 ANSI Shadow figlet 字体中,字母 O 的顶部和底部行有一个前导空格(属于字母形状的一部分)。当我手动在每个字母之间加 1 个空格时,不小心把 O 自带的前导空格也当成了间距——然后又多加了一个。两个独立的 bug 互相掩盖了:

  1. B→O 间距多了一个空格 —— 2 个而非 1 个(所有行 +1 字符)
  2. K 的第 2、3 行缺少尾部空格 —— K 在 ANSI Shadow 中这两行自然宽度是 7 字符,但应该补齐到 8(第 2、3 行 −1 字符)

在第 2、3 行:+1 和 −1 互相抵消,产生 65 字符的行。在其他行:只有 +1 生效,产生 66 字符的行。第一轮修复给第 0 行加了一个空格来匹配 66——但 66 才是错的。正确的宽度是 65。

正确的修复:将所有行的 B→O 间距减少到 1 个空格,并补齐 K 第 2、3 行的尾部空格。结果:全部 6 行 65 字符,字母间距统一为 1 个空格。

教训:当 ASCII art 看起来错位但字体确实是等宽的,先检查字符数。对每行跑一个长度检查——如果不全相等,那就是你的 bug。但别止步于让字符数相等——还要验证字母间距是否均匀。按已知的字母宽度拆分 figlet 参考输出,用你想要的间距重新拼接,然后对比。逐行长度检查能发现症状;逐字母间距检查才能发现病因。

q 返回p 上一篇