
做这行久了你会发现“批量生成字体图”这个需求远比听起来更常遇到。字体设计师要生成全套字形预览图OCR 项目要造训练样本广告文案要快速把一句话渲染成几百张字体对比图甚至电商运营要拿不同字体的字做商品图素材——本质上都是同一件事把字体文件批量变成图片。今天就把我在这上面踩过的坑和沉淀下来的完整流程整理出来算是一份能直接复用的实操记录。1. 核心需求与思路拆解为什么非要自己写一套1.1 这个需求到底在解决什么问题先厘清“批量生成字体图”这件事的真实场景。大多数找我请教这个问题的朋友需求基本逃不开下面几类字库预览图手里有一批 TTF/OTF 字体文件需要给每个字库生成一张包含常用汉字、英文、数字、标点的总览图用来快速对比不同字体的风格差异。这个在字体采购和品牌设计选型时特别常见。OCR 与机器学习数据集训练文字识别模型需要大量带标注的图像样本真实场景采集成本太高最可控的办法就是造一批白底黑字、字体多样、字号随机的合成图片。这一步直接决定模型能不能扛住真实场景。单字/词卡生成教育类 App 需要批量输出字卡图片每个字一张字体统一背景统一或者摄影博主需要批量把短句渲染成不同字体风格的水印图片。字体质量巡检字库文件可能存在缺字、乱码、字形异常等问题。通过批量生成字体图再配合比对工具可以快速找出问题字符。本质上所有场景都指向同一个技术动作用脚本控制字体文件把指定字符渲染成指定尺寸和样式的图片并且能横向铺开、批量执行。手工用 Photoshop 或者截图工具一张一张做少量可以量一上来就完全不可控。写脚本的意义不是“省事”而是“可复现”。1.2 为什么不直接依赖在线工具或现成软件市面上其实有不少在线字体预览工具输入文字就能看到渲染效果为什么还要写脚本我自己的实际体验是第一在线工具对隐私不友好。字体文件是你自己的资产文案内容有时候是未发布的新品信息传到第三方服务器始终有风险。第二在线工具的批量能力太弱。大多数在线工具只支持输入几行文字无法做到“遍历 100 个字体文件 × 3000 个常用汉字”这种量级的生成。我自己接过一个需求要对 80 套中文字库生成包含 GB2312 全量字符的预览图这种任务在线工具连想都不用想。第三可控性差。背景颜色、图片尺寸、渲染位置、文件名规则、输出目录结构这些参数在脚本里全部可调。而在线工具通常只给你几个固定模板。当你需要把这些图片作为数据集喂给训练框架时固定命名规则和目录结构是刚需。说白了脚本方案 可控 可批量 可复现 可集成。这四个词才是这个需求的真正核心。2. 技术选型Pillow 为何是主力2.1 渲染方案横评先交代一下我用过的几种渲染方案方案优点缺点适用场景Pillow (PIL)安装简单、API 友好、跨平台复杂排版能力有限绝大多数批量渲染场景FreeType (freetype-py)底层控制力强、字形轮廓精确API 较底层、使用门槛高需要精确到 glyph 级别的控制OpenCV (putText)图像处理集成方便不支持 TTF 子像素渲染中文支持依赖底层简单标注、非中文场景HTML无头浏览器 (playwright)CSS 排版能力强资源占用大、速度慢需要复杂排版效果的网页截图排除法之后90% 的情况下我都用 Pillow。原因很实际它就是为这个需求设计的ImageDraw.text()方法天然支持 TTF 字体加载和字符渲染而且 Pillow 的ImageFont.truetype()可以直接读取字体文件不需要额外安装字体管理器或系统级依赖。对生成“文字图片”这个任务来说它几乎没有多余的抽象层上手是最快的。2.2 完整流程设计批量生成字体图的完整链路可以拆成五个环节字体文件收集 → 字符集整理 → 渲染参数配置 → 批量绘制 → 输出校验梳理清楚这条链路后你会发现大部分问题都出在字符集整理和字体文件路径这两个环节真正写渲染核心代码反而很快。字符集决定“每个字都能画出来”字体路径决定“每个字体都能被加载”。这两个环节不稳定后面全部白搭。3. 实操搭建自己的批量字图流水线3.1 环境和基础依赖我这边环境是 Python 3.10 Pillow 10.x这套代码在 Python 3.8 以上应该都能跑。先安装依赖pip install Pillow不需要装别的了这是我一直推崇 Pillow 的原因。写批量任务时最多再装一个concurrent.futures标准库自带做并发加速不需要额外依赖。3.2 准备字体文件和字符集字体文件建议统一放在一个目录下命名尽量规范。我自己习惯按字体名-风格.扩展名命名例如思源黑体-Bold.ttf。这一步非常重要因为后续生成图片的文件名会依赖字体文件名命名规范能省掉后面所有排序和去重的麻烦。字符集准备有两层。如果你只是生成常用字直接用硬编码字符串即可common_chars 天地人和你我他ABCabc123。但如果要生成全量字库预览图建议从系统或者开源仓库拿一份中文字符集文件。我常用的是 GB2312 常用汉字表整理成chars.txt每行一个字符或者直接连续排列都可以。读进来的逻辑很简单def load_charset(path): with open(path, encodingutf-8) as f: content f.read() # 去重同时保留原始顺序 return list(dict.fromkeys(content.replace(\n, ).replace(\r, )))这里有个非常关键的经验一定要去重。很多从网上找的字符集文件里同一个字会重复出现多次不去重会白白增加渲染次数和文件数量。3.3 核心渲染脚本单字模式先写最简单的单字渲染函数。这个函数接收“字体路径 字符 输出路径”生成一张白底黑字的图片from PIL import Image, ImageDraw, ImageFont IMG_WIDTH 128 IMG_HEIGHT 128 FONT_SIZE 96 TEXT_COLOR (0, 0, 0) BG_COLOR (255, 255, 255) def render_single_char(font_path, char, output_path): font ImageFont.truetype(font_path, FONT_SIZE) image Image.new(RGB, (IMG_WIDTH, IMG_HEIGHT), BG_COLOR) draw ImageDraw.Draw(image) # 测量文本实际尺寸用于居中 bbox draw.textbbox((0, 0), char, fontfont) text_width bbox[2] - bbox[0] text_height bbox[3] - bbox[1] # 居中起点让字符落在线框中央 x (IMG_WIDTH - text_width) / 2 - bbox[0] y (IMG_HEIGHT - text_height) / 2 - bbox[1] draw.text((x, y), char, fillTEXT_COLOR, fontfont) image.save(output_path)这段代码里最值得注意的就是textbbox的用法。很多人刚开始做文字居中时直接draw.text((IMG_WIDTH/2, IMG_HEIGHT/2), char)结果发现所有字符都偏向右下角。原因是 Pillow 的text()起点坐标是文本的左上角边界而不同字符的左边界并不都在同一条线上。比如字母“g”的下半部分会超出基线汉字会有避头尾规则。用textbbox计算准确边界再做偏移才能做到真正的视觉居中。另外有一点要提前说明IMG_WIDTH、FONT_SIZE这些参数可以根据实际需求调整。我上面写的 128×128 和 96px 字号是给 OCR 模型做数据集的标准配置留出了上下左右一定的边距防止字形贴在图片边缘。如果是做字库预览图尺寸可以放大到 512字号相应增大观感会好很多。3.4 核心渲染脚本批量调度单字渲染函数写好之后批量调度就很简单了。我这里提供一版基础批量代码import os from concurrent.futures import ThreadPoolExecutor def batch_render(font_dir, charset_path, output_dir, workers8): os.makedirs(output_dir, exist_okTrue) fonts [f for f in os.listdir(font_dir) if f.lower().endswith((.ttf, .otf, .ttc))] chars load_charset(charset_path) tasks [] for font_name in fonts: font_path os.path.join(font_dir, font_name) # 去掉扩展名用于输出文件名 font_key os.path.splitext(font_name)[0] for char in chars: # 使用十六进制码点避免特殊字符导致文件名问题 code_hex char.encode(unicode_escape).decode().replace(\\\\u, ) out_name f{font_key}_{code_hex}.png out_path os.path.join(output_dir, out_name) tasks.append((font_path, char, out_path)) with ThreadPoolExecutor(max_workersworkers) as executor: futures [executor.submit(render_single_char, fp, ch, op) for fp, ch, op in tasks] for future in futures: future.result() # 确保所有任务执行完毕且异常可见这里有一个非常容易被忽视的设计文件名里不要直接使用字符本身。汉字做文件名没问题但遇到/、\、?、*这些特殊符号字符时在 Windows 系统上会直接报错。所以我统一用 Unicode 码点如“我”对应\u6211来命名这样既唯一又安全。你可以根据自己的需求改成font_key_{ord(char):04x}.pngout_name f{font_key}_{ord(char):04x}.png这样更简洁效果是一样的。3.5 样式进阶词组、间距与多行渲染很多时候不止要渲染单字还需要渲染词组甚至整句。最常见的需求是把一句话用不同字体各渲染一张图。def render_phrase(font_path, text, output_path, img_width800, img_height300): font ImageFont.truetype(font_path, 72) image Image.new(RGB, (img_width, img_height), BG_COLOR) draw ImageDraw.Draw(image) bbox draw.textbbox((0, 0), text, fontfont) text_width bbox[2] - bbox[0] text_height bbox[3] - bbox[1] x (img_width - text_width) / 2 - bbox[0] y (img_height - text_height) / 2 - bbox[1] draw.text((x, y), text, fillTEXT_COLOR, fontfont) image.save(output_path)这段跟单字模式几乎一样区别只是把char换成了text。之所以特意拿出来说是因为“词组渲染”有一个隐藏问题中文字形之间的间距依赖字体本身的字距表不同字体的渲染宽度不同。如果直接按比例缩放图片会导致不同字体的同一句话视觉宽度差异很大。我的做法是先渲染再统一裁切到目标尺寸而不是在渲染时强行控制文字宽度。切图可以在渲染之后用 Pillow 的Image.crop()或thumbnail()做标准化。多行渲染则稍微复杂一点需要手动计算行高def render_multiline(font_path, lines, output_path, img_width800, line_height80): font ImageFont.truetype(font_path, 64) img_height line_height * len(lines) 40 image Image.new(RGB, (img_width, img_height), BG_COLOR) draw ImageDraw.Draw(image) y 20 for line in lines: bbox draw.textbbox((0, 0), line, fontfont) x (img_width - (bbox[2] - bbox[0])) / 2 - bbox[0] draw.text((x, y), line, fillTEXT_COLOR, fontfont) y line_height image.save(output_path)这个函数里我直接传了一个lines列表每行文字宽度可以不同行高固定。在实际项目里这个函数被我改造成了从 CSV 读取文案方便运营同事直接改表格而不需要动代码。4. 进阶玩法字库预览总图与并发提速4.1 生成整张字库预览表单字图片生成后往往还需要输出一张“汇总图”把所有字符按网格排列在同一张图片里。这种图在字体对比时特别好用可以一眼看出不同字体的统一性和重心。def render_font_preview(font_path, chars, output_path, cols20, cell_size64): rows (len(chars) cols - 1) // cols img_width cols * cell_size 20 img_height rows * cell_size 20 image Image.new(RGB, (img_width, img_height), BG_COLOR) draw ImageDraw.Draw(image) font ImageFont.truetype(font_path, int(cell_size * 0.7)) for idx, char in enumerate(chars): row idx // cols col idx % cols x0 10 col * cell_size y0 10 row * cell_size bbox draw.textbbox((0, 0), char, fontfont) tw bbox[2] - bbox[0] th bbox[3] - bbox[1] x x0 (cell_size - tw) / 2 - bbox[0] y y0 (cell_size - th) / 2 - bbox[1] draw.text((x, y), char, fillTEXT_COLOR, fontfont) # 画网格线方便查看对齐情况 draw.rectangle([x0, y0, x0 cell_size, y0 cell_size], outline(200, 200, 200)) image.save(output_path)这里有两个细节字号取cell_size * 0.7给字符四周留出足够空间否则“国”“园”这类全包围结构汉字会挤到网格线。网格线用浅灰色(200, 200, 200)不干扰字形判断又能提供对齐参考。对字体对比来说这张汇总图的价值非常高。有次我在比选一款黑体和一款宋体做正文标题时就是靠这种网格图直接看出两款字体的字面率差异黑体字符明显撑得更满宋体相对收敛。这类宏观视觉特征是逐字看的时候容易忽略的。4.2 并发提速与进度展示批量任务最怕的就是跑了一半不知道进度、不知道哪里报错。我后来把调度函数升级成了带进度提示的版本from concurrent.futures import ThreadPoolExecutor, as_completed def batch_render_with_progress(tasks, output_dir, workers8): total len(tasks) finished 0 with ThreadPoolExecutor(max_workersworkers) as executor: future_map {executor.submit(render_single_char, fp, ch, op): (fp, ch, op) for fp, ch, op in tasks} for future in as_completed(future_map): fp, ch, op future_map[future] try: future.result() finished 1 if finished % 500 0: print(f进度: {finished}/{total} 完成) except Exception as e: # 记录失败任务但不中断整体流程 print(f失败: {op} - {e})这里用as_completed而不是直接executor.submit后马上result()好处是每完成一个任务都能及时感知并且单个任务失败不会影响整体。批量渲染这种任务容错比“一刀切”重要得多——宁可个别文件失败被记录也不要整个任务因为某个特殊字符直接退出。实测数据单台普通办公电脑8 线程渲染 3000 个汉字 × 10 套字体大约需要 20 到 30 分钟。如果增加workers16时间能缩短到 15 分钟左右。Pillow 的text()在单图渲染时是 CPU 密集操作Python 的多线程在 IO 密集场景有效在这里因为每个任务是独立的图像计算ThreadPoolExecutor同样能获得不错的加速效果。5. 常见问题与排查实录5.1 字体加载失败或渲染出来不是目标字体现象脚本跑完图片里的字形跟预期字体差很远。排查思路首先看字体路径是不是真的指向文件。我用os.path.exists先检查一遍。其次要看 Pillow 支持的字体格式TTF、OTF 都没问题但 TTC字体集合在部分旧版本 Pillow 上加载时会默认取第一个子字体。如果需要 TTC 里的特定字重要查一下 Pillow 文档里ImageFont.truetype对 TTC 索引参数的处理方式。还有一个我踩过的坑同一套字体在不同操作系统上渲染高度可能不同。这是因为字体的 ascent/descent 度量值在不同平台上的解释方式有差异。解决方案是不要手工指定文本垂直位置而是始终使用textbbox返回的边界值做居中偏移。这样至少保证在同一个环境下渲染结果是一致的。5.2 中文字符变成方框豆腐块现象生成的中文图片是一个个空心方框。原因几乎都是同一个字体文件本身不包含这些字符的 glyf 数据。也就是字体文件里没有这个字不是代码问题。我的排查方法是先用fontTools库读取字体文件的字符映射表确认是否包含目标字符from fontTools.ttLib import TTFont font TTFont(your_font.ttf) cmap font.getBestCmap() print(0x6211 in cmap) # 检查“我”字是否存在如果字体缺少大量常用汉字直接换字体源。网上有些“精简版”字体为了压缩体积砍掉了非高频字符集这种字体做全面预览图时必有豆腐块。这个环节最花时间但也是最有价值的排查——它本质上在做字库质量审计能在项目早期就把不合格的字体文件筛出去。5.3 输出文件太多目录结构混乱生成几万张图片时全部塞在一个目录里会导致文件管理器卡死后续按文件列表处理也很慢。建议按字体子目录 字符集子目录组织output/ 思源黑体/ Basic/ GB2312/ 站酷文艺体/ Basic/ GB2312/这个调整对后续做数据集划分也有好处分类规则越清晰后续写Dataset加载逻辑时越省事。5.4 字体版权风险这一点必须单独提醒。批量生成字体图很容易让人忽略字体文件本身的版权。字体文件是受版权保护的软件资产不同字体有不同的授权条款有些允许免费商用有些仅限个人学习有些要求在生成物中标注字体名称。我在项目中有一个硬性要求所有用于批量渲染的字体文件来源必须是授权范围内可使用的。尤其在做商用项目时这个点务必提前确认清楚否则后续麻烦会非常大。6. 个人经验总结最后分享几个我在实际项目中沉淀下来的体会。第一不要一上来就优化速度。很多人刚开始写批量脚本就去搞并发、搞 GPU 渲染实际上对于几千张的规模用单线程跑一遍只要几分钟先把流程跑通、把输出结果校验好再考虑提速也不迟。我自己第一次写这个脚本时就是单线程跑的发现结果不对改了几次逻辑如果是并发早就乱了。第二文件名就是元数据。生成图片后往往还要做二次处理打标、训练、审核文件名里包含“字体名字符码点”这类信息能让你在小规模和大规模阶段都不用手动维护索引表。码点命名法在中英文混合场景下永远是安全的这是很多程序员在 Windows 系统上吃过亏之后才明白的。第三渲染参数务必记录。我在项目目录里放了一个render_config.json记录字号、图片尺寸、背景色、字体目录、字符集文件、Pillow 版本。一个月后你再根据这些图片做实验复现时会发现这些记录比代码本身还重要。批量生成字体图这件事技术门槛不高但把细节打磨好能为你省下大量的重复劳动时间。这篇文章里的代码片段都是可以直接复制跑起来的建议你先准备一个小型字体目录三四套字体、几十个字符完整跑一遍流程再扩展到全量数据集。跑通全流程之后你会发现这个脚本的扩展空间很大加个随机背景色就能做数据增强加个字号随机就能提升模型泛化能力。后续想往哪个方向扩展就看你自己的实际需求了。