普通 svg 字形文件批量转换为 ttf/woff2等任意字体文件

由于要做一个几乎全部汉字的在线字典,好多生僻字需要进行字体的特殊处理。电脑端访问基本所有汉字都可正常显示,但是手机端,一般只支持基本汉字,扩展A,扩展B ~ 扩展H,几乎都不支持。显示都变成了“豆腐块”。 所以,我需要想办法做一批自定义的字体文件,用 webfont 来支持手机端生僻字的显示。

方案选择

字体的生成目前比较成熟的方案就是实用 fontforge 来进行 svg 到不同类型字体文件的转换。所以核心库,需要有fontforge 支持。考虑到 fontforge 对代码的支持,python 是比较成熟的。所以库和开发语言就确定为: python + fontforge。

另外为了保证环境的隔离性,目前有两种方案:

  • 方案一:conda (windows)新建运行环境,安装 fontforge 库,和 python。
  • 方案二:docker + linux(ubuntu/centos)+ fontforge + python。

方案一踩坑过程:经过一上午的验证,发现 conda 各种难用,以前搞 AI 大模型环境的时候,感觉还可以的。但是今天一用,各种卡,网上各种查,发现是由于依赖复杂,导致 conda 解析依赖太慢,同时,又发现 mamba 对依赖解析很快。所以又开始捣鼓将 annaconda 换成 mamba。然后 conda 安装mamba 也卡。。。怎么也装不上。无奈就卸载了conda,安装了 miniforge,这才用上了 mamba。

本以为,memba 能够很方便的安装上 fontforge,结果各种查找,fontforge、python-fontforge 都没有。pip 安装也没有这个库。最终结论就是,windows 环境下,没有合适的 fontforge 库能安装。搞了大半天没成功,干脆不搞了,换方案二。

方案二:验证很顺利。由于无法直接下载已有的 fontforge 镜像,只能自己装。我的docker 实在 windows 的wsl2 下安装的 ubuntu 系统中。在linux 下,应该可以拜托之前找不到库的问题。具体看下边环境搭建。

环境搭建

通过 docker 环境来实现,主要是为了保证环境的隔离。电脑上会运行各种类型的环境和软件,为了不互相冲突,且用过之后清理方便。所以选用了 docker 环境。

由于国外 docker hub 的封禁,目前国内好多镜像无法访问,所以想找个现有合适的镜像基本就不可能了。没干系,可以自己做一个镜像。

先建立一个目录:

bash 复制
mkdir fontforge
cd fontforge

然后在里面建立 Dockerfile 文件,构建镜像。

--- Dockerfile 核心安装流程如下 ---

  1. 首先更新基础包列表
  2. 安装 software-properties-common,它提供了 add-apt-repository 命令
  3. 使用 add-apt-repository universe 启用 universe 软件源
  4. 再次更新包列表,以包含 universe 源里的新包
  5. 安装所有需要的软件:
    • fontforge: FontForge 主程序
    • python3: Python 3 解释器
    • python3-fontforge: FontForge 的 Python 3 绑定(关键!)
    • vim: 一个文本编辑器,方便在容器内修改文件
  6. 清理 apt 缓存,减小镜像体积
Dockerfile 复制
FROM ubuntu:22.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
    apt-get install -y software-properties-common && \
    add-apt-repository universe && \
    apt-get update && \
    apt-get install -y fontforge python3 python3-fontforge vim && \
    rm -rf /var/lib/apt/lists/*
    
WORKDIR /data/svg2ttf

然后构建镜像:

bash 复制
# 通过当前目录的 Dockerfile 构建镜像:ubuntu-fontforge
docker build -t ubuntu-fontforge .
# 构建成功后,可以查看镜像
docker images

运行环境:

bash 复制
# 将本地目录 /mnt/d/php-project/svg2ttf 挂载到容器中 /data/svg2ttf 项目下,方便文件拷贝共享
docker run -d \
	--name fontforge-test-container \
	-v "/mnt/d/php-project/svg2ttf:/data/svg2ttf" \
        ubuntu-fontforge \
	sleep infinity
# 执行后可以拉起一个运行中的容器 fontforge-test-container,查看运行容器列表
docker ps
# 进入容器
docker exec -it fontforge-test-container bash

svg 转字体文件脚本

进入容器后,后边就是核心业务,将 svg 转为字体文件的过程了。可能涉及的脚本如下:

svg 字形文件源码示例:

Snipaste_2025-10-13_19-04-26.jpg

复制
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" baseProfile="full" viewBox="0 0 200 200" width="200" height="200">
<g fill="black">
<path d="M 84.5 66.0 L 84.5 70.0 L 51.0 70.0 L 51.0 183.5 L 41.0 188.5 L 41.0 70.0 L 11.8 70.0 L 11.8 66.0 L 62.5 66.0 L 72.5 56.0 Z M 121.1 126.3 L 120.03469390869141 135.0 L 146.8 135.0 L 146.8 100.0 L 158.8 102.0 L 161.8 104.0 L 158.8 105.8 L 158.8 135.0 L 165.5 135.0 L 175.5 125.0 L 187.5 135.0 L 187.5 139.0 L 158.8 139.0 L 158.8 181.0 L 146.8 187.0 L 146.8 139.0 L 119.26956520080566 139.0 L 117.9 145.3 L 115.3 153.6 L 111.8 161.2 L 107.7 168.0 L 102.8 173.9 L 97.3 178.9 L 91.1 182.9 L 84.3 185.9 L 76.9 187.8 L 76.3 186.1 L 82.6 182.5 L 88.1 178.4 L 92.9 174.0 L 97.0 169.0 L 100.5 163.4 L 103.4 157.3 L 105.7 150.4 L 107.5 142.9 L 108.01686744689941 139.0 L 80.4 139.0 L 80.4 135.0 L 108.54698791503907 135.0 L 108.6 134.6 L 109.2 125.6 L 109.2 100.0 L 121.2 102.0 L 124.2 104.0 L 121.2 105.8 L 121.2 126.0 L 121.0037735939026 126.29433965682983 Z M 30.7 88.0 L 33.4 90.0 L 30.7 91.72340421676635 L 30.7 120.0 L 30.2 128.2 L 29.5 135.9 L 28.5 143.0 L 27.2 149.5 L 25.6 155.4 L 23.7 160.7 L 21.4 165.3 L 18.7 169.2 L 15.7 172.3 L 12.4 174.6 L 11.3 173.3 L 13.0 170.2 L 14.5 166.7 L 15.9 162.8 L 17.1 158.4 L 18.0 153.5 L 18.8 148.0 L 19.4 141.9 L 19.7 135.2 L 19.8 127.9 L 19.7 120.0 L 19.7 86.0 Z M 74.1 88.0 L 76.8 90.0 L 74.1 91.72340421676635 L 74.1 155.2 L 63.1 160.7 L 63.1 86.0 Z M 83.0 93.7 L 128.0 93.7 L 128.0 63.4 L 94.3 63.4 L 94.3 59.4 L 128.0 59.4 L 128.0 29.6 L 88.9 29.6 L 88.9 25.6 L 157.0 25.6 L 167.0 15.6 L 179.0 25.6 L 179.0 29.6 L 140.0 29.6 L 140.0 59.4 L 151.6 59.4 L 161.6 49.4 L 173.6 59.4 L 173.6 63.4 L 140.0 63.4 L 140.0 93.7 L 162.9 93.7 L 172.9 83.7 L 184.9 93.7 L 184.9 97.7 L 83.0 97.7 Z M 75.3 30.0 L 75.3 34.0 L 20.2 34.0 L 20.2 30.0 L 53.3 30.0 L 63.3 20.0 Z " />
</g>
</svg>

批量将 svg 字体文件生成为 ttf/woff2 等字体文件

注意输入 svg 文件源码里需要只有一个 path 标签。要求输入文件的名称为 u{unicode}.svg 形式,名称字母全部小写。

可以指定输入目录,输出目录,输出字体文件类型,以及字体文件的名称。

此脚本可以将每个 svg 文件生成一个对应的字体文件(输入文件数=输出字体文件数)。

需要将所有字体文件打包成一个字体文件的,见后边脚本。

python 复制
import fontforge
import os
import re
import tempfile

# --------------------------
# Centralized Configuration (Modify these)
# --------------------------
CONFIG = {
    # Input/Output
    "INPUT_DIR": "./svg_g/wiki",  # Directory containing "u{unicode}.svg" files
    "OUTPUT_DIR": "./output/g/",  # Auto-generate if None (same as input dir)
    
    # --- New Configuration: Font Format ---
    # Possible values: 'ttf', 'woff', 'woff2', 'svg' (depending on FontForge support)
    "FONT_FORMAT": "woff2",

    # Font Metrics (adjust if glyph is cut off)
    "ASCENT_RATIO": 0.9,  # Space above baseline (90% of SVG width)
    "DESCENT_RATIO": 0.1, # Space below baseline (10% of SVG width)

    # Font Internal Names (will be embedded in all generated font files)
    "FONT_FAMILY": "HanDianJiFont",
    "FONT_FULLNAME": "HanDianJiFont",
    "FONT_FONTNAME": "HanDianJi-Regular",

    # Temporary SVG Template (for old FontForge compatibility)
    "TEMP_SVG_TPL": '''<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{width}" viewBox="0 0 {width} {width}">
  <path d="{path_d}" fill="black"/>
</svg>'''
}
# --------------------------


def extract_svg_data_from_file(svg_file_path):
    """Extract Unicode, path data, and width from a single SVG file."""
    try:
        filename = os.path.basename(svg_file_path)
        unicode_match = re.match(r'^u([0-9a-f]{1,6})\.svg$', filename.lower())
        if not unicode_match:
            return None, None, None, None, "Invalid filename format"

        unicode_code = int(unicode_match.group(1), 16)
        unicode_hex = hex(unicode_code)[2:].upper()

        with open(svg_file_path, 'r', encoding='utf-8') as f:
            svg_content = f.read()

        path_match = re.search(r'<path[^>]*?d="([^"]+)"', svg_content, re.IGNORECASE)
        if not path_match:
            return None, None, None, None, "No <path> tag or 'd' attribute"

        path_d = path_match.group(1)

        width_match = re.search(r'width\s*=\s*["\']?(\d+)["\']?', svg_content, re.IGNORECASE)
        svg_width = int(width_match.group(1)) if width_match else 200

        return unicode_code, unicode_hex, path_d, svg_width, None

    except Exception as e:
        return None, None, None, None, f"Parsing failed: {str(e)}"


def create_temp_svg(path_d, svg_width):
    """Create a minimal temporary SVG for old FontForge to import."""
    temp_svg_content = CONFIG["TEMP_SVG_TPL"].format(width=svg_width, path_d=path_d)
    temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False, encoding='utf-8')
    temp_file.write(temp_svg_content)
    temp_file_path = temp_file.name
    temp_file.close()
    return temp_file_path


def generate_font_for_svg(svg_file_path, output_dir, font_format):
    """Generate a single font file for one SVG based on the specified format."""
    unicode_code, unicode_hex, path_d, svg_width, err = extract_svg_data_from_file(svg_file_path)
    if err:
        print(f"Warning: Skipping invalid file '{os.path.basename(svg_file_path)}' -> {err}")
        return False

    # Prepare output font path with the correct extension
    font_filename = os.path.basename(svg_file_path).replace('.svg', f'.{font_format}')
    output_font_path = os.path.join(output_dir, font_filename)

    font = fontforge.font()
    font.em = svg_width
    font.ascent = int(svg_width * CONFIG["ASCENT_RATIO"])
    font.descent = int(svg_width * CONFIG["DESCENT_RATIO"])

    font.familyname = CONFIG["FONT_FAMILY"]
    font.fullname = CONFIG["FONT_FULLNAME"]
    font.fontname = CONFIG["FONT_FONTNAME"]

    glyph = font.createChar(unicode_code)
    glyph.width = svg_width

    temp_svg_path = create_temp_svg(path_d, svg_width)
    glyph.importOutlines(temp_svg_path)
    os.unlink(temp_svg_path)

    try:
        # FontForge uses the filename extension to determine the output format
        font.generate(output_font_path)
        print(f"Success: Generated {font_format.upper()} -> '{font_filename}' (U+{unicode_hex})")
        return True
    except Exception as e:
        print(f"Error: Failed to generate {font_format.upper()} for '{font_filename}' -> {str(e)}")
        return False
    finally:
        font.close()


def batch_convert():
    """Main function to batch convert all valid SVGs in a directory."""
    input_dir = CONFIG["INPUT_DIR"]
    output_dir = CONFIG["OUTPUT_DIR"] if CONFIG["OUTPUT_DIR"] else input_dir
    font_format = CONFIG["FONT_FORMAT"].lower() # Normalize to lowercase

    supported_formats = ['ttf', 'woff', 'woff2', 'svg']
    if font_format not in supported_formats:
        print(f"Error: Unsupported font format '{font_format}'. Supported formats are: {', '.join(supported_formats)}")
        return

    if not os.path.isdir(input_dir):
        print(f"Error: Input directory not found -> {input_dir}")
        return

    os.makedirs(output_dir, exist_ok=True)

    print(f"Info: Scanning for SVG files in '{input_dir}'...")
    print(f"Info: Target font format: {font_format.upper()}")
    total_files = 0
    success_count = 0

    for filename in os.listdir(input_dir):
        if filename.lower().endswith('.svg'):
            full_path = os.path.join(input_dir, filename)
            if generate_font_for_svg(full_path, output_dir, font_format):
                success_count += 1
            total_files += 1

    print("\nBatch conversion complete.")
    print(f"Summary: Processed {total_files} SVG file(s), successfully generated {success_count} {font_format.upper()} file(s).")


if __name__ == "__main__":
    batch_convert()

将多个 svg 字形文件打包成一个字体文件合集

svg 文件要求同上。

配置输入文件夹,和输出字体文件类型,扩展名决定最终的字体文件类型。

python 复制
import fontforge
import os
import re
import tempfile

# --------------------------
# Centralized Configuration (Modify these)
# --------------------------
CONFIG = {
    # Input/Output
    "INPUT_DIR": "./svg_g/wiki",  # Directory containing "u{unicode}.svg" files
    "OUTPUT_FILE": "./output/handianji-collection.woff2", # Path for the single output font file

    # Font Metrics (adjust if glyphs are cut off)
    # All glyphs will be scaled to fit this统一的尺寸
    "UNIFIED_EM_SIZE": 1024,
    "ASCENT_RATIO": 0.85,  # Space above baseline (85% of EM size)
    "DESCENT_RATIO": 0.15, # Space below baseline (15% of EM size)

    # Font Internal Names (will be embedded in the generated font file)
    "FONT_FAMILY": "HanDianJiCollection",
    "FONT_FULLNAME": "HanDianJi Collection",
    "FONT_FONTNAME": "HanDianJi-Collection-Regular",

    # Temporary SVG Template (for old FontForge compatibility)
    "TEMP_SVG_TPL": '''<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{width}" viewBox="0 0 {width} {width}">
  <path d="{path_d}" fill="black"/>
</svg>'''
}
# --------------------------


def extract_svg_data_from_file(svg_file_path):
    """Extract Unicode, path data, and width from a single SVG file."""
    try:
        filename = os.path.basename(svg_file_path)
        unicode_match = re.match(r'^u([0-9a-f]{1,6})\.svg$', filename.lower())
        if not unicode_match:
            return None, None, None, f"Invalid filename format: {filename}"

        unicode_code = int(unicode_match.group(1), 16)

        with open(svg_file_path, 'r', encoding='utf-8') as f:
            svg_content = f.read()

        path_match = re.search(r'<path[^>]*?d="([^"]+)"', svg_content, re.IGNORECASE)
        if not path_match:
            return None, None, None, f"No <path> tag or 'd' attribute: {filename}"

        path_d = path_match.group(1)

        width_match = re.search(r'width\s*=\s*["\']?(\d+)["\']?', svg_content, re.IGNORECASE)
        svg_width = int(width_match.group(1)) if width_match else 200

        return unicode_code, path_d, svg_width, None

    except Exception as e:
        return None, None, None, f"Parsing failed for {os.path.basename(svg_file_path)}: {str(e)}"


def create_temp_svg(path_d, svg_width):
    """Create a minimal temporary SVG for old FontForge to import."""
    temp_svg_content = CONFIG["TEMP_SVG_TPL"].format(width=svg_width, path_d=path_d)
    temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False, encoding='utf-8')
    temp_file.write(temp_svg_content)
    temp_file_path = temp_file.name
    temp_file.close()
    return temp_file_path


def merge_svgs_to_single_font():
    """Main function to merge all valid SVGs into a single font file."""
    input_dir = CONFIG["INPUT_DIR"]
    output_file = CONFIG["OUTPUT_FILE"]

    if not os.path.isdir(input_dir):
        print(f"Error: Input directory not found -> {input_dir}")
        return

    # Create output directory if it doesn't exist
    os.makedirs(os.path.dirname(output_file), exist_ok=True)

    print(f"Info: Scanning for SVG files in '{input_dir}'...")

    # Initialize a single FontForge font object
    font = fontforge.font()
    font.em = CONFIG["UNIFIED_EM_SIZE"]
    font.ascent = int(font.em * CONFIG["ASCENT_RATIO"])
    font.descent = int(font.em * CONFIG["DESCENT_RATIO"])

    font.familyname = CONFIG["FONT_FAMILY"]
    font.fullname = CONFIG["FONT_FULLNAME"]
    font.fontname = CONFIG["FONT_FONTNAME"]

    success_count = 0
    error_count = 0

    # Process each SVG file and add it as a glyph to the same font
    for filename in os.listdir(input_dir):
        if filename.lower().endswith('.svg'):
            full_path = os.path.join(input_dir, filename)
            unicode_code, path_d, svg_width, err = extract_svg_data_from_file(full_path)

            if err:
                print(f"Warning: {err}")
                error_count += 1
                continue

            try:
                # Create a new glyph in the font for this Unicode code point
                glyph = font.createChar(unicode_code)
                glyph.width = font.em # Set glyph width to the unified EM size

                # Create a temporary SVG with the path data
                temp_svg_path = create_temp_svg(path_d, svg_width)
                
                # Import the path. FontForge will handle scaling to the glyph's width.
                glyph.importOutlines(temp_svg_path)
                
                # Clean up the temporary file
                os.unlink(temp_svg_path)
                
                unicode_hex = hex(unicode_code)[2:].upper()
                print(f"Success: Added glyph -> '{filename}' (U+{unicode_hex})")
                success_count += 1

            except Exception as e:
                print(f"Error: Failed to add glyph from '{filename}' -> {str(e)}")
                error_count += 1
                # Clean up in case of failure
                if 'temp_svg_path' in locals() and os.path.exists(temp_svg_path):
                    os.unlink(temp_svg_path)

    if success_count == 0:
        print("\nError: No valid glyphs were added. Font file will not be generated.")
        font.close()
        return

    # Generate the final single font file
    try:
        # FontForge determines the format based on the output file's extension
        font.generate(output_file)
        print(f"\nSuccess: All glyphs merged into a single font file! -> {output_file}")
        print(f"Summary: Successfully added {success_count} glyph(s). {error_count} error(s) occurred.")
    except Exception as e:
        print(f"\nError: Failed to generate the final font file -> {str(e)}")
    finally:
        font.close()
        print("\nInfo: Font file closed.")


if __name__ == "__main__":
    merge_svgs_to_single_font()

使用转换成功的字体文件

通过 @font-face 来加载字体文件。示例如下,将以下 css 放到页面中可以测试下生成的字体文件是否成功。

css 复制
@font-face {
  font-family: 'hdjFont';
  src: url('/assets/fonts/ttf/u30a00.ttf') format('truetype');
  font-weight: normal;
  font-style: normal;
  unicode-range: U+30A00-30A00;
}
.test {
  font-family: 'hdjFont', sans-serif !important;
}

以上代码均能正常使用,相关自己文件也已经上线完成。