字体文件ttf转woff2脚本,包含检测字体文件类型,转换切分脚本

这里分享几个python脚本,主要是用来处理字体文件的。有检测字体文件类型脚本,将字体文件中的字体导出为svg文件的脚本,扫描目录中的字体文件的起始 unicode 编码脚本,将 ttf 文件转为 woff2 文件(支持指定切分 unicode 范围),将子集文件转换为支持 unicode-range 的 css 文件脚本。

环境准备

我这里用的python 版本为:3.12.4。如果版本高于我的版本,可以尝试是否能正常运行,如果不行,建议还是将版本设置为与我一致。

bash 复制
$ python --version
Python 3.12.4

首先处理字体,需要安装字体库工具:fonttools,我这里的版本为 4.51.0

bash 复制
$ pip list | grep font
fonttools                         4.51.0

其他用不到

python 脚本

检测字体文件类型脚本

python 复制
# check_header.py
file_path = "../resource/ttf/LXGWWenKaiMono-Regular.ttf"

with open(file_path, 'rb') as f:
    header = f.read(4)
    print(f"文件头: {header}")

if header == b'ttcf':
    print("⚠️  这是一个 .ttc 文件(字体集合),不是标准 .ttf")
elif header == b'\x00\x01\x00\x00':
    print("✅ 这是一个标准的 TrueType 字体")
elif header == b'OTTO':
    print("✅ 这是一个 OpenType (CFF) 字体")
else:
    print(f"❌ 未知格式: {header}")

此脚本用来检测你的字体文件的实际类型,防止有些文件类型跟实际类型不一致的情况。

将字体文件中的字体导出为 svg 格式

这个脚本一般用不到,某些特殊情况需要用到字体 svg 可以使用!

python 复制
# export_all_svg_final.py
# 功能:将 TTF 字体所有字形导出为居中、正向、大字体的 SVG 文件

from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.boundsPen import BoundsPen
import os

# ========== 路径配置 ==========
current_dir = os.path.dirname(__file__)
# 这里是输入字体文件路径文件名
ttf_path = os.path.join(current_dir, "..", "resource", "ttf", "LXGWWenKaiMono-Regular.ttf")
# 这里是输出的目录,输出svg名称,例:4E00.svg
output_dir = os.path.join(current_dir, "..", "svg_output")

print(f"🔍 字体文件: {ttf_path}")
print(f"🔍 输出目录: {output_dir}")

# 检查字体文件
if not os.path.exists(ttf_path):
    print(f"❌ 错误:字体文件不存在!\n请检查路径:{os.path.abspath(ttf_path)}")
    exit(1)

# 创建输出目录
os.makedirs(output_dir, exist_ok=True)

# ========== 字体加载 ==========
print("✅ 正在加载字体...")
try:
    font = TTFont(ttf_path)
    print("✅ 字体加载成功")
except Exception as e:
    print(f"❌ 加载字体失败: {e}")
    exit(1)

# ========== 参数设置 ==========
VIEWBOX = 1024
PADDING = 40
CENTER = VIEWBOX / 2  # 512

# 获取字符映射表
cmap = font.getBestCmap()
if not cmap:
    print("❌ 错误:无法获取字符映射表 (cmap)")
    exit(1)

items = [(code, glyph_name) for code, glyph_name in cmap.items()]
total = len(items)
print(f"📊 共找到 {total} 个字符,开始导出...")

# ========== 导出主循环 ==========
success_count = 0
failed_count = 0

for i, (code, glyph_name) in enumerate(items):
    hex_code = f"{code:04X}"
    char = chr(code)
    svg_file = os.path.join(output_dir, f"{hex_code}.svg")

    # 跳过已存在的文件
    if os.path.exists(svg_file):
        continue

    try:
        glyph = font['glyf'][glyph_name]

        # 计算包围盒(Bounding Box)
        bounds_pen = BoundsPen(font.getGlyphSet())
        glyph.draw(bounds_pen, glyfTable=font['glyf'])

        if bounds_pen.bounds is None:
            # 空字形(如空格)
            with open(svg_file, 'w', encoding='utf-8') as f:
                f.write(f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {VIEWBOX} {VIEWBOX}" width="200" height="200">
  <path d="" fill="black"/>
</svg>''')
            success_count += 1
            continue

        xMin, yMin, xMax, yMax = bounds_pen.bounds
        width = xMax - xMin
        height = yMax - yMin

        # 计算缩放因子:适配 viewBox 减去边距
        scale_factor = (VIEWBOX - 2 * PADDING) / max(width, height)

        # 当前字形中心(TTF 坐标系,Y 向上为正)
        center_x = (xMin + xMax) / 2
        center_y = (yMin + yMax) / 2

        # 🔥 核心:正确的 transform 顺序(从右到左执行)
        transform = (
            f"translate({CENTER}, {CENTER}) "           # 3. 移到 SVG 视口中心 (512,512)
            f"scale({scale_factor}) "                   # 2. 缩放至合适大小
            f"scale(1, -1) "                            # 1. Y 轴翻转(TTF → SVG 坐标系)
            f"translate({-center_x}, {-center_y})"      # 0. 将字形中心移至原点
        )

        # 生成 SVG 路径数据
        path_pen = SVGPathPen(font.getGlyphSet())
        glyph.draw(path_pen, glyfTable=font['glyf'])
        path_data = path_pen.getCommands()

        # 生成 SVG 内容
        svg_content = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {VIEWBOX} {VIEWBOX}" width="200" height="200">
  <path d="{path_data}" transform="{transform}" fill="black"/>
</svg>'''

        # 写入文件
        with open(svg_file, 'w', encoding='utf-8') as f:
            f.write(svg_content)

        success_count += 1

        # 进度提示
        if success_count % 500 == 0:
            print(f"📌 [{success_count}/{total}] 已生成")

    except Exception as e:
        failed_count += 1
        # 可选:取消注释以查看具体错误
        # print(f"❌ 失败 U+{hex_code} '{char}': {e}")

    # 每 1000 个打印一次总进度
    if (i + 1) % 1000 == 0 or i == total - 1:
        print(f"🔄 进度: {i+1}/{total} | 成功: {success_count} | 失败: {failed_count}")

# ========== 完成提示 ==========
print(f"\n🎉 所有字符导出完成!")
print(f"✅ 成功: {success_count}")
print(f"❌ 失败: {failed_count}")
print(f"📁 输出目录: {os.path.abspath(output_dir)}")
print("✨ 所有 SVG 已居中、正向、无截断!")

这个脚本输入文件名,输出目录需要再脚本头部,配置部分修改,具体看注释。然后直接执行:

bash 复制
python export_svg.py

扫描目录中的字体文件的起始 unicode 编码

这个脚本,主要是用来,将按照 unicode 顺序切分后的字体文件,起止的 unicode 编码打印出来,方便 css 文件通过 unicode-range 方式引入字体。当然,可以直接在切分的时候,就记录下来更好。但是我都弄完了,懒得改了。支持字体文件类型:'.woff2', '.ttf', '.otf', '.woff'。

python 复制
#!/usr/bin/env python3
# scan_range.py
# 检测字体文件中的 Unicode 范围

import os
import sys
from fontTools.ttLib import TTFont, TTLibError

def get_unicode_range(font_path):
    """获取字体文件的 Unicode 码点范围"""
    try:
        font = TTFont(font_path, fontNumber=0, allowVID=0, ignoreDecompileErrors=True, verbose=0)
        
        chars = set()
        
        # 遍历所有 cmap 子表,收集字符
        for table in font['cmap'].tables:
            if table.cmap:
                chars.update(table.cmap.keys())
        
        font.close()
        
        if not chars:
            return None, "字体中没有字符映射"
        
        min_code = min(chars)
        max_code = max(chars)
        count = len(chars)
        
        return (min_code, max_code, count), None  # (min, max, count), error
    
    except TTLibError as e:
        return None, f"字体解析错误: {e}"
    except Exception as e:
        return None, f"未知错误: {e}"

def main(folder_path):
    if not os.path.exists(folder_path):
        print(f"❌ 错误:目录不存在: {folder_path}")
        return
    
    # 支持的字体扩展名
    font_exts = {'.woff2', '.ttf', '.otf', '.woff'}
    
    print(f"🔍 扫描目录: {folder_path}")
    print(f"{'文件名':<30} {'起始 (U+)':<10} {'结束 (U+)':<10} {'字符数':<8}")
    print("-" * 60)
    
    found = False
    for filename in sorted(os.listdir(folder_path)):
        ext = os.path.splitext(filename)[1].lower()
        if ext not in font_exts:
            continue
            
        filepath = os.path.join(folder_path, filename)
        if not os.path.isfile(filepath):
            continue
            
        found = True
        range_data, error = get_unicode_range(filepath)
        
        if range_data:
            min_code, max_code, count = range_data
            print(f"{filename:<30} {min_code:04X}       {max_code:04X}       {count:>6}")
        else:
            print(f"{filename:<30} {'-':<10} {'-':<10} {'-':<8} ❌ {error}")
    
    if not found:
        print("⚠️  在指定目录中未找到任何支持的字体文件。")
    else:
        print("-" * 60)
        print("✅ 扫描完成。")

if __name__ == "__main__":
    # 默认目录:当前目录下的 subsets
    default_folder = "../subsets"
    
    # 可以通过命令行参数指定目录
    folder = sys.argv[1] if len(sys.argv) > 1 else default_folder
    
    main(folder)

使用方式:

bash 复制
python scan_range.py [字体文件目录]

将 ttf 文件转为 woff2 文件,支持指定切分 unicode 范围

此脚本用来,将原始的 ttf 文件,进行 woff2 文件转换,同时支持指定范围的切分,和切分块的大小。具体可以看使用命令。

python 复制
# split_cjk.py
from fontTools.ttLib import TTFont
import os
import sys
import argparse


def get_all_chars_from_font(font_path):
    """从字体中提取所有支持的 Unicode 字符"""
    font = TTFont(font_path)
    chars = set()
    for table in font['cmap'].tables:
        if table.isUnicode():
            chars.update(table.cmap.keys())
    font.close()
    return sorted(chars)


def save_subset(font_path, chars, output_path, subset_index):
    """使用 fontTools.subset API 生成子集(兼容 fonttools 4.51.0)"""
    from fontTools.subset import Subsetter, load_font, save_font, Options

    print(f"📦 生成子集 {subset_index + 1}: {output_path} ({len(chars)} 个字符)")

    try:
        options = Options()
        options.flavor = 'woff2'  # 输出格式为 WOFF2,可改为 'ttf' 或 None 保留原格式
        options.ignore_missing_glyphs = True
        options.ignore_missing_unicodes = True
        options.notdef_outline = True
        options.name_IDs = '*'  # 保留所有名字记录
        options.name_legacy = True
        options.name_languages = '*'
        options.layout_features = '*'  # 保留所有 OpenType 特性
        options.hinting = False  # 不保留 hinting
        options.desubroutinize = True  # 去子程序化

        font = load_font(font_path, options)
        subsetter = Subsetter(options=options)
        subsetter.populate(unicodes=chars)
        subsetter.subset(font)
        save_font(font, output_path, options)
        print(f"✅ 成功生成: {output_path}")
    except Exception as e:
        print(f"❌ 生成失败 {output_path}: {e}")
    finally:
        if 'font' in locals():
            font.close()


def filter_chars_in_range(chars, start_unicode, end_unicode):
    """从字符列表中筛选出在指定 Unicode 范围内的字符"""
    return [c for c in chars if start_unicode <= c <= end_unicode]


def split_font_in_range(font_path, start_hex, end_hex, subset_size=1000, output_prefix="subset"):
    """主函数:在指定 Unicode 范围内切分子集"""
    start_unicode = int(start_hex, 16)
    end_unicode = int(end_hex, 16)

    print(f"🔍 正在读取字体中的所有字符...")
    all_chars = get_all_chars_from_font(font_path)
    print(f"🎉 字体共包含 {len(all_chars)} 个字符")

    # 筛选指定范围内的字符
    filtered_chars = filter_chars_in_range(all_chars, start_unicode, end_unicode)
    print(f"📋 在 Unicode 范围 U+{start_hex}..U+{end_hex} 内找到 {len(filtered_chars)} 个字符")

    if len(filtered_chars) == 0:
        print("❌ 错误:在指定范围内未找到任何字符")
        return

    # 创建输出目录
    output_dir = os.path.dirname(output_prefix)
    if output_dir and not os.path.exists(output_dir):
        os.makedirs(output_dir)

    # 分块生成子集
    for i in range(0, len(filtered_chars), subset_size):
        subset_chars = filtered_chars[i:i + subset_size]
        output_file = f"{output_prefix}_{i//subset_size + 1}.woff2"
        save_subset(font_path, subset_chars, output_file, i // subset_size)


def main():
    parser = argparse.ArgumentParser(
        description="从字体中提取指定 Unicode 范围内的字符并切分为多个子集"
    )
    parser.add_argument("font_path", help="字体文件路径(TTF/WOFF/OTF 等)")
    parser.add_argument("--start", default=None, help="起始 Unicode(如 4E00)")
    parser.add_argument("--end", default=None, help="结束 Unicode(如 9FFF)")
    parser.add_argument("--size", type=int, default=1000, help="每个子集包含的字符数(默认 1000)")
    parser.add_argument("--prefix", default="subset", help="输出文件前缀(含路径,如 subsets/cjk_subset)")

    args = parser.parse_args()

    if not os.path.exists(args.font_path):
        print(f"❌ 错误:字体文件不存在: {args.font_path}")
        sys.exit(1)

    if args.start is not None and args.end is not None:
        # 使用指定范围
        split_font_in_range(
            args.font_path,
            args.start.upper(),
            args.end.upper(),
            subset_size=args.size,
            output_prefix=args.prefix
        )
    else:
        # 向后兼容:如果没有指定 start/end,则使用原逻辑(全部字符)
        print("⚠️ 未指定 --start 和 --end,正在处理字体中所有字符...")
        all_chars = get_all_chars_from_font(args.font_path)
        output_dir = os.path.dirname(args.prefix)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir)

        for i in range(0, len(all_chars), args.size):
            subset_chars = all_chars[i:i + args.size]
            output_file = f"{args.prefix}_{i//args.size + 1}.woff2"
            save_subset(args.font_path, subset_chars, output_file, i // args.size)


if __name__ == "__main__":
    main()

start 和 end 包含进当前子集,是闭区间。不指定的话,直接根据字体文件所有字体进行切分。size 是每500个字体分为一个子集 woff2 文件。--prefix 用来指定输出的自己文件的目录。

bash 复制
python split_cjk2.py your_font.ttf \
  --start 4E00 \
  --end 9FFF \
  --size 500 \
  --prefix "../subsets/cjk_subset"

将子集文件转换为支持 unicode-range 的 css 文件

php 复制
<?php
$data = [['cjk_base_1.woff2', '4E00', '51E7', 1000],
    ['cjk_base_10.woff2', '7128', '750F', 1000],
    ['cjk_base_11.woff2', '7510', '78F7', 1000],
    ['cjk_base_12.woff2', '78F8', '7CDF', 1000],
    ['cjk_base_13.woff2', '7CE0', '80C7', 1000],
    ['cjk_base_14.woff2', '80C8', '84AF', 1000],
    ['cjk_base_15.woff2', '84B0', '8897', 1000],
    ['cjk_base_16.woff2', '8898', '8C7F', 1000],
    ['cjk_base_17.woff2', '8C80', '9067', 1000],
    ['cjk_base_18.woff2', '9068', '944F', 1000],
    ['cjk_base_19.woff2', '9450', '9837', 1000],
    ['cjk_base_2.woff2', '51E8', '55CF', 1000],
    ['cjk_base_20.woff2', '9838', '9C1F', 1000],
    ['cjk_base_21.woff2', '9C20', '9FCC', 941],
    ['cjk_base_3.woff2', '55D0', '59B7', 1000],
    ['cjk_base_4.woff2', '59B8', '5D9F', 1000],
    ['cjk_base_5.woff2', '5DA0', '6187', 1000],
    ['cjk_base_6.woff2', '6188', '656F', 1000],
    ['cjk_base_7.woff2', '6570', '6957', 1000],
    ['cjk_base_8.woff2', '6958', '6D3F', 1000],
    ['cjk_base_9.woff2', '6D40', '7127', 1000],
    ['cjk_a_1.woff2', '3400', '37E7', 1000],
    ['cjk_a_2.woff2', '37E8', '3BCF', 1000],
    ['cjk_a_3.woff2', '3BD0', '3FB7', 1000],
    ['cjk_a_4.woff2', '3FB8', '439F', 1000],
    ['cjk_a_5.woff2', '43A0', '4787', 1000],
    ['cjk_a_6.woff2', '4788', '4B6F', 1000],
    ['cjk_a_7.woff2', '4B70', '4DB5', 582],
    ['cjk_b_1.woff2', '20000', '203E7', 1000],
    ['cjk_b_10.woff2', '22328', '2270F', 1000],
    ['cjk_b_11.woff2', '22710', '22AF7', 1000],
    ['cjk_b_12.woff2', '22AF8', '22EDF', 1000],
    ['cjk_b_13.woff2', '22EE0', '232C7', 1000],
    ['cjk_b_14.woff2', '232C8', '236AF', 1000],
    ['cjk_b_15.woff2', '236B0', '23A97', 1000],
    ['cjk_b_16.woff2', '23A98', '23E7F', 1000],
    ['cjk_b_17.woff2', '23E80', '24267', 1000],
    ['cjk_b_18.woff2', '24268', '2464F', 1000],
    ['cjk_b_19.woff2', '24650', '24A37', 1000],
    ['cjk_b_2.woff2', '203E8', '207CF', 1000],
    ['cjk_b_20.woff2', '24A38', '24E1F', 1000],
    ['cjk_b_21.woff2', '24E20', '25207', 1000],
    ['cjk_b_22.woff2', '25208', '255EF', 1000],
    ['cjk_b_23.woff2', '255F0', '259D7', 1000],
    ['cjk_b_24.woff2', '259D8', '25DBF', 1000],
    ['cjk_b_25.woff2', '25DC0', '261A7', 1000],
    ['cjk_b_26.woff2', '261A8', '2658F', 1000],
    ['cjk_b_27.woff2', '26590', '26977', 1000],
    ['cjk_b_28.woff2', '26978', '26D5F', 1000],
    ['cjk_b_29.woff2', '26D60', '27147', 1000],
    ['cjk_b_3.woff2', '207D0', '20BB7', 1000],
    ['cjk_b_30.woff2', '27148', '2752F', 1000],
    ['cjk_b_31.woff2', '27530', '27917', 1000],
    ['cjk_b_32.woff2', '27918', '27CFF', 1000],
    ['cjk_b_33.woff2', '27D00', '280E7', 1000],
    ['cjk_b_34.woff2', '280E8', '284CF', 1000],
    ['cjk_b_35.woff2', '284D0', '288B7', 1000],
    ['cjk_b_36.woff2', '288B8', '28C9F', 1000],
    ['cjk_b_37.woff2', '28CA0', '29087', 1000],
    ['cjk_b_38.woff2', '29088', '2946F', 1000],
    ['cjk_b_39.woff2', '29470', '29857', 1000],
    ['cjk_b_4.woff2', '20BB8', '20F9F', 1000],
    ['cjk_b_40.woff2', '29858', '29C3F', 1000],
    ['cjk_b_41.woff2', '29C40', '2A027', 1000],
    ['cjk_b_42.woff2', '2A028', '2A40F', 1000],
    ['cjk_b_43.woff2', '2A410', '2A6D6', 711],
    ['cjk_b_5.woff2', '20FA0', '21387', 1000],
    ['cjk_b_6.woff2', '21388', '2176F', 1000],
    ['cjk_b_7.woff2', '21770', '21B57', 1000],
    ['cjk_b_8.woff2', '21B58', '21F3F', 1000],
    ['cjk_b_9.woff2', '21F40', '22327', 1000],
    ['cjk_c_1.woff2', '2A700', '2AAE7', 1000],
    ['cjk_c_2.woff2', '2AAE8', '2AECF', 1000],
    ['cjk_c_3.woff2', '2AED0', '2B2B7', 1000],
    ['cjk_c_4.woff2', '2B2B8', '2B69F', 1000],
    ['cjk_c_5.woff2', '2B6A0', '2B734', 149],
    ['cjk_d_1.woff2', '2B740', '2B81D', 222],
    ['cjk_e_1.woff2', '2B820', '2BC07', 1000],
    ['cjk_e_2.woff2', '2BC08', '2BFEF', 1000],
    ['cjk_e_3.woff2', '2BFF0', '2C3D7', 1000],
    ['cjk_e_4.woff2', '2C3D8', '2C7BF', 1000],
    ['cjk_e_5.woff2', '2C7C0', '2CBA7', 1000],
    ['cjk_e_6.woff2', '2CBA8', '2CEA1', 762],
    ['cjk_f_1.woff2', '2CEB0', '2D297', 1000],
    ['cjk_f_2.woff2', '2D298', '2D67F', 1000],
    ['cjk_f_3.woff2', '2D680', '2DA67', 1000],
    ['cjk_f_4.woff2', '2DA68', '2DE4F', 1000],
    ['cjk_f_5.woff2', '2DE50', '2E237', 1000],
    ['cjk_f_6.woff2', '2E238', '2E61F', 1000],
    ['cjk_f_7.woff2', '2E620', '2EA07', 1000],
    ['cjk_f_8.woff2', '2EA08', '2EBE0', 473]];

$template = <<<TEM
@font-face {
  font-family: "{name}";
  src: url("{base}/{fontFile}") format("{fontType}");
  font-weight: 400;
  font-style: normal;
  unicode-range: U+{unicodeStart}-{unicodeEnd};
  font-display: swap;
}
TEM;

$base = '/assets/fonts/woff2';
$fontName = 'HanDianJi';

foreach ($data as $item) {
    $tmp = str_replace('{name}', $fontName, $template);
    $tmp = str_replace('{base}', $base, $tmp);
    $tmp = str_replace('{fontFile}', $item[0], $tmp);
    $tmp = str_replace('{fontType}', 'woff2', $tmp);
    $tmp = str_replace('{unicodeStart}', $item[1], $tmp);
    $tmp = str_replace('{unicodeEnd}', $item[2], $tmp);
    echo $tmp . PHP_EOL;
}

这个脚本是 php 的,其中,$data 是用scan_range.py 扫描出来的切分的子集的字体文件的unicode信息。最终结果直接打印出来的,可以保存到css文件即可。@font-face 模板可以根据需要调整一下。

注:本文中涉及脚本均为本人亲自测试调试过,均可正常使用!!!