中文像素字体的那些坑
我最近在做 Xprite,可以把它看成网页版的 Aseprite,一款像素画和动画编辑器,Xprite 能直接打开它的 .aseprite 工程,界面也照着它做,从按钮到菜单都是像素风。

英文界面几乎没花我什么力气。Aseprite 的界面字体以 CC BY 4.0 授权,我把它转成 WOFF2 直接用上,看起来一切正常。
Aseprite 的界面字体
Aseprite 只有两款界面字体,默认字体高 7 像素,Mini 高 5 像素。放到网格上看,能看出作者在这么小的尺寸里做了多少取舍。

-
大写字母占 6 行,小写只比它矮 1 行,g、p、y 往基线下只伸 1 行,所以 7 像素高的字依然饱满。
-
笔画一律 1 像素宽,没有粗体和斜体。
-
每个字母宽度不同,i 和 l 只有 2 像素,m 和 W 有 8 像素,字距直接画在字形右侧那一列空白里。
-
Mini 更省,连降部都取消了,g 和 p 也挤进 5 行,基线就是格底。

缩放同样只认整数。Aseprite 的「屏幕缩放」和「UI 元素缩放」只有 100%、200%、300%、400% 四档,字体、笔画和缩放全对齐在同一张网格上。
这两款字体只覆盖拉丁和西里尔字母。Aseprite 自己的中文界面碰到汉字,会沿着回退链交给系统字体,Windows 上是微软正黑体,Linux 上是 Noto Sans CJK,汉字根本不是像素字。
轮到中文时,我以为这是件小事。中文像素字体在 GitHub 上一搜一大把,挑一款换上去就行。
结果这一步花的时间比整个英文界面还多。
选择其实不多
能搜到的字体
中文像素字体本来就难做。拉丁字母加标点一百来个字形,一个人几天能画完,汉字光 GB2312 一级就有 3755 个,界面文案、文件名、图层名还会用到更生僻的字。8 像素差不多是汉字还能认出来的下限,「藏」「疆」这种字放进 8 × 8 的格子,只能删笔画、并部件。字形一多,体积也跟着上去,Aseprite 整套英文字体不到 6 KB,一款中文像素字体动辄几百 KB。
即便如此,能搜到的名字还是不少:
| 字体 | 尺寸 | 覆盖 | 授权 |
|---|---|---|---|
| 缝合像素字体 Fusion Pixel | 8/10/12px | 10px 含 GB2312 一级 | ✅ OFL 1.1 |
| 方舟像素字体 Ark Pixel | 10/12px | 10px 一级只有 602 字 | ✅ OFL 1.1 |
| Z工坊像素黑体 Z Labs Pixel | 12px | 通用规范汉字表 | ⚠️ OFL,保留字体名 |
| 美績點陣體 MisekiBitmap | 8px | 13248 个汉字 | ✅ OFL 1.1 |
| 目哉像素 MuzaiPixel | 8 × 12px | GB2312、通用规范汉字表 | ✅ OFL 1.1 |
| 寒蝉点阵体 ChillBitmap | 7/16px | 7px 共 13600 字符 | ✅ OFL 1.1 |
| 凤凰点阵体 Vonwaon Bitmap | 12/16px | 6763 个汉字 | ⚠️ CC0,字模来源存疑 |
| 秋叶圆体 16 | 16px | GB2312、Big5 常用字 | ✅ OFL 1.1 |
| 最像素 Zpix | 12px | 22237 字符 | ⚠️ 商用收费 |
| 文泉驿点阵宋体 | 12–16px | 基本区汉字全收 | ⚠️ GPL,含嵌入例外 |
| GNU Unifont | 16px | 基本区汉字全收 | ✅ OFL / GPL |
| 精品點陣體 9×9 | 10px | 12858 字符,繁简 | ✅ OFL,保留字体名 |
| 俐方體 11 號 Cubic 11 | 12px | 常用國字、GB2312 一级 | ✅ OFL,保留字体名 |
| 美咲フォント Misaki | 8 × 8px | JIS 第一、第二水准 | ⚠️ 作者自定许可 |
| Silver | — | 拉丁、中日韩等 | ⚠️ CC BY,大项目需授权 |
大多出自同几个源头
十几款字体,乍看很够用。但读完各家的 README,把作者写明的来源连起来,它们大多能追到同样几个源头。
-
8px 这一档几乎都是美咲フォント的后代,精品點陣體 7×7、美績點陣體、寒蝉点阵体 7px 都从它改出来。
-
16px 绕不开 Unifont。Unifont 的汉字来自房骞骞的 Unibit 1.1,Unibit 又取自国家标准 GB 19966-2005 的点阵字型,寒蝉点阵体 16px 再从 Unifont 派生。
-
缝合像素字体干脆就是拼出来的,以方舟像素字体为底,再用美咲、美績、精品、俐方體和 Galmuri 补齐方舟还没画完的字,作者自己管它叫方舟的「临时过渡方案」。
图里并入缝合的那几条线上标着尺寸,这里容易看岔。精品 9×9 这类名字里的数字,指的是汉字本身画多大;缝合的 10px 却是整个字身框,多出的 1 像素留作字距。我把缝合的简体等宽版逐字渲染出来抽查,几乎每个汉字的墨迹都正好比字身框小 1 像素:
| 来源字体 | 汉字大小 | 并进缝合的尺寸 |
|---|---|---|
| 精品點陣體 7×7 | 7 × 7 | 8px |
| 精品點陣體 9×9 | 9 × 9 | 10px |
| 俐方體 11 號 | 11 × 11 | 12px |
授权
这一关又筛掉一批:
-
Z工坊、精品點陣體、俐方體都在 OFL 里保留了字体名,裁剪子集再打包就得改名。
-
最像素只对个人和教育用途免费,商用要付费。
-
Silver 免费可商用,但项目总预算超过 10 万美元就要另谈。
-
凤凰点阵体虽然以 CC0 发布,字模却来自 aguegu 的 BitmapFont,最早取自 DOS 时代的 HZK 点阵,版权归属说不清。
-
美咲フォント 用的是作者自己写的许可,缝合把它标成「无类型许可证,兼容 OFL 1.1」,下游字体的授权最后都要追溯到这里。
覆盖率
剩下几款,我又用同一句界面文字试了覆盖率。

方舟 10px 那一行缺了一大半,12px 也缺「葱」。覆盖率够、授权又允许随 Web 应用分发的,最后只剩几款,尺寸集中在 8、10、12、16px 这几档,而尺寸恰恰是最难迁就的地方。
字号只能取整数倍
物理像素的整数倍
普通 Web 字体是矢量轮廓,浏览器按任意字号重新栅格化,再用抗锯齿把边缘抹平,13px、15px 都能看。像素字体是方块拼出来的,只有放大整数倍,每个方块才对得上像素格。放大 1.5 倍,方块的边就落在两个物理像素之间,笔画随之发糊。

这里的整数倍算的是物理像素,devicePixelRatio 也得乘进去:
字号 × devicePixelRatio ÷ 设计尺寸 = 整数
所以 CSS 里的字号不一定要是 10 的整数倍,乘上屏幕倍率之后是整数就行。在 2 倍屏上,10px 的字体设成 15px 是清楚的,15 × 2 ÷ 10 = 3,每个方块正好占 3 × 3 个物理像素;设成 14px 就糊了,14 × 2 ÷ 10 = 2.8。

中文的三种做法
问题是界面里的英文已经定了:Aseprite 的 7 像素字体按 2 倍画,一行 14px,笔画 2px。中文要和它排在同一行,只有三种做法。

-
8px 字体放大 2 倍。像素全是实的,笔画 2px,和英文一样粗。可 8 × 8 的格子里笔画早挤在一起,放大只是把一团墨迹放大,「洋葱」在界面字号下几乎认不出。
-
16px 字体原样显示。Unifont 是真正的 16 × 16 设计,结构完整,不用缩放。但笔画只有 1px,和 2px 笔画的英文挨着,中文像浅了一个色号,16px 也比 14px 的行高。
-
10px 字体放大 1.4 倍。缝合 10px 画到 14px,和英文一样高,字形比 8px 清楚得多,笔画也接近 2px。代价是 1.4 不是整数,笔画有宽有窄,边缘带灰。
14 和 10 之间没有整数倍关系。想两全,要么找一款 7 像素高、还认得出来的中文字体,这样的字体并不存在;要么把中文界面的行改成 20px,让 10px 字体正好放大 2 倍,代价是所有控件都得重排。
我的选择
第一种看不清,第二种和英文轻重不配,所以我选了第三种,用缝合 10px 的简体版。它把 GB2312 一级汉字收全了,二级也有七成,OFL 允许随 Web 应用分发,字形以方舟为底,等方舟补完 10px,切过去也不费事。
1.4 倍的误差没法消掉,只能藏一藏。在 Canvas 上画汉字时,半透明的像素按阈值二值化,阈值比一半低一些,免得细笔画整笔消失。画进作品里的文字工具则不做这种折中,汉字只按 10px 的整数倍放大。
字重也没法凭空加
字号的毛病,到了字重又犯一次。
伪粗体
CSS 里写 font-weight: bold,字体又没有粗体文件,浏览器就会合成「伪粗体」,把轮廓按字号比例往外扩一圈。扩出去的宽度不会刚好是一个字体像素,横竖笔画于是粗细不一,边缘发糊,和非整数缩放是同一个病。

现成的粗体
像素粗体只能另画一套,或者按像素规则算出来。现成的选择很少:
-
缝合有一个算法粗体版本「缝合粗像素字体」,就是上图第三行,每一笔整齐地加宽 1 像素,不过作者明说它只是测试项目。
-
方舟、Z工坊、美績都只有常规字重,Aseprite 的字体也没有粗体。
-
精品點陣體 9×9、Galmuri 和文泉驿点阵宋体倒是带粗体,尺寸却对不上 14px 的行。
最后我没做粗体。界面样式统一加了 font-synthesis: none,不让浏览器合成粗体和斜体,文字工具选中位图字体时,「B」按钮直接置灰。
行高与基线
基线
中英文排进同一行,先碰到的是基线。Aseprite 的字体按 2 倍画成 14px 时,大写字母从行顶一直画到第 12 像素,下面 2 像素留给 g、p 这些字母的降部。缝合 10px 放大 1.4 倍后,汉字有 13 像素高,比大写字母多出 1 像素。两种字体共用一条基线,汉字底边和英文对齐,顶边就冒出行框 1 像素。
逐字居中
我最早的做法是量出每个汉字实际的墨迹,再把墨迹放到行框正中:
const metrics = ctx.measureText(char);
const baselineY = Math.round(
(size + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2,
);汉字确实收进了行框,看起来却往下沉了一截。行框的正中比大写字母的正中低 1 像素,因为行框底部那 2 像素是留给降部的,大多数英文用不上。逐字居中还有一个毛病:「一」「口」这种墨迹不满格的字,会被挪离字体作者给它安排的位置。
整体下移 1 像素
汉字和英文差的其实只是固定的 1 像素,不必逐字去量。我把整套中文字体的字形统一往下挪 1 像素,汉字顶边和大写字母顶边对齐,底边伸进降部 1 像素。普通中文字体也是这样设计的,以思源黑体为例,汉字字身框的底边就在英文基线下方 0.12 em。

挪动字形的同时,我把字体文件里声明的上伸和下伸也改成和 Aseprite 一样的 10/14 em 和 4/14 em。这一步是为了行高,下面会讲到。
用 fontTools 改字体
两件事都是用 Python 的 fontTools 直接改字体文件:
-
缝合每 em 是 1000 个单位,在 14px 下 1 像素约等于 71 个单位,所以每个字形的轮廓都整体往下平移 71。
-
上伸和下伸在字体里存了三处,hhea 表一处,OS/2 表里的 typo 和 win 两组各一处。三处都要改,否则不同系统读到的值不一样。
from fontTools.ttLib import TTFont
from fontTools.pens.t2CharStringPen import T2CharStringPen
from fontTools.pens.transformPen import TransformPen
font = TTFont("fusion-10.woff2")
glyphs = font.getGlyphSet()
charstrings = font["CFF "].cff.topDictIndex[0].CharStrings
for name in font.getGlyphOrder():
pen = T2CharStringPen(font["hmtx"][name][0], glyphs)
glyphs[name].draw(TransformPen(pen, (1, 0, 0, 1, 0, -71))) # 下移 71 单位
old = charstrings[name]
charstrings[name] = pen.getCharString(private=old.private, globalSubrs=old.globalSubrs)
hhea, os2 = font["hhea"], font["OS/2"]
hhea.ascent, hhea.descent, hhea.lineGap = 714, -285, 0
os2.sTypoAscender, os2.sTypoDescender, os2.sTypoLineGap = 714, -285, 0
os2.usWinAscent, os2.usWinDescent = 714, 285
font.save("fusion-10-aseprite.woff2")CSS 的 @font-face 里也有 ascent-override 和 descent-override,能改后两项,但 Safari 正式版还不支持,平移字形更是只能在字体文件里做。改过的字体按 OFL 要换个名字,Xprite 里叫 FusionPixel10Aseprite,只给 14px 的界面文字用。10px 的地方还用原版,因为同样 71 个单位在 10px 下只有 0.71 像素,笔画会跨到两行上。
行高
像素界面的行高必须是整数像素,最好还是缩放倍数的整数倍,多出半个像素,下一行就会整体错位或发虚。line-height: normal 偏偏不是固定值:浏览器会在这一行用到的所有字体里,取最高的上伸加最深的下伸。缝合原本声明的上伸是 0.9 em,纯英文的行是 14px,只要混进一个汉字,这一行在 Chrome 里就变成 17px,整列文字跟着上下跳。
把行高写死成 14px,行是对齐了,汉字却照样顶出行框,挤进上一行的底部。改过字体度量之后,两种字体报给浏览器的高度相同,line-height: normal 下含汉字的行也是 14px,汉字也留在行框里。

界面里的行高仍然一律写成整数:文字行高等于字号 14px,菜单项 22px,文字顶边落在第 4px,列表项 18px。
文字工具干脆没有行距。它和 Aseprite 一样只画单行,文本框高度取最高的那个字形,中英混排时被汉字撑高,两种字体再按更低的那条基线对齐。
抗锯齿与小数坐标
灰边的来源
浏览器画字时,会算出字形盖住了每个物理像素的几成,盖住一半就涂成半灰,这就是抗锯齿。像素字体的方块如果正好对齐像素格,每个像素要么被整个盖住,要么完全没盖到,画出来只有纯黑和纯白。
方块对不齐像素格,灰边就出来了。除了前面说的非整数倍缩放,另一种情况是文字的起点落在半个像素上。比如一个 200px 宽的居中对话框,放在 801px 宽的窗口里,margin: auto 算出的左边距是 300.5px。框里每个 1 像素宽的竖笔都横跨两个物理像素,各盖住一半,于是一条黑线变成两条灰线。

会带进小数坐标的写法还有很多,Fixing blurry pixel fonts 里有更多例子:
-
百分比或 rem 写的内边距
-
transform 里的位移
-
回退字体带小数的字宽
-
Canvas 上的 fillText,画在整数坐标上是清楚的,x 加 0.5 就发灰
把坐标取整
所以文字所在的每一层,位置和尺寸都得是整数。CSS 自己保证不了这一点,只能在布局时自己取整。居中不交给 margin: auto,而是自己算出左边距再向下取整:
const left = Math.floor((parentWidth - width) / 2);Xprite 的界面整个排在自己的网格上:窗口宽高先除以 2 再向下取整,得到界面有多少个像素,面板的位置和尺寸都按这个网格上的整数来算,最后再按 2 倍画出来。
整数 CSS 像素在 125%、150% 这类缩放下仍然会落到物理像素中间。要对齐到物理像素,得把坐标换算成物理像素后取整,再换算回去:
const dpr = window.devicePixelRatio;
const snap = (x: number) => Math.round(x * dpr) / dpr;Canvas 的尺寸则可以用 ResizeObserver 的 device-pixel-content-box 直接拿到物理像素宽高,web.dev 上有一篇详细的介绍。不过文字本身的字号也要满足前面那个整数倍公式,否则坐标再整齐也没用。
关掉抗锯齿的写法只在 macOS 上有效
很多文章碰到这种灰边,给的办法是用 CSS 关掉抗锯齿:
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: grayscale;
font-smooth: never;这三个属性都不在标准里。MDN 写明前两个只在 macOS 上生效,-moz-osx-font-smoothing 也没有 none 这个值;Chromium 的源码里,也只有 macOS 的字体代码读了 -webkit-font-smoothing。我在 Linux 上的 Chrome 里加上这三行,渲染结果一个像素都没变。
内嵌点阵会被丢掉
字体文件里内嵌的点阵在网页上也用不了。OpenType 可以用 EBDT 和 EBLC 两张表存点阵,但 Chrome 加载网页字体时会先用 OTS 清理一遍,这两张表会被删掉。网页上的像素字体,都是把像素方块画成轮廓的 OTF、TTF 或 WOFF2。
最后
方案写出来只有几行:英文用 Aseprite 的 7px 字体按 2 倍画,中文用缝合 10px 按 1.4 倍塞进同一行,整套字形下移 1 像素并改掉字体度量,行高写成整数,不做粗体。这份近 400 KB 的中文字体只在界面语言是中日韩,或者皮肤用了位图字体时才预加载,启动画面只带一份不到 1 KB 的英文子集。
没解决的还有不少:
-
界面里的汉字不是整数倍,只是「像」像素字,笔画宽窄不一,边缘带灰。
-
对齐靠手调。复选框上移 2px、标签多留 1px,都是对着截图调出来的,换一款字体就得重来。
-
首次进入中文界面要多下载约 400 KB,图层名这类用户输入又让子集化很难做。
-
没有粗体,想强调一段中文只能换颜色。
-
125%、150% 这类系统缩放下,英文也逃不过重采样,中文只会更糊。
英文那边一路顺利,是因为 Aseprite 早把字号、笔画和缩放放进了同一张网格。中文没有现成的网格,只能自己一格一格去凑。