AIGC标识 不依赖 GDI+、自带中文字体:写一个纯托管 TrueType 渲染库

不依赖 GDI+、自带中文字体:写一个纯托管 TrueType 渲染库

源码:https://github.com/sevencat/zlmsharp/tree/main/src/GlyphRaster
NuGet:Sevencat.GlyphRaster(net8.0,零依赖,2 MB 内含中文字体)

两点说明先说在前面:这套做法是抄 OpenCV 5 的 UniFont 的;代码主要由 AI 生成,
我负责提需求、定 API、做取舍和验收。本文可以当成"如何让 AI 写出一个能用的 TTF 渲染器"来读。

缘起:在 OpenCV 画面上写中文

我需要在视频帧上叠加分析结果:检测框、类别名、置信度、OCR 文本。
帧是裸内存的 BGR24 缓冲,最直接的写法是 Cv2.PutText——但它用的是 Hershey 矢量字体,没有汉字,
写出来全是 ???。

换 System.Drawing?.NET 6 之后非 Windows 直接抛 PlatformNotSupportedException,
而且我要做 AOT 发布,System.Drawing.Common 并不合适。
SkiaSharp 能解决,但为了在字节数组上写几十个标签,引一整套原生图形栈有点重,AOT/裁剪配置也要额外照顾。

SixLabors.ImageSharp + ImageSharp.Drawing 也是纯托管,同样是"读字体文件、自己光栅化"的路子,
画中文没问题,AOT 也友好——但它的许可证是颗雷:ImageSharp 2.0 起就从 Apache 2.0 换成了
Six Labors Split License,营收超过门槛(100 万美元)的商业使用需要购买商业授权,
"ImageSharp 是 Apache 2.0" 这个印象早就过期了。这个库本身是 GPL-3.0 开源的,下游又可能拿去做商业产品,
我不想给别人埋一张突如其来的 License 账单。功能再好,许可上说不清就先排除。

自制 16×16 点阵字库(hzk16)也试过,能显示,但只有 GB2312、没有抗锯齿、字号不可变,
还得在包里塞一个 .bin 资源,字型也确实难看。

于是决定自己写:只读 TTF,只做光栅化,只往调用方的内存里写像素。
整个库不碰任何图形 API,自然也就没有平台差异和 AOT 问题。

问题在于:为了在画面上写几十个汉字,值不值得把整套 OpenCV 原生包拖进来?OpenCvSharp4 加上对应平台的
运行时包,体积几十到上百 MB,还得操心原生 DLL 怎么部署、runtimes/ 目录怎么带、AOT 能不能过。
我只是要在字节数组上铺字,不需要矩阵运算、不需要视频解码,更不需要那几百个 P/Invoke。

所以我把这套作业抄成了纯托管版:同样的字体、同样的"压进二进制 + 运行时解压",但没有 OpenCV,没有原生 DLL。

最终形态是 GlyphRaster:一个 2 MB 的 DLL(其中 2 MB 是压缩后的字体),零第三方依赖,
既能出字形位图,也能只出矢量轮廓。

写到这里得先坦白两件事。

第一,这套做法不是我原创的,是抄 OpenCV 5 的。 OpenCV 5 的 UniFont(WITH_UNIFONT)
就把文泉驿微米黑直接编进了库,运行时解压出来渲染,从此不管系统装没装中文字体——我连字体文件
都是从它那儿拿的(同一个 gz,没改一个字节)。

第二,这个库的代码是 AI 写的。 我只做了三件事:提需求("在 RGB24 缓冲上画中文标签,
零依赖、AOT 能过、别每帧分配")、定 API 形状、验收(逐条 review、跑测试、拿真实视频帧对效果)。
TrueType 解析、扫描线光栅化、排版、缓存加锁,以及那堆测试,都是大模型吐出来的。
所以这篇不是"我怎么手写了一个渲染器",而是"我如何指挥 AI 写出一个能用的渲染器"——
读者有权知道自己在读谁写的代码,这也是我把这句放在开头的原因。

目标与非目标

目标:

  1. net8.0 纯托管,无原生依赖,裁剪 / NativeAOT 分析器 0 警告;
  2. 内置一套覆盖中日韩的字体,运行环境不需要装任何字体;
  3. 布局在 Span<byte> 上,能在 OpenCV Mat、Bitmap、自定义帧缓冲里直接用;
  4. 抗锯齿达到实用水平(别是硬邦邦的单色边缘)。

非目标:

  1. 不做完整排版引擎——没有 HarfBuzz 级别的整形,连字、kerning、阿拉伯文重排都不支持;
  2. 不追求 GDI/Skia 那种亚像素定位和 hinting 质量;
  3. 不支持 CFF/OTF 轮廓(OTTO),只支持 TrueType 的 glyf。

事实上一开始就否掉了 CFF:分析场景只需要中文、拉丁、日文假名、韩文,
glyf 覆盖了绝大多数开源中文字体(思源系列是 CFF,但文泉驿、黑体、宋体都是 glyf)。

拆开 TTF:其实只需要九张表

TrueType 的结构比想象中友好。文件头是 sfnt 目录:表数量 + 一张 (tag, offset, length) 表。
真正要读的只有九张:

表 用途
head unitsPerEm、indexToLocFormat
hhea ascender、descender、lineGap、numberOfHMetrics
maxp 字形总数
hmtx 每个字形的推进宽度(advance)
cmap 码点 → 字形索引
loca 字形索引 → glyf 表内偏移
glyf 轮廓数据
name 字体族名(只用于展示)

几个实现要点:

  • cmap 只支持四种子表:format 0(byte 映射)、4(BMP 分段映射)、6(trimmed)、12(UCS-4 分段)。
    中文 TTF 基本都是 format 4,emoji 类字体才会用 12。选子表时优先挑 (3,10) / (3,1) / (0,x)。
  • loca 有短/长两种格式,由 head.indexToLocFormat 决定:短格式存的是 offset / 2,读出来要乘 2。
  • glyf 有两种字形:简单字形(一个轮廓点数数组 + 每点 on/off curve 标志)和复合字形
    (引用其他字形,附 dx/dy 偏移、可选的 2×2 变换与缩放)。复合字形必须递归展开,
    而且要防环——我用一个 depth 上限,同时沿路累积变换矩阵。
  • 轮廓点不是"贝塞尔段数组",而是点序列 + on/off 标志:两个 on-curve 点是直线;
    on/off/on 是二次贝塞尔;两个连续 off-curve 点之间,按规范隐含一个 on-curve 中点。
    这个"隐式中点"规则如果不处理,字会缺笔画或者出现尖刺。

解析完得到 GlyphOutline:List<OutlinePoint>[],每个点带 (X, Y, OnCurve),
坐标还是 em 单位、y 轴向上。

光栅化:纵向上采样,横向精确覆盖

把轮廓变成 A8 覆盖率位图,我用的是一套很朴素的扫描线算法:

  1. 展平:把所有二次贝塞尔按弦高误差细分成折线,误差默认 0.25 px;
  2. 定边界:求出所有折线的包围盒,向上/向左取整,得到位图的 (left, top, width, height);
  3. 多重采样:每个像素行取 4 条子扫描线(y + 0.5/4, 1.5/4, 2.5/4, 3.5/4),
    对每条子扫描线求与所有边的交点,按非零环绕规则排出实心区间;
  4. 横向精确覆盖:区间与像素的横向重叠长度直接按比例累加到该像素的覆盖率上。

也就是说,竖直方向是 4 倍超采样,水平方向是解析计算的精确覆盖率。
等效 4×AA,实际观感跟 GDI 的灰度抗锯齿差不多,代价是要遍历每个像素行的所有边。

几个保护:

  • 位图尺寸超过 4096 直接返回空白——防止恶意/损坏字体让一次分配吃掉几个 GB;
  • 复合字形的变换矩阵归一化后再展平,避免缩放后控制点距离爆炸导致步数失控(步数限制在 1..128);
  • 空字形(空格、缺字、glyphIndex == 0)返回 GlyphMask.Blank(advance):没有像素,但有推进量。

覆盖率存成 byte[],Height 行 × Width 列。混合就退化成一次整数运算:

var alpha = mask.Coverage[row * mask.Width + col];
var inv = 255 - alpha;
dst[p]     = (byte)((dst[p]     * inv + color.R * alpha) / 255);
dst[p + 1] = (byte)((dst[p + 1] * inv + color.G * alpha) / 255);
dst[p + 2] = (byte)((dst[p + 2] * inv + color.B * alpha) / 255);

DrawRgb24 / DrawBgra32 就这一件事,外加把负坐标、超出右下的部分算成裁剪区间。
不做临时缓冲、不做 new,几十个标签叠一帧也没有 GC 压力。

排版:够用就好

排版逻辑只有一百多行:

  • 用 text.EnumerateRunes() 逐码点走(能正确处理代理对,emoji 不会拆坏);
  • 笔位 penX 累加 GlyphMask.Advance;
  • \n 换行,\t 按 4 个空格宽度,\r 忽略;
  • 给了 MaxWidth 且下一个字放不下时,回退笔位、行号 +1;
  • 每个非空字形的位图位置 = (penX + mask.OffsetX, ascent + line * lineHeight + mask.OffsetY)。

中日韩每个字都是单码点单字形,不需要整形,所以这里不需要 HarfBuzz。
拉丁文会缺 kerning(比如 AV 之间的收紧),在标签这种短文本上看不出来,
真要精细排版,这个库不是正确选择。

产出的 TextLayout 带 Glyphs / Width / Height / Ascent / LineHeight / LineCount,
Height 是"行数 × 行高",可以直接当文本盒用来画底衬。

两条出口

出口一:字形位图

font.Layout(text, size) → TextPainter.DrawRgb24(...)。适合绝大多数叠加场景。

出口二:矢量轮廓

font.GetTextPaths(text, size) 返回 PathCollection,可以 Transform(Matrix3x2)、
Flatten(离散成折线)、AsClosedPath、ToSvgPathData(导出 SVG,调试时特别有用)。

这条路线适合:描边文字、文字动画、几何运算、交给 GPU 三角化,
或者你手上只有 Cv2.FillPoly 这种"只认多边形"的绘制器。

性能:别每帧重建字体

同一台机器,1280×720 CV_8UC3,50 个 OCR 标签,字号 16,预热 5 帧后取 50 帧平均:

绘制方式 每帧耗时
Cv2.PutText(复用 FontFace) 1.76 / 2.49 ms
TextPainter.DrawRgb24 2.58 / 2.99 ms
GetTextPaths + Cv2.FillPoly 13.4 / 16.8 ms

其中框和标签底(Cv2.Rectangle)固定要 1.41 ms,真正花在文字上的只有 0.4~1.2 ms。
轨迹路线慢一个数量级,因为 GetTextPaths 每次都要重新构造路径(5.44 ms)再展平(7.73 ms)。

最大的坑不是算法,是实例生命周期:

写法 每帧耗时
Cv2.PutText + 每帧 new FontFace 13.2 ~ 14.0 ms
字形位图 + 每帧 TextFont.LoadBuiltIn() 6.6 ~ 8.3 ms

LoadBuiltIn() 每次都会重新解压、重新解析字体表,并生成一个全新的字形缓存,
上一帧光栅化的结果全部作废。改成 TextFont.BuiltIn 这个静态共享实例后,耗时直接砍到 1/3。
TextFont.BuiltIn 内部的字形缓存带锁,可以跨线程共享(我加了并发测试,四个线程各跑八轮,输出完全一致)。

字形缓存键是 (码点, 字号 × 4),上限 4096 条,满了整体清空。这不是 LRU,
所以在字号种类很多(比如做文字缩放动画)的场景里会反复失效,最好是固定几档字号。

踩过的坑

写这个库和用它的时候踩的坑,比写代码本身花的时间还多:

  1. PathSegment 取错坐标。QuadraticTo 的 X1,Y1 是控制点,终点在 X2,Y2;
    LineTo 的 X1,Y1 恒为 0。一开始拿 X1,Y1 当终点,Cv2.FillPoly 出来的字形整个塌成一团。
  2. Bounds 不是从 (0,0) 开始。GetTextPaths 返回的包围盒是墨迹包围盒,
    要把文字画到 (100, 50),得减去 Bounds.X/Y,否则整体偏移。
  3. (x, y) 是文本块左上角,不是基线。这个 API 设计上是"把整个排版块放到这里",
    比"传基线坐标"更好用,但第一次用容易按 GDI 的 DrawString 习惯去理解。
  4. RGB 和 BGR 是两个世界。DrawRgb24 按 R,G,B 顺序写内存,白色三通道相同所以没事;
    往 OpenCV 的 BGR24 上画橙色,得传 new RgbColor(0, 165, 255) 而不是 (255, 165, 0)。
  5. stride 别用 width * 3 硬算。Mat.Step() 因为行对齐可能大于 width * 3,硬算会画斜。
  6. 字形有孔。Cv2.FillPoly 不看绕向,一律填实,「回」会变成一块实心方块。
    Cv2.ContourArea 返回绝对值,判断不了方向,得自己拿鞋带公式算有向面积,
    再用背景色把负面积(孔)重填一遍——这一招只在纯色底上成立,底色是图像就只能走位图路线。
  7. TextFont 没有 Dispose,不能 using。它只持有托管内存(字节数组 + 字典),交给 GC 就行。
  8. 布局宽度是 advance 之和,不是墨迹宽度。某些字形左侧会伸出笔位(OffsetX < 0),
    贴边绘制时会被裁掉一点,需要自己留 padding。

用起来

dotnet add package Sevencat.GlyphRaster --version 0.1.5
using GlyphRaster;

var font = TextFont.BuiltIn;                          // 共享实例,别每帧新建
var layout = font.Layout("中文标签 0.98", 16f);        // 16 = em 高度(像素)

// RGB24 缓冲;画到 OpenCV 的 BGR24 Mat 上时,stride 用 Mat.Step()
TextPainter.DrawRgb24(pixels, width, height, stride, layout, x: 8, y: 8, RgbColor.White);

只想要轮廓:

var paths = font.GetTextPaths("回口A0", 16f).Flatten(0.25f);
foreach (var path in paths)   // VectorPath: Start + Segments(LineTo)
{
    // 自己 FillPoly / 描边 / 转 SVG
}

内置字体是文泉驿微米黑,从 OpenCV 5 的 WITH_UNIFONT 用的同一个文件(gzip 后 2.01 MB)嵌进 DLL,
Apache 2.0 / GPL v3 双许可(含字体嵌入例外),覆盖 GB2312/GBK 常用汉字、假名和拉丁字母。
换句话说,连字体带思路都是从 OpenCV 5 搬来的——只是不用把 OpenCV 5 那个包也搬过来。
想换成自己的字体,TextFont.LoadFromFile("simhei.ttf") 即可。

结语

一句话总结:抄 OpenCV 5 的作业,但不带 OpenCV 5 那个包——同样的"字体进二进制 + 运行时解压",
换成纯托管实现,2 MB 单 DLL,任何平台任何部署方式都能跑;也顺手绕开了 ImageSharp 那套要按营收算钱的许可。

再补一句:代码是 AI 写的,思路是抄的,坑是真踩的。AI 能把语法和结构写对,
但"X1,Y1 不是终点""Bounds 不从 0 开始""BGR 不是 RGB""每帧重建字体实例会把缓存全废掉"这些,
只有真的把字画到帧上、看见它歪了、再看数据,才知道问题在哪——这部分经验和这份文档,是我的。

写一个能用的 TrueType 渲染器,难度比想象中小:九张表、一个扫描线光栅化、一百多行排版,
一万行不到的 C# 就够了。真正费时间的,是把边界情况想全——复合字形、隐式中点、TTC、
字形缓存的生命周期、孔洞绕向、BGR 与 RGB 的差别。

如果你也在给裸像素缓冲写中文,可以试试看:

用的时候踩到坑,也欢迎回来说一声;这份文档里的"踩坑清单",一半是自己撞出来的,另一半是被别人问出来的。

posted @ 2026-11-07 16:44  首席装逼官  阅读(6)  评论(0)    收藏  举报