概述
TTF是字体文件格式,里面存储的是矢量化的字体信息。TTF与图片之间的相互转换简单描述如下:
- 使用python中的PIL(pillow)图像库可以实现TTF转图片
- 使用potrace可以将图片转为矢量文件svg,再进一步使用fontforge可以将svg转为TTF文件
在windows中,我们可以拿系统字体做一下演示。系统字体的路径为:C:\Windows\Fonts
,这里我们用方正舒体作为示例,字体文件名是FZSTK.TTF
字体的浏览和修改需要使用专业的字体软件,这里使用免费的fontforge,界面略显简单粗糙,但是后续图片转TTF时候还要用,加之免费,所以选用该软件。
fontforge下载地址:https://fontforge.org/en-US/
ps:fontforge默认情况打开字体后,无论字体有没有实现,都会占一个格子,所以可能会出现大量空格子,给我们浏览字体带来不便(托滑动条都难找),因此可做如下设置,以紧凑的方式显示字体:菜单栏依次点击Encoding -> Compact (hide unused glyphs)
Unicode简介
unicode官网:https://home.unicode.org/
字体会关联一套编码系统,比如提到汉字编码我们常见的是GBK,但在做字体相关工作时候,更常用的是Unicode编码系统,Unicode更加通用,它为很多语言做了唯一编码,甚至还有一些表情。Unicode使用4个16进制的数字来作为字符的编码(索引),如4E00
表示汉字一
,0061
表示小写字母a
。
常用汉字的编码范围是:4E00(一)
- 9FA5(龥)
。
截止这篇博客写作时间(2024.09),unicode最新版本是16.0.0,包含了154,998个字符(数量相当多)。可以在这里查询Unicode最新版本情况:https://www.unicode.org/versions/latest/
ps:4位16进制最大可表示65536,所以为什么Unicode能编码15万+字符?暂且不深究了,对于本文来讲不关键。
TTF转图片
这部分我们需要使用两个关键性依赖库,pillow(PIL)和fonttools,其中pillow是必需的,fonttools可选但建议也装上,因为下面代码会依赖。
- fonttools主要用来获取字体的一些信息,主要指Unicode信息。
- pillow用来转图片
安装依赖:
pip install pillow fonttools
有个跟字体相关度较高的简单概念最好能提前了解一下,即字体的 cmap - Character to Glyph Index Mapping Table,它里面存储的是Unicode信息(已转为十进制数字)和对应的字符名称,在python中使用fonttools读取后以字典形式存放。字符名称是个字符串,一般只有西文字符(英文、北欧、拉丁等)会有有含义的名称,其他字符常用字符串形式的16进制Unicode码来作为名称。字符名称了解一下即可,不关键。关键的是Unicode数字,如下图中前面的数字才是关键的,后面字符串不用太在意。
在处理字体相关信息时,代码中会使用很多的try…except语句,并且except不指定错误类型,因为总有字体编码时候不那么规范,导致python里面的两个依赖库处理不了,错误类型有点不确定,所以就全接了,以避免程序崩掉。
代码如下:
示例使用的是TTF文件,实际上OTF文件也能用下面代码。
# -*- coding: utf-8 -*-
import os
from fontTools.ttLib import TTFont
from PIL import Image
from PIL import ImageDraw
from PIL import ImageFontdef get_cmap(font_file):"""Get unicode cmap - Character to Glyph Index Mapping Tablefont_file: path of font file"""try:font = TTFont(font_file)except:return Nonetry:cmap = font.getBestCmap()except:return Nonefont.close()return cmapdef get_decimal_unicode(font_file):"""Get unicode (decimal mode - radix=10) of font."""cmap = get_cmap(font_file)if cmap is None:return Nonetry:decimal_unicode = list(cmap.keys())except:decimal_unicode = Nonereturn decimal_unicodedef decimal_to_hex(decimal_unicode, prefix='uni'):"""Convert decimal unicode (radix=10) to hex unicode (radix=16, str type)"""def _regularize(single_decimal_unicode, prefix):# result of hex() contains prefix '0x', such as '0x61',# while font file usually use 'uni0061',# so support changing prefix and filling to width 4 with 0h = hex(single_decimal_unicode)single_hex_unicode = prefix + h[2:].zfill(4)return single_hex_unicodeis_single_code = Falseif not isinstance(decimal_unicode, (list, tuple)):decimal_unicode = [decimal_unicode]is_single_code = Truehex_unicode = [_regularize(x, prefix) for x in decimal_unicode]if is_single_code:hex_unicode = hex_unicode[0]return hex_unicodedef decimal_to_char(decimal_unicode):"""Convert decimal unicode (radix=10) to characters"""is_single_code = Falseif not isinstance(decimal_unicode, (list, tuple)):decimal_unicode = [decimal_unicode]is_single_code = Truechar = [chr(x) for x in decimal_unicode]if is_single_code:char = char[0]return chardef get_bbox_offset(bbox, image_size):"""Get offset (x, y) for moving bbox to the center of imagebbox: bounding box of character, containing [xmin, ymin, xmax, ymax]"""if not isinstance(image_size, (list, tuple)):image_size = (image_size, image_size)center_x = image_size[0] // 2center_y = image_size[1] // 2xmin, ymin, xmax, ymax = bboxbbox_xmid = (xmin + xmax) // 2bbox_ymid = (ymin + ymax) // 2offset_x = center_x - bbox_xmidoffset_y = center_y - bbox_ymidreturn offset_x, offset_ydef char_to_image(char, font_pil, image_size, bg_color=255, fg_color=0):"""Generate an image containing single character in a font.char: such as '中' , 'a' ...font_pil: result of PIL.ImageFont"""try:bbox = font_pil.getbbox(char)except:return Noneif not isinstance(image_size, (list, tuple)):image_size = (image_size, image_size)offset_x, offset_y = get_bbox_offset(bbox, image_size)offset = (offset_x, offset_y)# convert ttf/otf to bitmap image using PILimage = Image.new('L', image_size, bg_color)draw = ImageDraw.Draw(image)draw.text(offset, char, font=font_pil, fill=fg_color)return imagedef font2image(font_file,font_size,image_size,out_folder=None,decimal_unicode=None,name_mode='char',image_extension='jpg',bg_color=255,fg_color=0,is_skip=True):"""Generate images from a font.font_size: size of font when reading by PIL, type=floatimage_size: image_size should normally be larger than font_sizedecimal_unicode: if not None, only generate images of decimal_unicodename_mode: if not 'char', then will be like 'uni0061'is_skip: whether skip existed images"""if out_folder is None:out_folder = os.path.splitext(font_file)[0]os.makedirs(out_folder, exist_ok=True)font_pil = ImageFont.truetype(font_file, font_size)if not isinstance(image_size, (list, tuple)):image_size = (image_size, image_size)if decimal_unicode is None:decimal_unicode = get_decimal_unicode(font_file)for code in decimal_unicode:char = chr(code)# get output filenameif name_mode == 'char':filename = charelse:filename = decimal_to_hex(code)filename = os.path.join(out_folder, f'{filename}.{image_extension}')# skip existed imagesif is_skip and os.path.exists(filename):continueimage = char_to_image(char, font_pil, image_size, bg_color, fg_color)if image is None:continuetry:image.save(filename)except:passif __name__ == '__main__':font_file = r'FZSTK.TTF'font2image(font_file, 112, 128)
生成的结果如图所示:
图片转TTF
需要安装fontforge和potrace,fontforge在概述中已经提到过了,这里再罗列一下。
- fontforge下载地址:https://fontforge.org/en-US/
- potrace下载地址:https://potrace.sourceforge.net/
fontforge需要安装,potrace实际上不需要安装,解压即可。
图片转TTF需要分两步:
- 图片转SVG
- SVG转TTF
图片转SVG
需用到potrace,解压potrace,把potrace.exe
的路径配置到下面python代码中。
然后改一改下面代码中的路径参数,执行即可。
生成的SVG可以用浏览器打开查看,如chrome。
代码如下:
# -*- coding: utf-8 -*-
import os
import subprocess
from PIL import ImageIMAGE_EXTENSIONS = ['jpg', 'png']
POTRACE_PATH = r'.\potrace-1.16.win64\potrace.exe'def clamp(x, xmin, xmax):return min(max(x, xmin), xmax)def get_files(path, extensions):files = []for name in os.listdir(path):fullname = os.path.join(path, name)if os.path.isfile(fullname):ext = os.path.splitext(name)[-1].lower()[1:]if (ext == '') or (ext in extensions):files.append(fullname)return filesdef get_folders(path):children_paths = os.listdir(path)folders = [os.path.join(path, x) for x in children_paths]folders = [x for x in folders if os.path.isdir(x)]return foldersdef remove_file(filename):if os.path.exists(filename):os.remove(filename)def image2pgm(image_file, dst_size, mode='L'):"""Convert ordinary image to resized pgm and return path of pgm_filemode: image color model, can be 'L' (grayscale) or 'RGB'"""if not isinstance(dst_size, (tuple, list)):dst_size = (dst_size, dst_size)image = Image.open(image_file).convert(mode)image = image.resize(dst_size)pgm_file = os.path.splitext(image_file)[0] + '.pgm'image.save(pgm_file)return pgm_filedef image2svg(image_root_folder, out_folder, dst_size, alphamax=1):"""Convert image to svg using `potrace`dst_size: resize image before calling potracealphamax: potrace parameter to control the roundness of curves.larger value lead to round curve, while smaller value lead to straight curve."""# pre-process parametersif not isinstance(dst_size, (tuple, list)):dst_size = (dst_size, dst_size)alphamax = clamp(alphamax, 0., 1.)folders = get_folders(image_root_folder)if len(folders) == 0:folders = [image_root_folder]for i, folder in enumerate(folders):print("processing %d / %d, %s" % (i + 1, len(folders), folder))out_subfolder = folder.replace(image_root_folder, out_folder)os.makedirs(out_subfolder, exist_ok=True)image_files = get_files(folder, IMAGE_EXTENSIONS)for image_file in image_files:# convert ordinary image to pgmpgm_file = image2pgm(image_file, dst_size)svg_file = os.path.splitext(image_file)[0] + '.svg'svg_file = svg_file.replace(image_root_folder, out_folder)# convert pgm to svg using potracesubprocess.run([POTRACE_PATH, pgm_file,'--alphamax', str(alphamax),'--svg','-o', svg_file]) # ignore_security_alertremove_file(pgm_file)if __name__ == '__main__':image_root_folder = r'.\FZSTK'out_folder = r'.\FZSTK_SVG'dst_size = 512alphamax = 1image2svg(image_root_folder, out_folder, dst_size, alphamax)
生成的svg示例如下,svg边缘受图片质量的影响,相比原始字符多少有些毛糙。
图片质量包括分辨率较小,128*128,另外由jpeg压缩带来的画质损失。
SVG转TTF
# -*- coding: utf-8 -*-
"""
This script can NOT run directly, use the following command in cmd:
`fontforge.exe -script xxx.py param1 param2 ...`
"""
import os
import sys
import fontforgedef get_files(path, extensions):files = []for name in os.listdir(path):fullname = os.path.join(path, name)if os.path.isfile(fullname):ext = os.path.splitext(name)[-1].lower()[1:]if (ext == '') or (ext in extensions):files.append(fullname)return filesdef svg2ttf(folder, out_file):if not os.path.isdir(folder):raise ValueError("%s is NOT a directory!" % folder)svg_files = get_files(folder, ['svg'])infos = []for svg_file in svg_files:char = os.path.splitext(svg_file)[0][-1]decimal_unicode = ord(char)hex_unicode = "uni" + hex(decimal_unicode)[2:].zfill(4)infos.append([char, decimal_unicode, hex_unicode, svg_file])# sort infos by decimal_unicode in ascend orderinfos.sort(key=lambda x: (x[1]))ff_font = fontforge.font()ff_font.fontname = "fontname"ff_font.fullname = "fullname"ff_font.familyname = "familyname"ff_font.encoding = "Unicode"for info in infos:char, dec_code, hex_code, svg_file = infoglyph = ff_font.createChar(dec_code)glyph.width = 1000glyph.importOutlines(svg_file)# Make the glyph lay on the baseline.ymin = glyph.boundingBox()[1]glyph.transform([1, 0, 0, 1, 0, -ymin])ff_font.generate(out_file)ff_font.close()if __name__ == '__main__':# the sys.argv[0] is python scripts itself,# sys.argv[1] is first param, sys.argv[2] is second param and so onfolder = sys.argv[1]if len(sys.argv) == 3:out_file = sys.argv[2]else:out_file = folder + '.ttf'svg2ttf(folder, out_file)
给上述脚本保存个文件名为:svg_to_ttf.py
,需要给该脚本设置两个参数,第一个参数是SVG的文件夹路径,第二个参数是待输出的TTF的路径。
上述脚本在windows中不能直接执行,因为常规的python环境下 import fontforge
会报错,找不到module。需要让fontforge.exe
在脚本模式下去调用python脚本,才能使import fontforge正常生效,所以需要在cmd中使用如下命令:
.\FontForgeBuilds\bin\fontforge.exe -script svg_to_ttf.py FZSTK_SVG FZSTK_new.ttf
生成出来的字体如下,跟上面截图相比最大的区别在于字体的位置,在svg转ttf脚本中,我们把字体位置做了向下对齐,而非上下居中,这一点可以自行修改。
另外细节也会有损失,字体矢量化过程中矢量信息会增多,所以最终生成的字体文件也变大了不少。