跳到正文
← 返回文章列表

编程 · 2026-10-23 · 29 分钟

点阵字体二三事

关于如何在高分辨率屏幕上使用点阵宋体字体。

缘起

作为一个 DNF 的忠实玩家,DNF 早期的美术风格给我留下了深刻的印象。早年的 CRT 显示器,搭配清晰、朴素、硬朗的字体,还有那些神秘莫测的装备名称,“玄磁光剑”、“双龙魔影剑”、“细雪之舞”,真的太美了。

DNF
DNF

我一直想在浏览器上复刻 DNF 的部分场景,但出于种种原因(懒惰)一直没有做。 前不久重构博客时,我突发奇想,如果能用 DNF 中的那种字体风格来实现博客,那该多酷啊!

Just 宋体

DNF 用的是宋体,这个在贴吧里很多人都提到过。 我想当然的在 DEMO 网页中写了 font-family: SimSun; font-size: 12px;,但在我的 4K 屏幕下一直显示成这样:

宋体-4K
宋体-4K

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

宋体-1080P
宋体-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 的设备上使用点阵宋体了吗? 自然还是有的~

我们可以用脚本将点阵字体转为标准的矢量字体,用矢量模拟点阵,这样无论是什么分辨率的屏幕/操作系统,都可以正常显示点阵字体了,因为对于任何渲染引擎来说,它就是一个常规矢量字体。

相关的脚本我放在文末了,这里说一下如何使用以及具体思路:

shell
# 首先安装相关依赖
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()

评论