点阵字体二三事
关于如何在高分辨率屏幕上使用点阵宋体字体。
缘起
作为一个 DNF 的忠实玩家,DNF 早期的美术风格给我留下了深刻的印象。早年的 CRT 显示器,搭配清晰、朴素、硬朗的字体,还有那些神秘莫测的装备名称,“玄磁光剑”、“双龙魔影剑”、“细雪之舞”,真的太美了。

我一直想在浏览器上复刻 DNF 的部分场景,但出于种种原因(懒惰)一直没有做。 前不久重构博客时,我突发奇想,如果能用 DNF 中的那种字体风格来实现博客,那该多酷啊!
Just 宋体
DNF 用的是宋体,这个在贴吧里很多人都提到过。 我想当然的在 DEMO 网页中写了 font-family: SimSun; font-size: 12px;,但在我的 4K 屏幕下一直显示成这样:

完全没有 DNF 截图中那硬朗的感觉嘛!我有些懊恼,鬼使神差地将网页胡乱地往我的副屏(1080P)屏幕上甩了一下,我被眼前的网页惊呆了:

我又将使用了宋体的文字使用绝对定位在网页上和游戏截图做对比,完全一模一样,严丝合缝,这就是我要的效果呀!

我又做了几次字号大小、分辨率相关的测试,结论如下:
| 字体大小 | 4K屏幕 | 1080P屏幕 |
|---|---|---|
| < 18px | 平滑字体 | 点阵字体 |
| >= 18px | 平滑字体 | 平滑字体 |
本次测试是在 Windows 平台的 Chrome 和 Firefox 浏览器测试的,两者的表现完全一致。
寻根问底
问了下 Gemini ,大意如下:
早年的显示器分辨率较低,为了中文宋体的显示效果不发生粘连,微软在宋体内置了部分点阵字体,覆盖了 12px ~ 17px 的范围,其中 12px、14px 最为经典。
18px 以内使用点阵字体进行渲染,18px 及以上使用 TrueType 矢量轮廓进行渲染,由此可得到上方表格的结果。
在 1080P 显示器上,物理像素和逻辑像素一一对应,因此点阵字体的像素点可以完美对应到物理像素点上。
而到了现在的 4K 高分辨率显示器上,一般都会对屏幕使用 150% 或者 200% 进行缩放,一个逻辑像素无法对应到物理像素上,强行放大会造成马赛克、失真。
因此 Windows 在 DPR > 1 时,会自动阻断点阵字体的使用,切换到矢量渲染。
至于 iOS/macOS,从很早的时候(大约 iOS4 时期),系统级字体引擎就完全禁用了点阵字体的使用。
如何使用
那么就没有办法在 DPR > 1 的设备上使用点阵宋体了吗? 自然还是有的~
我们可以用脚本将点阵字体转为标准的矢量字体,用矢量模拟点阵,这样无论是什么分辨率的屏幕/操作系统,都可以正常显示点阵字体了,因为对于任何渲染引擎来说,它就是一个常规矢量字体。
相关的脚本我放在文末了,这里说一下如何使用以及具体思路:
# 首先安装相关依赖
pip install pillow fonttools brotli
# 生成包含全部汉字的字体
python convert.py "C:/Windows/Fonts/simsun.ttc" -s 12 -a -o simsun-pixel-12-all.woff2
# 或者生成仅包含 x.txt 文件中汉字的字体
python convert.py "C:/Windows/Fonts/simsun.ttc" -s 12 -c ./x.txt -o simsun-pixel-12-subset.woff2生成完整汉字字体太过庞大,建议在使用中仅生成包含用到汉字的字体~ 我的博客就是这么做的,编译之前先将博客中包含的所有文字提取出来,然后再生成相关的字体。
完整脚本
由于使用了 Windows 平台的 GDI 进行渲染,因此目前该脚本只能在 Windows 中使用。
"""
把一个字体在指定像素大小下的点阵,转成"方块轮廓"字体。
原理:使用 Windows GDI 的单色字形位图和像素度量,复现经典 Windows 的文字效果。
把渲染出来的 1bit 网格读回来,每个亮点画成一个真实的方形轮廓,打包成 woff2。
这样得到的字体不依赖系统、不依赖 DPI,放大多少倍边缘都压在整像素上。
"""
import argparse
import sys
from functools import partial
from time import perf_counter
from types import SimpleNamespace
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib import woff2
from gdi_font import GdiFont
UPP = 64 # 每个点阵像素占多少字体单位
def save_woff2(builder, output, quality):
"""fontTools 没有公开压缩等级参数,只在此次保存期间替换其 Brotli 接口。"""
original = woff2.brotli
configured = SimpleNamespace(**vars(original))
configured.compress = partial(original.compress, quality=quality)
try:
woff2.brotli = configured
builder.save(output)
finally:
woff2.brotli = original
def runs(row):
"""把一行里连续的亮点合并成 (起点, 长度),减少轮廓数量。"""
out, start = [], None
for i, on in enumerate(row):
if on and start is None:
start = i
elif not on and start is not None:
out.append((start, i - start))
start = None
if start is not None:
out.append((start, len(row) - start))
return out
def build_glyph(font, ch):
"""渲染一个字符,返回 (TTGlyph, 步进宽度像素数)。"""
bitmap = font.bitmap(ch)
if bitmap is None:
return None, None
rows, left, top, adv = bitmap
pen = TTGlyphPen(None)
for r, row in enumerate(rows):
for c0, length in runs(row):
x0 = (left + c0) * UPP
x1 = (left + c0 + length) * UPP
y1 = (top - r) * UPP
y0 = y1 - UPP
pen.moveTo((x0, y0))
pen.lineTo((x0, y1))
pen.lineTo((x1, y1))
pen.lineTo((x1, y0))
pen.closePath()
return pen.glyph(), adv
def font_charset(path, index):
"""读源字体的 cmap,拿到它真正支持的所有码位。"""
try:
tt = TTFont(path, fontNumber=index, lazy=True)
except Exception:
tt = TTFont(path, lazy=True) # 不是 ttc 的情况
try:
cmap = tt.getBestCmap()
finally:
tt.close()
out = []
for cp in sorted(cmap):
if cp <= 0x1F or 0x7F <= cp <= 0x9F: # 控制字符
continue
if 0xD800 <= cp <= 0xDFFF: # 代理区
continue
out.append(chr(cp))
return out
def default_charset():
"""内置常用字集:ASCII + CJK 标点 + 全角 + 前 800 汉字。"""
chars = [chr(c) for c in range(0x20, 0x7F)]
chars += [chr(c) for c in range(0x3000, 0x3030)]
chars += [chr(c) for c in range(0xFF01, 0xFF5F)]
chars += [chr(c) for c in range(0x4E00, 0x4E00 + 800)]
return chars
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source", help="源字体,如 C:/Windows/Fonts/simsun.ttc")
ap.add_argument("-s", "--size", type=int, default=12, help="点阵像素大小")
ap.add_argument("-i", "--index", type=int, default=0, help="ttc 里第几个字体")
ap.add_argument("-c", "--chars", help="只要这些字的文本文件(留空则用内置常用字集)")
ap.add_argument("-a", "--all", action="store_true",
help="把源字体 cmap 里所有字符都打包进去(体积大、耗时长)")
ap.add_argument("-n", "--name", default="PixelSong", help="生成字体的名字")
ap.add_argument("-o", "--out", default="pixel.woff2")
ap.add_argument("--quality", "--brotli-quality", type=int, choices=range(12), default=0,
help="Brotli 压缩等级 0–11,默认 0(最快);越高通常越小、越慢")
ap.add_argument("--transform", "--woff2-transform", action="store_true",
help="开启 WOFF2 字形预变换(默认关闭;全量点阵字体可能很慢)")
args = ap.parse_args()
if args.size <= 0:
ap.error("--size 必须大于 0")
if sys.platform != "win32":
ap.error("GDI 字体转换仅支持 Windows")
with GdiFont(args.source, args.size, args.index) as font:
generate(args, font)
def generate(args, font):
ascent, descent = font.ascent, font.descent
print(f"使用 Windows GDI:{font.family},{args.size}px 单色点阵", file=sys.stderr, flush=True)
# 字符集
if args.all:
chars = font_charset(args.source, args.index)
print(f"源字体 cmap 共 {len(chars)} 个字符,开始渲染……", file=sys.stderr)
elif args.chars:
text = open(args.chars, encoding="utf-8").read()
chars = sorted(set(c for c in text if c.isprintable()))
else:
chars = default_charset()
glyph_order = [".notdef"]
glyphs, metrics, cmap = {}, {}, {}
pen = TTGlyphPen(None)
glyphs[".notdef"] = pen.glyph()
metrics[".notdef"] = (args.size * UPP, 0)
skipped = 0
for n, ch in enumerate(chars, 1):
if args.all and n % 2000 == 0:
print(f" {n}/{len(chars)}……", file=sys.stderr)
g, adv = build_glyph(font, ch)
if g is None or adv is None or adv == 0:
skipped += 1
continue
name = "uni%04X" % ord(ch)
if name in glyphs:
continue
glyph_order.append(name)
glyphs[name] = g
# 轮廓保留了点阵的原始水平位置,LSB 必须与实际轮廓左边界一致。
# 不能只用 ox:mask 内部也可能有空白列;负边距则按原样保留。
g.recalcBounds(glyphs)
lsb = g.xMin if g.numberOfContours != 0 else 0
metrics[name] = (adv * UPP, lsb)
cmap[ord(ch)] = name
if len(glyph_order) > 65535:
sys.exit(f"字形数 {len(glyph_order)} 超过 TrueType 上限 65535,请缩小字符集")
fb = FontBuilder(unitsPerEm=args.size * UPP, isTTF=True)
fb.setupGlyphOrder(glyph_order)
fb.setupCharacterMap(cmap)
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(ascent=ascent * UPP, descent=-descent * UPP)
ver = "1.0"
fb.setupNameTable({
"familyName": args.name,
"styleName": "Regular",
"uniqueFontIdentifier": f"{args.name};{ver}",
"fullName": args.name,
"psName": args.name.replace(" ", ""),
"version": f"Version {ver}",
})
fb.setupOS2(sTypoAscender=ascent * UPP,
sTypoDescender=-descent * UPP,
usWinAscent=ascent * UPP,
usWinDescent=descent * UPP)
fb.setupPost(isFixedPitch=0)
# gasp:告诉光栅器所有尺寸下都不要网格拟合、不要灰度抗锯齿。
# 轮廓已经严格压在像素格上,任何"优化"都只会把它挪歪。
gasp = newTable("gasp")
gasp.version = 1
gasp.gaspRange = {0xFFFF: 0}
fb.font["gasp"] = gasp
fb.font.flavor = "woff2"
# 完整点阵字体轮廓很多,默认跳过耗时的 WOFF2 预变换,仍保留 Brotli 压缩。
# 博客构建时的子集编码独立进行,不受这里的设置影响。
fb.font.flavorData = woff2.WOFF2FlavorData(
transformedTables=None if args.transform else set()
)
transform_status = "开启" if args.transform else "关闭"
print(f"正在编码并压缩 WOFF2(等级 {args.quality},预变换{transform_status}),请稍候……",
file=sys.stderr, flush=True)
started = perf_counter()
save_woff2(fb, args.out, args.quality)
print(f"保存完成,耗时 {perf_counter() - started:.1f} 秒", file=sys.stderr, flush=True)
print(f"字符 {len(cmap)} 个,跳过 {skipped} 个")
print(f"点阵 {args.size}px,unitsPerEm {args.size * UPP}(1 像素 = {UPP} 单位)")
print(f"→ {args.out}")
print(f"\nCSS 里写:font-size: {args.size}px(或 {args.size*2}px / {args.size*3}px)")
if __name__ == "__main__":
sys.exit(main())
"""用 Windows GDI 读取指定 TrueType 字体的单色字形与像素度量。"""
import ctypes as ct
from ctypes import wintypes as wt
from pathlib import Path
import sys
from fontTools.ttLib import TTFont
class FIXED(ct.Structure):
_fields_ = [("fract", wt.WORD), ("value", ct.c_short)]
class MAT2(ct.Structure):
_fields_ = [(name, FIXED) for name in ("eM11", "eM12", "eM21", "eM22")]
class GLYPHMETRICS(ct.Structure):
_fields_ = [
("width", wt.UINT), ("height", wt.UINT), ("origin", wt.POINT),
("advance_x", ct.c_short), ("advance_y", ct.c_short),
]
class TEXTMETRICW(ct.Structure):
_fields_ = [
(name, wt.LONG) for name in (
"height", "ascent", "descent", "internal_leading", "external_leading",
"average_width", "maximum_width", "weight", "overhang", "aspect_x", "aspect_y",
)
] + [
(name, wt.WCHAR) for name in ("first_char", "last_char", "default_char", "break_char")
] + [
(name, wt.BYTE) for name in ("italic", "underlined", "struck_out", "pitch", "charset")
]
class GdiFont:
"""私有加载源字体;退出时恢复 DC 并释放字体,不安装到系统。"""
def __init__(self, source, size, index=0):
if sys.platform != "win32":
raise RuntimeError("GDI 字体转换仅支持 Windows")
self.path = str(Path(source).resolve(strict=True))
self.dc = self.handle = self.previous = None
self.loaded = False
with TTFont(self.path, fontNumber=index, lazy=True) as font:
self.glyph_ids = {
cp: font.getGlyphID(name) for cp, name in (font.getBestCmap() or {}).items()
}
names = font["name"]
family = names.getDebugName(1)
self.family_names = {n.toUnicode().casefold() for n in names.names if n.nameID == 1}
if not family:
raise ValueError("源字体缺少 familyName")
weight = font["OS/2"].usWeightClass
italic = bool(font["head"].macStyle & 2)
self.gdi = ct.WinDLL("gdi32", use_last_error=True)
signatures = {
"AddFontResourceExW": ([wt.LPCWSTR, wt.DWORD, ct.c_void_p], ct.c_int),
"RemoveFontResourceExW": ([wt.LPCWSTR, wt.DWORD, ct.c_void_p], wt.BOOL),
"CreateCompatibleDC": ([wt.HDC], wt.HDC),
"DeleteDC": ([wt.HDC], wt.BOOL),
"CreateFontW": ([ct.c_int] * 5 + [wt.DWORD] * 8 + [wt.LPCWSTR], wt.HFONT),
"SelectObject": ([wt.HDC, wt.HGDIOBJ], wt.HGDIOBJ),
"DeleteObject": ([wt.HGDIOBJ], wt.BOOL),
"GetTextFaceW": ([wt.HDC, ct.c_int, wt.LPWSTR], ct.c_int),
"GetTextMetricsW": ([wt.HDC, ct.POINTER(TEXTMETRICW)], wt.BOOL),
"GetGlyphOutlineW": ([wt.HDC, wt.UINT, wt.UINT, ct.POINTER(GLYPHMETRICS),
wt.DWORD, ct.c_void_p, ct.POINTER(MAT2)], wt.DWORD),
}
for name, (args, result) in signatures.items():
function = getattr(self.gdi, name)
function.argtypes, function.restype = args, result
self.matrix = MAT2(FIXED(0, 1), FIXED(0, 0), FIXED(0, 0), FIXED(0, 1))
try:
# FR_PRIVATE:与系统字体同名时优先使用这个文件,只对当前进程生效。
if not self.gdi.AddFontResourceExW(self.path, 0x10, None):
raise RuntimeError(f"GDI 无法加载源字体:{self.path}")
self.loaded = True
self.dc = self.gdi.CreateCompatibleDC(None)
if not self.dc:
raise ct.WinError(ct.get_last_error())
# 负高度指定 em 像素大小;DEFAULT_CHARSET;NONANTIALIASED_QUALITY。
self.handle = self.gdi.CreateFontW(
-size, 0, 0, 0, weight, int(italic), 0, 0, 1, 0, 0, 3, 0, family
)
if not self.handle:
raise ct.WinError(ct.get_last_error())
self.previous = self.gdi.SelectObject(self.dc, self.handle)
if not self.previous or self.previous == ct.c_void_p(-1).value:
self.previous = None
raise RuntimeError("GDI 无法选择源字体")
selected = ct.create_unicode_buffer(128)
if not self.gdi.GetTextFaceW(self.dc, len(selected), selected):
raise RuntimeError("GDI 无法读取选中的字体名称")
if selected.value.casefold() not in self.family_names:
raise RuntimeError(f"GDI 回退到了 {selected.value},未选中源字体 {family}")
self.family = selected.value
metrics = TEXTMETRICW()
if not self.gdi.GetTextMetricsW(self.dc, ct.byref(metrics)):
raise RuntimeError("GDI 无法读取字体度量")
self.ascent, self.descent = metrics.ascent, metrics.descent
except BaseException:
self.close()
raise
def bitmap(self, char):
"""返回 (逐行布尔点阵, 左边距, 基线以上的顶边位置, 前进宽度)。"""
glyph_id = self.glyph_ids.get(ord(char))
if glyph_id is None:
return None
metrics = GLYPHMETRICS()
# GGO_BITMAP | GGO_GLYPH_INDEX:直接使用源 cmap 的字形,避免缺字时字体回退。
flags = 1 | 0x80
required = self.gdi.GetGlyphOutlineW(
self.dc, glyph_id, flags, ct.byref(metrics), 0, None, ct.byref(self.matrix)
)
if required == 0xFFFFFFFF:
raise RuntimeError(f"GDI 无法读取字形 U+{ord(char):04X}")
rows = []
if required:
buffer = ct.create_string_buffer(required)
result = self.gdi.GetGlyphOutlineW(
self.dc, glyph_id, flags, ct.byref(metrics), required, buffer, ct.byref(self.matrix)
)
if result == 0xFFFFFFFF:
raise RuntimeError(f"GDI 无法读取点阵 U+{ord(char):04X}")
# GGO_BITMAP:每行按 DWORD 对齐,字节内最高位在左,行序自上而下。
stride = ((metrics.width + 31) // 32) * 4
data = buffer.raw
for y in range(metrics.height):
rows.append([
bool(data[y * stride + x // 8] & (0x80 >> (x % 8)))
for x in range(metrics.width)
])
return rows, metrics.origin.x, metrics.origin.y, metrics.advance_x
def close(self):
if self.previous:
self.gdi.SelectObject(self.dc, self.previous)
self.previous = None
if self.handle:
self.gdi.DeleteObject(self.handle)
self.handle = None
if self.dc:
self.gdi.DeleteDC(self.dc)
self.dc = None
if self.loaded:
self.gdi.RemoveFontResourceExW(self.path, 0x10, None)
self.loaded = False
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()