☰
Python+Pillow批量生成字体图:从单字渲染到完整流水线实践
2026/10/2 14:52:14 网站建设 项目流程

做这行久了你会发现,“批量生成字体图”这个需求远比听起来更常遇到。字体设计师要生成全套字形预览图,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, encoding='utf-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, font=font) 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, fill=TEXT_COLOR, font=font) 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, workers=8): os.makedirs(output_dir, exist_ok=True) 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_workers=workers) 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}.png:

out_name = f'{font_key}_{ord(char):04x}.png'

这样更简洁,效果是一样的。

3.5 样式进阶:词组、间距与多行渲染

很多时候不止要渲染单字,还需要渲染词组甚至整句。最常见的需求是:把一句话用不同字体各渲染一张图。

def render_phrase(font_path, text, output_path, img_width=800, img_height=300): 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, font=font) 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, fill=TEXT_COLOR, font=font) image.save(output_path)

这段跟单字模式几乎一样,区别只是把char换成了text。之所以特意拿出来说,是因为“词组渲染”有一个隐藏问题:中文字形之间的间距依赖字体本身的字距表,不同字体的渲染宽度不同。如果直接按比例缩放图片,会导致不同字体的同一句话视觉宽度差异很大。我的做法是:先渲染,再统一裁切到目标尺寸,而不是在渲染时强行控制文字宽度。切图可以在渲染之后用 Pillow 的Image.crop()或thumbnail()做标准化。

多行渲染则稍微复杂一点,需要手动计算行高:

def render_multiline(font_path, lines, output_path, img_width=800, line_height=80): 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, font=font) x = (img_width - (bbox[2] - bbox[0])) / 2 - bbox[0] draw.text((x, y), line, fill=TEXT_COLOR, font=font) y += line_height image.save(output_path)

这个函数里我直接传了一个lines列表,每行文字宽度可以不同,行高固定。在实际项目里,这个函数被我改造成了从 CSV 读取文案,方便运营同事直接改表格而不需要动代码。

4. 进阶玩法:字库预览总图与并发提速

4.1 生成整张字库预览表

单字图片生成后,往往还需要输出一张“汇总图”,把所有字符按网格排列在同一张图片里。这种图在字体对比时特别好用,可以一眼看出不同字体的统一性和重心。

def render_font_preview(font_path, chars, output_path, cols=20, cell_size=64): 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, font=font) 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, fill=TEXT_COLOR, font=font) # 画网格线,方便查看对齐情况 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, workers=8): total = len(tasks) finished = 0 with ThreadPoolExecutor(max_workers=workers) 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 分钟。如果增加workers=16,时间能缩短到 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 数据。也就是字体文件里没有这个字,不是代码问题。

我的排查方法是:

  1. 先用fontTools库读取字体文件的字符映射表,确认是否包含目标字符:

    from fontTools.ttLib import TTFont font = TTFont('your_font.ttf') cmap = font.getBestCmap() print(0x6211 in cmap) # 检查“我”字是否存在
  2. 如果字体缺少大量常用汉字,直接换字体源。网上有些“精简版”字体为了压缩体积,砍掉了非高频字符集,这种字体做全面预览图时必有豆腐块。

这个环节最花时间,但也是最有价值的排查——它本质上在做字库质量审计,能在项目早期就把不合格的字体文件筛出去。

5.3 输出文件太多,目录结构混乱

生成几万张图片时,全部塞在一个目录里会导致文件管理器卡死,后续按文件列表处理也很慢。建议按字体子目录 + 字符集子目录组织:

output/ 思源黑体/ Basic/ GB2312/ 站酷文艺体/ Basic/ GB2312/

这个调整对后续做数据集划分也有好处,分类规则越清晰,后续写Dataset加载逻辑时越省事。

5.4 字体版权风险

这一点必须单独提醒。批量生成字体图很容易让人忽略字体文件本身的版权。字体文件是受版权保护的软件资产,不同字体有不同的授权条款:有些允许免费商用,有些仅限个人学习,有些要求在生成物中标注字体名称。我在项目中有一个硬性要求:所有用于批量渲染的字体文件来源必须是授权范围内可使用的。尤其在做商用项目时,这个点务必提前确认清楚,否则后续麻烦会非常大。

6. 个人经验总结

最后分享几个我在实际项目中沉淀下来的体会。

第一,不要一上来就优化速度。很多人刚开始写批量脚本就去搞并发、搞 GPU 渲染,实际上对于几千张的规模,用单线程跑一遍只要几分钟,先把流程跑通、把输出结果校验好,再考虑提速也不迟。我自己第一次写这个脚本时就是单线程跑的,发现结果不对,改了几次逻辑,如果是并发早就乱了。

第二,文件名就是元数据。生成图片后往往还要做二次处理(打标、训练、审核),文件名里包含“字体名+字符码点”这类信息,能让你在小规模和大规模阶段都不用手动维护索引表。码点命名法在中英文混合场景下永远是安全的,这是很多程序员在 Windows 系统上吃过亏之后才明白的。

第三,渲染参数务必记录。我在项目目录里放了一个render_config.json,记录字号、图片尺寸、背景色、字体目录、字符集文件、Pillow 版本。一个月后你再根据这些图片做实验复现时,会发现这些记录比代码本身还重要。

批量生成字体图这件事,技术门槛不高,但把细节打磨好,能为你省下大量的重复劳动时间。这篇文章里的代码片段都是可以直接复制跑起来的,建议你先准备一个小型字体目录(三四套字体、几十个字符)完整跑一遍流程,再扩展到全量数据集。跑通全流程之后,你会发现这个脚本的扩展空间很大,加个随机背景色就能做数据增强,加个字号随机就能提升模型泛化能力。后续想往哪个方向扩展,就看你自己的实际需求了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询