1. 点阵字库转 BMP 到底在做什么
点阵字库转 BMP 绘图,说白了就是把 HZK16 这类字库文件里按区位码排布的二进制字模,逐位解析成 0/1 矩阵,再把这些位映射成 RGB 像素,最后按 BMP 的文件头规范拼出一张能直接用看图软件打开的图片。它能帮你把「字库文件里到底存了什么」这件事从抽象变成肉眼可见,适合做嵌入式字库裁剪、点阵屏取模验证、老式 GUI 字模调试的开发者。我第一次把「嵌入式」三个字渲染成 BMP 打开时,那种逐像素对上的感觉比看十六进制 dump 强太多。
整条链路其实只有四步:读字库拿到 32 字节字模、按位判断每个点是前景还是背景、把点写成 BGR 三字节、按 BMP 头结构落盘。坑基本都集中在两处——BMP 行对齐和字库偏移公式。前者写错图会斜,后者写错字会花。下面按可复制的顺序走一遍,配置部分用 TaoToken 统一 Key 管理,方便你把模型对话、接入文档、Coding Plan 这些入口收敛到一个地方,不用在多个平台之间来回切。
2. TaoToken 前置:统一 Key 与 config.toml 骨架
在动手写 BMP 之前,先把调用链路里的 Key 管理理顺。TaoToken 的定位是统一入口,官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api 。你如果只是本地跑字库转 BMP,其实不依赖网络;但一旦要把渲染结果丢给模型做像素级核对、或者让 Agent 帮你改字模解析代码,就需要一个稳定的 Key 配置。
我习惯把配置写进项目根目录的config.toml,骨架如下,字段名按你实际使用的客户端调整,核心是 base_url 和 api_key 两项:
# config.toml —— 统一入口配置骨架 [provider] name = "taotoken" base_url = "https://taotoken.net/api" api_key = "sk-你的统一Key" timeout_seconds = 60 [model] default = "claude-sonnet" max_tokens = 4096 [render] font_file = "HZK1616.dat" ascii_file = "ASC0814.dat" output_bmp = "out.bmp"Key 的获取入口在控制台的 API Keys 页面:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。拿到之后不要硬编码进源码,用环境变量注入更稳:
export TAOTOKEN_API_KEY="sk-你的统一Key"如果你后面要做长期的编码或 Agent 任务,比如让模型持续帮你迭代字模解析逻辑,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。接入细节和字段说明在文档里:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。想先验证模型能不能正确理解你的字模数据,直接开模型对话:https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
注意:Key 只用于你本地的模型调用链路,字库解析和 BMP 写入本身是纯本地文件操作,不经过任何网络。
3. 可复制配置:BMP 头结构与字库偏移
3.1 BMP 文件头为什么必须写满 52 字节
BMP 分两段头:14 字节的文件头(BMPFILESTRUCT)和 40 字节的信息头(BMPINFOSTRUCT),加起来 54 字节。很多教程说 52,是因为把bfType的 2 字节「BM」单独用fwrite("BM",1,2,fp)写了,结构体里就不再包含它,于是结构体本身只剩 52 字节。这两种写法都对,关键是别重复写也别漏写。
#include <stdio.h> #include <stdint.h> #include <string.h> #pragma pack(push, 1) // 关键:禁用结构体对齐,否则 sizeof 会变大 typedef struct { uint32_t bfSize; uint16_t bfReserved1; uint16_t bfReserved2; uint32_t bfOffBits; } BMPFILESTRUCT; typedef struct { uint32_t biSize; int32_t biWidth; int32_t biHeight; uint16_t biPlanes; uint16_t biBitCount; uint32_t biCompression; uint32_t biSizeImage; int32_t biXPelsPerMeter; int32_t biYPelsPerMeter; uint32_t biClrUsed; uint32_t biClrImportant; } BMPINFOSTRUCT; #pragma pack(pop)#pragma pack(1)是必须的。默认对齐下BMPFILESTRUCT会被填充到 16 字节而不是 14,写出来的头就错位了,图直接打不开。这是我踩过的坑之一,现象是文件大小对但看图软件报「不支持的格式」。
3.2 行对齐:宽度不是 4 的倍数时要补零
BMP 规定每一行像素数据的字节数必须是 4 的倍数。24 位色下每像素 3 字节,一行width*3字节,如果width*3 % 4 != 0,要在行尾补 0 到 4 的倍数。16 像素宽一行是 48 字节,正好整除,所以 16x16 汉字不用补;但如果你拼三个汉字成 48 像素宽,一行 144 字节,也整除。真正会出问题的是 8 像素宽的 ASCII 或非 4 倍数宽度。
int row_bytes = width * 3; int padding = (4 - (row_bytes % 4)) % 4; int stride = row_bytes + padding;写像素时每行写完补padding个 0 字节,biSizeImage用stride * height而不是width*height*3。
3.3 字库偏移公式
HZK16 按区位码排列,每个汉字 32 字节。偏移公式:
offset = (94 * (qh - 1) + (wh - 1)) * 32;其中qh = 区码 = 字节0 - 0xA0,wh = 位码 = 字节1 - 0xA0。GB2312 里「嵌」的编码是0xC7 0xB6,减 0xA0 得区 0x27、位 0x16,代入即可。ASCII 字库 ASC0814 是 8x14,每字符 14 字节,偏移就是ch * 14。
4. 验证请求:从字模到 RGB 像素的完整写入
4.1 完整可运行代码
下面这段把三个汉字渲染成 48x16 的 BMP,前景浅灰、背景渐变,方便肉眼核对每个点:
#include <stdio.h> #include <stdlib.h> #include <stdint.h> #pragma pack(push, 1) typedef struct { uint32_t bfSize; uint16_t r1, r2; uint32_t bfOffBits; } BMPFILESTRUCT; typedef struct { uint32_t biSize; int32_t biWidth, biHeight; uint16_t biPlanes, biBitCount; uint32_t biCompression, biSizeImage; int32_t biXPelsPerMeter, biYPelsPerMeter; uint32_t biClrUsed, biClrImportant; } BMPINFOSTRUCT; #pragma pack(pop) typedef struct { unsigned char b, g, r; } RGBDATA; static void write_bmp_header(FILE *fp, int w, int h) { int stride = (w * 3 + 3) & ~3; BMPFILESTRUCT fh = { 0 }; fh.bfSize = 14 + 40 + stride * h; fh.bfOffBits = 14 + 40; BMPINFOSTRUCT ih = { 0 }; ih.biSize = 40; ih.biWidth = w; ih.biHeight = h; // 正数表示自下而上存储 ih.biPlanes = 1; ih.biBitCount = 24; ih.biCompression = 0; ih.biSizeImage = stride * h; fwrite("BM", 1, 2, fp); fwrite(&fh, 1, sizeof(fh), fp); fwrite(&ih, 1, sizeof(ih), fp); } static void render_hanzi(FILE *fp, const unsigned char *gb, int w_total) { unsigned char qh = gb[0] - 0xA0; unsigned char wh = gb[1] - 0xA0; unsigned long offset = (94UL * (qh - 1) + (wh - 1)) * 32; unsigned char mat[32]; FILE *hzk = fopen("HZK1616.dat", "rb"); if (!hzk) { perror("HZK1616.dat"); exit(1); } fseek(hzk, offset, SEEK_SET); fread(mat, 32, 1, hzk); fclose(hzk); RGBDATA fg = { 200, 200, 200 }; // BGR for (int i = 0; i < 16; i++) { RGBDATA bg = { 15 * i, 10 * i, 255 - i }; for (int j = 0; j < 2; j++) { for (int k = 0; k < 8; k++) { int on = mat[15 - i][j] & (0x80 >> k); fwrite(on ? &fg : &bg, 1, 3, fp); } } } } int main(void) { const int W = 48, H = 16; FILE *fp = fopen("out.bmp", "wb"); if (!fp) { perror("out.bmp"); return 1; } write_bmp_header(fp, W, H); const unsigned char s1[3] = "嵌", s2[3] = "入", s3[3] = "式"; render_hanzi(fp, s1, W); render_hanzi(fp, s2, W); render_hanzi(fp, s3, W); fclose(fp); printf("done: out.bmp %dx%d\n", W, H); return 0; }编译运行:
gcc -O2 -o hzk2bmp hzk2bmp.c ./hzk2bmp4.2 成功结果长什么样
运行后得到out.bmp,用看图软件打开应该看到「嵌入式」三个字,背景从下到上由深蓝渐变到浅色,字是浅灰。用十六进制工具看文件头,前两字节是42 4D(BM),偏移 0x0A 处的bfOffBits应该是 54(0x36)。文件大小 = 54 + 48163 = 2358 字节,因为 48*3=144 已整除 4,无 padding。
逐像素核对的方法:用 Python 读回来验证某一行:
from PIL import Image img = Image.open("out.bmp") print(img.size) # (48, 16) print(img.getpixel((0, 0))) # 左下角第一个像素如果getpixel拿到的颜色和你代码里写的背景色对得上,说明行序和 BGR 顺序都正确。BMP 是自下而上存储,biHeight为正时第一行数据对应图像最下面一行,所以代码里用mat[15-i]把字模顶行放到图像顶行。
5. 本篇常见错排查
5.1 图打不开或提示格式错误
九成是结构体对齐问题。检查sizeof(BMPFILESTRUCT)是不是 14、sizeof(BMPINFOSTRUCT)是不是 40。如果打印出来是 16 和 40 但文件头错位,就是没加#pragma pack(1)。另一个原因是bfType写重了——结构体里含bfType又额外fwrite("BM"),导致头多 2 字节。
5.2 字是花的或整体偏移
偏移公式写错。常见错误是把qh-1写成qh,或者用(qh-0xA0)重复减。正确是(94*(qh-1)+(wh-1))*32,其中 qh、wh 已经减过 0xA0。另外确认字库文件是 HZK16 而不是 HZK12,两者每字字节数不同。
5.3 图像上下颠倒
BMP 默认自下而上。如果你按字模第 0 行直接写第一行数据,图会上下翻。两种改法:写像素时用mat[15-i]翻转,或者把biHeight设为负数表示自上而下。后者兼容性略差,建议用前者。
5.4 宽度非 4 倍数时右侧出现斜纹
行 padding 没补。每行写完width*3字节后,补(4 - (width*3)%4)%4个 0。biSizeImage也要用补零后的 stride 乘高度,否则文件大小对不上,看图软件可能截断。
5.5 颜色通道反了
BMP 24 位是 BGR 顺序,不是 RGB。结构体里字段顺序写成b, g, r,写文件时也按这个顺序。如果你按 r、g、b 写,红色会变蓝。用取色器核对一个已知像素最快。
6. 把链路收进统一入口
字库解析和 BMP 写入是纯本地活,但调试过程中你大概率会想让模型帮你解释某段字模数据、或者让 Agent 批量改渲染参数。这时候把 Key 收敛到 TaoToken 一个地方就省事:接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API Keys 在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,长期编码任务走 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,想先验证模型对字模的理解就开模型对话 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。API 基址统一用 https://taotoken.net/api 。
最后留一个实用技巧:把out.bmp和字库文件放同一目录,写个 shell 循环批量渲染整个 GB2312 一级字库,每 100 个字拼一张大图,肉眼扫一遍就能发现哪些区位码解析异常。这比逐个打印十六进制快得多,也是我验证偏移公式最有效的一招。