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)整体左移一列。只影响顶部那一行。

# 第 0 行(错误——65 字符,K 顶部左移)
 ██████╗  ███████╗ ██████╗  ██████╗   ██████╗   ██████╗  ██╗  ██╗
                                   ^^^
                              3 个空格(应该是 4 个)

# 第 1 行(正确——66 字符)
 ██╔══██╗ ██╔════╝ ██╔══██╗ ██╔══██╗  ██╔═══██╗ ██╔═══██╗ ██║ ██╔╝

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

教训:当 ASCII art 看起来错位但字体确实是等宽的,先检查字符数。对每行跑一个长度检查——如果不全相等,那就是你的 bug。一行中间少一个空格,后面所有内容都会偏移,而且最右边的字母错位最明显。

q 返回p 上一篇