中文像素字体的那些坑

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

Aseprite 与 Xprite
Aseprite 与 Xprite

英文界面几乎没花我什么力气。Aseprite 的界面字体以 CC BY 4.0 授权,我把它转成 WOFF2 直接用上,看起来一切正常。

Aseprite 的界面字体

Aseprite 只有两款界面字体,默认字体高 7 像素,Mini 高 5 像素。放到网格上看,能看出作者在这么小的尺寸里做了多少取舍。

Aseprite 默认字体的 H、x、g、p、l、i 放大到像素网格。大写占 6 行,x 字高 5 行,降部 1 行,每个字形右侧自带 1 列空白
Aseprite 默认字体的 H、x、g、p、l、i 放大到像素网格。大写占 6 行,x 字高 5 行,降部 1 行,每个字形右侧自带 1 列空白
  • 大写字母占 6 行,小写只比它矮 1 行,g、p、y 往基线下只伸 1 行,所以 7 像素高的字依然饱满。

  • 笔画一律 1 像素宽,没有粗体和斜体。

  • 每个字母宽度不同,i 和 l 只有 2 像素,m 和 W 有 8 像素,字距直接画在字形右侧那一列空白里。

  • Mini 更省,连降部都取消了,g 和 p 也挤进 5 行,基线就是格底。

Aseprite Mini 的同一组字母,5 行高,没有降部
Aseprite Mini 的同一组字母,5 行高,没有降部

缩放同样只认整数。Aseprite 的「屏幕缩放」和「UI 元素缩放」只有 100%、200%、300%、400% 四档,字体、笔画和缩放全对齐在同一张网格上。

这两款字体只覆盖拉丁和西里尔字母。Aseprite 自己的中文界面碰到汉字,会沿着回退链交给系统字体,Windows 上是微软正黑体,Linux 上是 Noto Sans CJK,汉字根本不是像素字。

轮到中文时,我以为这是件小事。中文像素字体在 GitHub 上一搜一大把,挑一款换上去就行。

结果这一步花的时间比整个英文界面还多。

选择其实不多

能搜到的字体

中文像素字体本来就难做。拉丁字母加标点一百来个字形,一个人几天能画完,汉字光 GB2312 一级就有 3755 个,界面文案、文件名、图层名还会用到更生僻的字。8 像素差不多是汉字还能认出来的下限,「藏」「疆」这种字放进 8 × 8 的格子,只能删笔画、并部件。字形一多,体积也跟着上去,Aseprite 整套英文字体不到 6 KB,一款中文像素字体动辄几百 KB。

即便如此,能搜到的名字还是不少:

字体尺寸覆盖授权
缝合像素字体 Fusion Pixel8/10/12px10px 含 GB2312 一级✅ OFL 1.1
方舟像素字体 Ark Pixel10/12px10px 一级只有 602 字✅ OFL 1.1
Z工坊像素黑体 Z Labs Pixel12px通用规范汉字表⚠️ OFL,保留字体名
美績點陣體 MisekiBitmap8px13248 个汉字✅ OFL 1.1
目哉像素 MuzaiPixel8 × 12pxGB2312、通用规范汉字表✅ OFL 1.1
寒蝉点阵体 ChillBitmap7/16px7px 共 13600 字符✅ OFL 1.1
凤凰点阵体 Vonwaon Bitmap12/16px6763 个汉字⚠️ CC0,字模来源存疑
秋叶圆体 1616pxGB2312、Big5 常用字✅ OFL 1.1
最像素 Zpix12px22237 字符⚠️ 商用收费
文泉驿点阵宋体12–16px基本区汉字全收⚠️ GPL,含嵌入例外
GNU Unifont16px基本区汉字全收✅ OFL / GPL
精品點陣體 9×910px12858 字符,繁简✅ OFL,保留字体名
俐方體 11 號 Cubic 1112px常用國字、GB2312 一级✅ OFL,保留字体名
美咲フォント Misaki8 × 8pxJIS 第一、第二水准⚠️ 作者自定许可
Silver—拉丁、中日韩等⚠️ CC BY,大项目需授权

大多出自同几个源头

十几款字体,乍看很够用。但读完各家的 README,把作者写明的来源连起来,它们大多能追到同样几个源头。

未声明来源 / Undeclared origin日美咲フォントMisaki · 8px日LCフォントSharp · 商业点阵 · 任天堂DS 采用日M+ BITMAP FONTS日JIS X 9051日本工业标准点阵字型简GB 19966-2005国家标准点阵字型简文泉驿志愿者修订字形(团队,非字体)日k8x128×12px日KHドット秋葉原16简HZK 点阵字库UCDOS · aguegu 整理 · 来源不明日DotGothic1616px简Z工坊像素黑体Z Labs Pixel · 12px其他GuanZhi 8px繁精品點陣體 7×7BoutiqueBitmap7x7简Zfull作者与版权不明 · 2023 移出缝合简最像素Zpix · 12px韩Galmuri日PixelMPlus繁俐方體 11 號Cubic 11日Izumi16日jiskan1616px · 公有领域简Unibit 1.1房骞骞简方正像素方正字库 · 12-24px · 商业简目哉像素MuzaiPixel简秋叶圆体 16QiuYeYuanTi-16简凤凰点阵体Vonwaon Bitmap简正格点黑 16yzdnn · 2026 · OFL日マルモニカMaruMonica · 12×16px简寒蝉点阵体 7pxChillBitmap简方舟像素字体Ark Pixel · 10/12px简美績點陣體MisekiBitmap · 8px繁精品點陣體 9×9BoutiqueBitmap9x9日BestTen-DOT其他Silver韩DOSGothic其他GNU Unifont16px简多巴胺像素体方正手迹 · 2023 · 商业简文泉驿点阵宋体WenQuanYi Bitmap Song其他k8x12+简寒蝉点阵体 16pxChillBitmap简缝合像素字体Fusion Pixel · 8/10/12px简缝合粗像素字体Fusion Bold
派生合并参考补字/贡献已移除疑似,未证实
  • 8px 这一档几乎都是美咲フォント的后代,精品點陣體 7×7、美績點陣體、寒蝉点阵体 7px 都从它改出来。

  • 16px 绕不开 Unifont。Unifont 的汉字来自房骞骞的 Unibit 1.1,Unibit 又取自国家标准 GB 19966-2005 的点阵字型,寒蝉点阵体 16px 再从 Unifont 派生。

  • 缝合像素字体干脆就是拼出来的,以方舟像素字体为底,再用美咲、美績、精品、俐方體和 Galmuri 补齐方舟还没画完的字,作者自己管它叫方舟的「临时过渡方案」。

图里并入缝合的那几条线上标着尺寸,这里容易看岔。精品 9×9 这类名字里的数字,指的是汉字本身画多大;缝合的 10px 却是整个字身框,多出的 1 像素留作字距。我把缝合的简体等宽版逐字渲染出来抽查,几乎每个汉字的墨迹都正好比字身框小 1 像素:

来源字体汉字大小并进缝合的尺寸
精品點陣體 7×77 × 78px
精品點陣體 9×99 × 910px
俐方體 11 號11 × 1112px

授权

这一关又筛掉一批:

  • Z工坊、精品點陣體、俐方體都在 OFL 里保留了字体名,裁剪子集再打包就得改名。

  • 最像素只对个人和教育用途免费,商用要付费。

  • Silver 免费可商用,但项目总预算超过 10 万美元就要另谈。

  • 凤凰点阵体虽然以 CC0 发布,字模却来自 aguegu 的 BitmapFont,最早取自 DOS 时代的 HZK 点阵,版权归属说不清。

  • 美咲フォント 用的是作者自己写的许可,缝合把它标成「无类型许可证,兼容 OFL 1.1」,下游字体的授权最后都要追溯到这里。

覆盖率

剩下几款,我又用同一句界面文字试了覆盖率。

「显示洋葱皮,新建图层,导出为 PNG」用六款字体按各自设计尺寸渲染并放大 3 倍
「显示洋葱皮,新建图层,导出为 PNG」用六款字体按各自设计尺寸渲染并放大 3 倍

方舟 10px 那一行缺了一大半,12px 也缺「葱」。覆盖率够、授权又允许随 Web 应用分发的,最后只剩几款,尺寸集中在 8、10、12、16px 这几档,而尺寸恰恰是最难迁就的地方。

字号只能取整数倍

物理像素的整数倍

普通 Web 字体是矢量轮廓,浏览器按任意字号重新栅格化,再用抗锯齿把边缘抹平,13px、15px 都能看。像素字体是方块拼出来的,只有放大整数倍,每个方块才对得上像素格。放大 1.5 倍,方块的边就落在两个物理像素之间,笔画随之发糊。

缝合像素字体 10px 分别以 10px、15px、20px 渲染并放大 4 倍,1.5 倍那一行边缘出现灰色过渡
缝合像素字体 10px 分别以 10px、15px、20px 渲染并放大 4 倍,1.5 倍那一行边缘出现灰色过渡

这里的整数倍算的是物理像素,devicePixelRatio 也得乘进去:

字号 × devicePixelRatio ÷ 设计尺寸 = 整数

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

2 倍屏上的 14px 和 15px。14px 笔画粗细不一,15px 每个方块都是 3 × 3 物理像素
2 倍屏上的 14px 和 15px。14px 笔画粗细不一,15px 每个方块都是 3 × 3 物理像素

中文的三种做法

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

英文 2 倍旁边并排三种中文方案:美績 8px 放大 2 倍、Unifont 16px 原样、缝合 10px 放大 1.4 倍
英文 2 倍旁边并排三种中文方案:美績 8px 放大 2 倍、Unifont 16px 原样、缝合 10px 放大 1.4 倍
  1. 8px 字体放大 2 倍。像素全是实的,笔画 2px,和英文一样粗。可 8 × 8 的格子里笔画早挤在一起,放大只是把一团墨迹放大,「洋葱」在界面字号下几乎认不出。

  2. 16px 字体原样显示。Unifont 是真正的 16 × 16 设计,结构完整,不用缩放。但笔画只有 1px,和 2px 笔画的英文挨着,中文像浅了一个色号,16px 也比 14px 的行高。

  3. 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,字体又没有粗体文件,浏览器就会合成「伪粗体」,把轮廓按字号比例往外扩一圈。扩出去的宽度不会刚好是一个字体像素,横竖笔画于是粗细不一,边缘发糊,和非整数缩放是同一个病。

「显示洋葱皮 Bold」的三种渲染:常规、浏览器伪粗体、缝合粗像素字体 10px
「显示洋葱皮 Bold」的三种渲染:常规、浏览器伪粗体、缝合粗像素字体 10px

现成的粗体

像素粗体只能另画一套,或者按像素规则算出来。现成的选择很少:

  • 缝合有一个算法粗体版本「缝合粗像素字体」,就是上图第三行,每一笔整齐地加宽 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。

14px 下的三种排法。共用基线时汉字顶出上方红线;逐字居中时汉字整体偏低;整体下移 1px 后汉字顶边与大写字母齐平
14px 下的三种排法。共用基线时汉字顶出上方红线;逐字居中时汉字整体偏低;整体下移 1px 后汉字顶边与大写字母齐平

挪动字形的同时,我把字体文件里声明的上伸和下伸也改成和 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,汉字也留在行框里。

左边 line-height: normal,含汉字的行变成 17px;中间固定 14px,汉字顶出行框;右边改过字体度量,每行 14px,汉字都在行框里
左边 line-height: normal,含汉字的行变成 17px;中间固定 14px,汉字顶出行框;右边改过字体度量,每行 14px,汉字都在行框里

界面里的行高仍然一律写成整数:文字行高等于字号 14px,菜单项 22px,文字顶边落在第 4px,列表项 18px。

文字工具干脆没有行距。它和 Aseprite 一样只画单行,文本框高度取最高的那个字形,中英混排时被汉字撑高,两种字体再按更低的那条基线对齐。

抗锯齿与小数坐标

灰边的来源

浏览器画字时,会算出字形盖住了每个物理像素的几成,盖住一半就涂成半灰,这就是抗锯齿。像素字体的方块如果正好对齐像素格,每个像素要么被整个盖住,要么完全没盖到,画出来只有纯黑和纯白。

方块对不齐像素格,灰边就出来了。除了前面说的非整数倍缩放,另一种情况是文字的起点落在半个像素上。比如一个 200px 宽的居中对话框,放在 801px 宽的窗口里,margin: auto 算出的左边距是 300.5px。框里每个 1 像素宽的竖笔都横跨两个物理像素,各盖住一半,于是一条黑线变成两条灰线。

缝合 10px 在 1 倍屏上从 x = 10px 和 x = 10.5px 开始绘制,放大 6 倍
缝合 10px 在 1 倍屏上从 x = 10px 和 x = 10.5px 开始绘制,放大 6 倍

会带进小数坐标的写法还有很多,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 早把字号、笔画和缩放放进了同一张网格。中文没有现成的网格,只能自己一格一格去凑。

返回全部文章