简介:这是面向MCU开发与嵌入式学习者的点阵字体文件查看工具,采用C#编写,用于直观查看HZK16等16×16点阵字库数据,尤其在低资源环境下的文本显示调试中很实用。工具以二维数组表示字符像素,通过读取、解析和图形化展示,帮助开发者快速确认字形并排查字库问题。压缩包内共15个文件,结构精炼:8个.cs源码承担文件读取、数据解析、界面逻辑与程序入口,3个.resx保存窗体资源,另含.sln/.csproj工程文件、App.config及Settings设置项,整套工程可在Visual Studio中直接打开编译;包体仅9KB,轻量易用,已有935人学习下载。通过完整项目代码,可掌握System.IO读取字库文件、二进制数据解析、Windows Forms图形化展示以及缩放翻页等交互实现,为理解嵌入式字符显示机制和自建类似工具提供实用参考,也可作为C#桌面程序开发的演示范例。
1. 点阵字体这个名词并不老:C# 查看工具能救回大量旧字库
点阵字体听起来是很古董的概念,但在嵌入式LCD、老式工控屏和复古UI里,它到今天还有大量存量,而且格式比想象的更杂。我在一个工业屏项目里就栽过跟头:屏幕只能显示8x8点阵,现成查看工具要么只支持某个私有格式,要么把位图放大得没法看。折腾两天后,我干脆写了一个C#点阵字体文件查看工具,把解析、渲染、缩放和批量导出全部跑通。这篇要拆的正是这套源码:它解决的是未知点阵字库的肉眼查看和批量导出问题,适合单片机工程师、做像素风界面的开发者,也适合像你我一样需要临时读老字库的人。你不需要懂底层C++,跟着步骤就能跑通。
2. 拆解二进制结构:FNT0 格式定义与 C# 位操作
很多点阵字体查看工具难用,不是C#写不出来,而是格式确认太折腾。市面上的字库文件,常见的有HZK16、HZK32、FON、BDF,还有各种LCD厂商自定义的私有二进制。HZK16是16x16汉字库,每行两个字节;BDF是文本格式;而老式工业屏常用的是连文档都找不到的裸字模。这套源码里默认解析一个抽出来的简化格式“FNT0”,格式设计比较干净,方便你替换成自己的私有格式。
2.1 格式定义:8字节文件头加连续字模
FNT0不是标准格式,是我拆了几种常见字库后抽象出来的。整个文件由文件头加字模数据组成,字模按字符编码顺序连续存放,没有索引表。文件头固定8字节:
| 字节偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0~3 | 4 | 魔数 | 固定为FNT0 |
| 4 | 1 | StartChar | 起始字符编码 |
| 5 | 1 | EndChar | 结束字符编码 |
| 6 | 1 | Width | 字体像素宽度 |
| 7 | 1 | Height | 字体像素高度 |
单个字形数据量不是Width * Height这么简单,因为文件是字节流,每行像素必须按字节对齐。例如宽度为8时,每行占1字节;宽度为12时,每行仍要占2字节,多余位补零。所以引入两个关键参数:
stride = (Width + 7) / 8,表示每行占用的字节数;glyphSize = stride * Height,表示每个字形占用的字节数。
字符总数是EndChar - StartChar + 1,所以整个文件期望长度是:
8 + glyphSize * (EndChar - StartChar + 1)这个检查很重要,后面坑1会用到。
2.2 读取文件头并解析字形数据
读取逻辑我放在BitmapFontLoader类里,用静态方法加载byte[]。之所以不直接传入文件路径,是为了让单元测试可以传入内存构造的任意字节数组,不用真实文件。
public static BitmapFont LoadFont(byte[] raw) { // 魔数固定为 FNT0,占用前4字节 if (raw.Length < 8 || raw[0] != 'F' || raw[1] != 'N' || raw[2] != 'T' || raw[3] != '0') throw new FormatException("文件头魔数不匹配,不是FNT0点阵字体"); var font = new BitmapFont(); font.StartChar = raw[4]; font.EndChar = raw[5]; font.Width = raw[6]; font.Height = raw[7]; int charCount = font.EndChar - font.StartChar + 1; int stride = (font.Width + 7) / 8; // 每行按字节对齐后的宽度 int glyphSize = stride * font.Height; // 单个字形占用的字节数 int expectedLength = 8 + glyphSize * charCount; if (raw.Length < expectedLength) throw new InvalidDataException($"文件长度不足:期望{expectedLength}字节,实际{raw.Length}字节"); font.Glyphs = new byte[charCount][]; int cursor = 8; for (int i = 0; i < charCount; i++) { font.Glyphs[i] = new byte[glyphSize]; Array.Copy(raw, cursor, font.Glyphs[i], 0, glyphSize); cursor += glyphSize; } return font; }这段代码先校验魔数和长度,再读取头部字段。关键参数是stride和glyphSize,如果文件被截断,会提前抛异常而不是解析到一半崩溃。这里字形数据用byte[][]而不是二维数组,是因为每个字形需要独立引用,后续按索引取某一字符更顺手。若你的文件比expectedLength大,说明可能带尾部信息,这里直接忽略多余部分。
2.3 MSB-first 和 LSB-first:位顺序决定是否镜像
点阵字模最坑的是每个字节里哪一位对应最左边像素。多数老字库采用MSB-first,即一个字节的最高位对应左边第一个像素;但也有反过来的,尤其是一些LCD厂商私有格式。下面这段代码把字节数组转成0/1像素矩阵,用一个bool控制顺序。
bool msbFirst = true; // 通常为true,若镜像翻转则改为false for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { byte b = glyph[row * stride + col / 8]; int bit = msbFirst ? (7 - col % 8) : (col % 8); pixels[row, col] = (byte)((b >> bit) & 0x01); } }col / 8得到当前列所在的字节索引,col % 8是字节内偏移。msbFirst为true时取位7,也就是最高位。宽度不是8的倍数时,每行末尾多余的位通常补零,但我们不读这些位,对显示不影响。这个bool建议做成界面上的一个“翻转”按钮,后面坑2会细说。
3. 渲染与交互:在 WinForms 里让 8x8 像素放大不模糊
字体文件解析完只是第一步,查看工具的核心体验在渲染。点阵字体的原始尺寸往往只有8到16像素,直接拉大很考验渲染方式。这一章讲我在源码里用的 WinForms 渲染方案。
3.1 自绘控件与 Paint 事件
我自定义了一个PixelFontView控件,继承自UserControl,把所有字形画在一个网格里。这样滚动、缩放和刷新都集中在一个区域。
public sealed class PixelFontView : UserControl { private BitmapFont _font; private float _scale = 10f; public void SetFont(BitmapFont font) { _font = font; Invalidate(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_font == null) return; e.Graphics.Clear(Color.Black); int cols = Math.Max(1, (Width - 20) / (int)(_font.Width * _scale)); int x0 = 10, y0 = 10; int count = _font.Glyphs.Length; for (int i = 0; i < count; i++) { int row = i / cols; int col = i % cols; int x = x0 + col * (int)(_font.Width * _scale); int y = y0 + row * (int)(_font.Height * _scale); DrawGlyph(e.Graphics, _font, _font.StartChar + i, x, y); } } private void DrawGlyph(Graphics g, BitmapFont font, int charCode, int x, int y) { byte[] glyph = font.Glyphs[charCode - font.StartChar]; int stride = (font.Width + 7) / 8; for (int py = 0; py < font.Height; py++) { for (int px = 0; px < font.Width; px++) { byte b = glyph[py * stride + px / 8]; int bit = 7 - px % 8; if (((b >> bit) & 1) == 1) { e.Graphics.FillRectangle(Brushes.White, x + px * _scale, y + py * _scale, _scale, _scale); } } } } }注意OnPaint里我用FillRectangle直接按缩放比例画实心矩形,没有经过DrawImage。这样最可控,缩放倍数非整数也不会有插值问题。Brush用白色比较省事,想换前景色可以用SolidBrush存成成员变量。这里_scale是关键参数,初始10倍是为了看清8x8像素;如果字体分辨率是16x16,建议初始5倍。
3.2 鼠标滚轮缩放与最近邻采样
如果要在放大时保留原始锯齿风格,绝不能使用默认的Graphics.DrawImage。WinForms 默认插值是双线性,会导致边缘出现灰色过渡。滚轮缩放代码如下:
protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); if (e.Delta > 0) _scale = Math.Min(30f, _scale * 1.1f); else _scale = Math.Max(1f, _scale / 1.1f); Invalidate(); }滚轮每次贡献的Delta通常为120,这里直接按比例调整_scale。最小1倍,最大30倍,防止用户把字体放大到看不出像素界限。
如果你更想用DrawImage画字形位图,那必须显式设置最近邻和半像素偏移:
g.InterpolationMode = InterpolationMode.NearestNeighbor; g.PixelOffsetMode = PixelOffsetMode.Half;PixelOffsetMode.Half也很重要,它让采样点对准像素中心,避免放大后整体偏半格。我自己实测过,只设NearestNeighbor不够,加上Half后边缘才锐利。这是所有像素风格渲染的通用套路。
3.3 一次性读入 vs 按需延迟加载
FNT0 这种小字库,一个文件最多几百KB,完全可以一次读入内存。我在LoadFont里用File.ReadAllBytes读完,再走内存解析,这样后续操作不碰磁盘。
但如果是全量 GBK 点阵字库(几千个汉字),一次性加载可能吃掉8MB以上,此时建议只解析用户当前请求的字符。常见做法是建立索引表,头文件里记录每个字形在文件中的偏移,按需FileStream.Seek后读取单个字形。这一版的源码没有做“懒加载”,是因为定位是查看工具而非字体生成库。文件超过5MB时,我开始考虑延迟加载;如果你要做成编辑器,最好一开始就设计成按需读取。
4. 踩坑实录:点阵字体查看器最容易翻车的四个地方
这里写的是我在这个项目里真实遇到过的坑,按“现象 → 原因 → 解决”的顺序记录。每一条都值得提前避开。
4.1 坑1:文件长度与头部声明不一致导致越界
现象:解析到一半抛出IndexOutOfRangeException,或者界面上显示的字形全是乱码,每个字符的像素叠加在一起。
原因:文件头EndChar - StartChar + 1计算出的字符数量,比文件实际能容纳的字形数量多。常见于从老设备备份出来的字库文件被截断,或者是把不同文件的头和数据硬拼在一起。没有在解析前做总长度校验,直接按循环读,就会越界。
解决:在LoadFont开头用raw.Length对比期望长度,这是我的习惯:
int expectedLength = 8 + glyphSize * charCount; if (raw.Length < expectedLength) throw new InvalidDataException($"文件长度不足:期望{expectedLength}字节,实际{raw.Length}字节");注意这里用的是<而不是!=。很多复制传输会多出几个尾部填充字节,只要不短于期望长度就能解析。如果你发现合理文件总是长度不足,那第一优先怀疑头部字段被单字节读成了无符号数,导致起始/结束字符区间错误。
4.2 坑2:位顺序搞反导致字符左右镜像
现象:字符看起来左右翻转,比如显示‘A’时尖朝右,或者整个字形呈“面条”状混乱排列。
原因:文件实际是LSB-first排列,代码却按MSB-first解析,导致每个字节内的位被颠倒读取。对8x8字形来说,这条翻转会让所有垂直线条变成对称乱码,因为左右边界完全换了位置。
解决:做一个切换开关,而不是代码里写死。最简单的方式是把 2.3 节的msbFirst变量提升为控件属性,然后绑到界面按钮:
public bool MsbFirst { get; set; } = true; private void FlipButton_Click(object sender, EventArgs e) { MsbFirst = !MsbFirst; pixelFontView.Invalidate(); }切换后用肉眼观察一个字母的镜像是否正常。也可以更进一步,自动识别:比较空格和‘M’这类字符左右对称的特征,但多数情况下手动切换一次就够了。这里我给源码留下一个“翻转”按钮,避免用户猜半天。
4.3 坑3:宽度不是8的倍数时 stride 算错
现象:每个字符都整体错位,越到后面的字形越不连贯,明明一行只有8个像素,却从下一行中间开始。
原因:部分自定义格式里宽度为12、16或24时,每行字节数不是简单的Width / 8。我一开始写代码图省事用了int stride = width / 8,当宽度12时算出1,实际每行应该补到2字节。于是解析器把上一行的后半字节当成下一行开头,越错越远。
解决:统一用(Width + 7) / 8,并且把stride作为字段保存在BitmapFont里,而不是每次临时算。同时,很多老格式有额外强制对齐规则,例如 HZK16 是16宽度,每行2字节;但某些LCD私有格式会强制每行4字节,即使只有8像素宽。遇到这种,就给BitmapFont增加一个LineBytes属性,在解析时覆盖stride。
public int Stride => (Width + 7) / 8;把Stride做成只读属性,所有取字模的地方统一走它,就不会出现两处计算不一致。
4.4 坑4:缩放后边缘发虚,看起来像用了劣质插值
现象:放大后边缘有灰阶,像把像素字体做了“羽化”,完全失去点阵字的锐利感。
原因:用Graphics.DrawImage时,默认的InterpolationMode是双线性插值。双线性会在像素之间生成过渡色,对照片是好事,对点阵字体是灾难。另外PixelOffsetMode默认是InvalidOffset,也可能让采样点落在像素边界上。
解决:如果你走DrawImage路线,必须同时设置两个属性:
g.InterpolationMode = InterpolationMode.NearestNeighbor; g.PixelOffsetMode = PixelOffsetMode.Half;我最后还是干脆放弃了DrawImage,直接在OnPaint里用FillRectangle画每一个像素点。当缩放倍数是整数时,这种方式绝对锐利;当缩放倍数是非整数比如1.37倍时,FillRectangle同样会有轻微不匀,但只要把_scale控制在整数附近,肉眼几乎感知不到。
for (int py = 0; py < font.Height; py++) { for (int px = 0; px < font.Width; px++) { if (IsPixelOn(glyph, stride, px, py)) { e.Graphics.FillRectangle(Brushes.White, x + px * (int)_scale, y + py * (int)_scale, (float)Math.Ceiling(_scale), (float)Math.Ceiling(_scale)); } } }这里把缩放值先取整,再用Ceiling调整矩形宽度,是为了避免像素之间出现细缝。这是我调了很久才定下来的细节。
5. 进阶技巧:把查看器变成批量导出工具,并守护解析逻辑
查看器只能看还不够,实际项目里往往要把字库导出成图片或文本,给美术或嵌入式同事确认。这个源码里的导出功能很直接:遍历所有字形,逐个输出成文本点阵。为了方便我定义了一个DumpGlyphToText方法,把像素输出成#和.两种字符。
public static string DumpGlyphToText(BitmapFont font, int charIndex) { int stride = font.Stride; byte[] glyph = font.Glyphs[charIndex]; StringBuilder sb = new StringBuilder(); for (int row = 0; row < font.Height; row++) { for (int col = 0; col < font.Width; col++) { byte b = glyph[row * stride + col / 8]; int bit = 7 - col % 8; sb.Append(((b >> bit) & 1) == 1 ? '#' : '.'); } sb.AppendLine(); } return sb.ToString(); }这个方法只要几十行,但解决了很大的问题:你不需要打开丑陋的工具,就能直接在一份纯文本里核对字模。配合File.WriteAllText可以批量导出整个字库。
源码里还附带了一个我最小的NUnit测试项目,专门守住解析逻辑。核心测试用例是构造一个已知的字模文件,断言像素数量和特定字符的形状。
[Test] public void LoadFont_TwoGlyphs_ExpectCorrectLength() { byte[] raw = BuildFnt0( startChar: 'A', endChar: 'B', width: 8, height: 8, glyphBits: new byte[] { 0x10, 0x12, 0xFE, 0x12, 0x10, 0x00, 0x00, 0x00 } ); BitmapFont font = BitmapFontLoader.LoadFont(raw); Assert.AreEqual(2, font.Glyphs.Length); Assert.AreEqual(8 * 8, font.Glyphs[0].Sum(p => p)); }这类测试的价值在于:你换一个陌生格式时,先写一个最小的字节数组喂给解析器,确认无误再去解析真实文件,排错成本会大幅降低。从那以后,我每次拿到陌生字体文件,都强制自己先写一个十六进制 dump 的小函数,看一眼前64字节再决定解析方式。这个习惯帮我避开了至少三次格式翻车。希望帮到你。
本文还有配套的精品资源,点击获取