不依赖 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 写出一个能用的渲染器"——
读者有权知道自己在读谁写的代码,这也是我把这句放在开头的原因。
目标与非目标
目标:
net8.0纯托管,无原生依赖,裁剪 / NativeAOT 分析器 0 警告;- 内置一套覆盖中日韩的字体,运行环境不需要装任何字体;
- 布局在
Span<byte>上,能在 OpenCVMat、Bitmap、自定义帧缓冲里直接用; - 抗锯齿达到实用水平(别是硬邦邦的单色边缘)。
非目标:
- 不做完整排版引擎——没有 HarfBuzz 级别的整形,连字、kerning、阿拉伯文重排都不支持;
- 不追求 GDI/Skia 那种亚像素定位和 hinting 质量;
- 不支持 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 覆盖率位图,我用的是一套很朴素的扫描线算法:
- 展平:把所有二次贝塞尔按弦高误差细分成折线,误差默认 0.25 px;
- 定边界:求出所有折线的包围盒,向上/向左取整,得到位图的
(left, top, width, height); - 多重采样:每个像素行取 4 条子扫描线(
y + 0.5/4, 1.5/4, 2.5/4, 3.5/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,
所以在字号种类很多(比如做文字缩放动画)的场景里会反复失效,最好是固定几档字号。
踩过的坑
写这个库和用它的时候踩的坑,比写代码本身花的时间还多:
PathSegment取错坐标。QuadraticTo的X1,Y1是控制点,终点在X2,Y2;
LineTo的X1,Y1恒为 0。一开始拿X1,Y1当终点,Cv2.FillPoly出来的字形整个塌成一团。Bounds不是从 (0,0) 开始。GetTextPaths返回的包围盒是墨迹包围盒,
要把文字画到(100, 50),得减去Bounds.X/Y,否则整体偏移。(x, y)是文本块左上角,不是基线。这个 API 设计上是"把整个排版块放到这里",
比"传基线坐标"更好用,但第一次用容易按 GDI 的DrawString习惯去理解。- RGB 和 BGR 是两个世界。
DrawRgb24按R,G,B顺序写内存,白色三通道相同所以没事;
往 OpenCV 的 BGR24 上画橙色,得传new RgbColor(0, 165, 255)而不是(255, 165, 0)。 stride别用width * 3硬算。Mat.Step()因为行对齐可能大于width * 3,硬算会画斜。- 字形有孔。
Cv2.FillPoly不看绕向,一律填实,「回」会变成一块实心方块。
Cv2.ContourArea返回绝对值,判断不了方向,得自己拿鞋带公式算有向面积,
再用背景色把负面积(孔)重填一遍——这一招只在纯色底上成立,底色是图像就只能走位图路线。 TextFont没有Dispose,不能using。它只持有托管内存(字节数组 + 字典),交给 GC 就行。- 布局宽度是 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 的差别。
如果你也在给裸像素缓冲写中文,可以试试看:
- 源码:https://github.com/sevencat/zlmsharp/tree/main/src/GlyphRaster
- NuGet:https://www.nuget.org/packages/Sevencat.GlyphRaster
- 有问题或建议:直接提到 https://github.com/sevencat/zlmsharp/issues,
或者 fork 之后提 PR——bug 修复、新字体、性能优化都欢迎,能合并的尽量合并。
用的时候踩到坑,也欢迎回来说一声;这份文档里的"踩坑清单",一半是自己撞出来的,另一半是被别人问出来的。
浙公网安备 33010602011771号