博客更换中文字体:自托管与子集化
上一篇《字体原理笔记》讲了字体栈和子集化的原理,这一篇只解决怎么落地:把一套中文字体放到自己的服务器上,并把十几 MB 的源文件裁成网页能承受的大小。
我拿 Noto Serif SC 做示例,正文使用 400,标题和粗体使用 700。换成思源宋体、霞鹜文楷或其它字体时,步骤不变,需要调整的只是下载地址、文件名和字重。
为什么要做字体子集
中文字体通常包含两万多个字形。一个完整的 OTF/TTF 字重常有 8–12MB,如果正文和粗体各放一份,读者打开文章前就要先下载二十多 MB,显然不合适。
博客真正用到的汉字没有那么多。把文章、模板和界面文案全部扫描一遍,往往只有一两千个不同字符。用 fonttools 只保留这些字符,再输出为 woff2,一个字重通常能缩到几百 KB。
自托管还有一个实际好处:字体和网页由同一套发布流程管理,不依赖第三方字体服务。国内访问更稳定,缓存策略和字体版本也都掌握在自己手里。
确认字体授权
确认字体许可:
- 修改字体文件,因为删除字形、生成子集也属于修改
- 把修改后的文件放到网站上供访客下载,也就是再分发
SIL Open Font License(OFL)授权的字体通常允许这样做,Noto 和思源系列就是常见例子。
商业字体或厂商字体要逐条看授权说明,网页可用、免费商用,不一定等于允许修改后再分发。拿不准时不要把字体文件放上服务器。
发布时把字体许可文件一并放进 static/fonts/。这既是履行授权要求,也方便以后回头确认字体来源。
准备环境
下面的命令在 Debian、Ubuntu 或 WSL 中执行。先安装 fonttools 和 woff2 压缩所需的 brotli:
sudo apt-get update
sudo apt-get install -y python3-fonttools python3-brotli curl
假设网站目录大致如下:
kit/
├── src/ # Markdown 文章
├── templates/ # 页面模板
├── pages/ # 独立静态页面
├── static/
│ ├── style.css
│ └── fonts/ # 最终生成的字体放这里
└── tools/
进入项目根目录,并创建字体目录:
cd /path/to/kit
mkdir -p static/fonts
后面的相对路径都以这个目录为起点。如果自己的项目没有 templates 或 pages,字符提取脚本会自动跳过,不用专门建空目录。
下载字体
字体有多少个字重,浏览器就可能下载多少份文件。正文用 400、标题用 700 的网站,准备 Regular 和 Bold 两份就够了。CSS 即使给某个标题写了 600,浏览器也会从已有字重中选择最接近的 700。
mkdir -p /tmp/blog-font-source
# Regular 对应 CSS 的 400
curl --fail --location --retry 3 \
--output /tmp/blog-font-source/NotoSerifSC-Regular.otf \
"https://raw.githubusercontent.com/notofonts/noto-cjk/main/Serif/SubsetOTF/SC/NotoSerifSC-Regular.otf"
# Bold 对应 CSS 的 700
curl --fail --location --retry 3 \
--output /tmp/blog-font-source/NotoSerifSC-Bold.otf \
"https://raw.githubusercontent.com/notofonts/noto-cjk/main/Serif/SubsetOTF/SC/NotoSerifSC-Bold.otf"
换字体时不要只改输出文件名,下载地址也要指向那套字体真正的 OTF 或 TTF 文件。一个常见失误是下载到 GitHub 的 HTML 页面,文件名虽然以 .otf 结尾,fonttools 却无法识别。curl --fail 可以挡住一部分这类错误。
提取网站字符
字体子集只会保留 charset.txt 里的字符,所以不能只扫描 Markdown 正文。导航、搜索按钮、页脚和错误页面里的文字也会显示在浏览器中,应该一并纳入。
下面这段 Python 会扫描 src、templates、pages 和 static 中常见的文本文件,跳过现有字体目录,再补齐可打印 ASCII 和常用中文标点:
python3 - <<'PY'
from pathlib import Path
# 当前命令在网站项目根目录执行
project_root = Path.cwd()
output_file = project_root / "static/fonts/charset.txt"
# 这些目录中的内容最终会出现在网页上
scan_dirs = ["src", "templates", "pages", "static"]
# 只读取明确属于文本的文件,避免误读图片、字体和压缩包
text_suffixes = {
".md", ".html", ".htm", ".css", ".js", ".json",
".svg", ".txt", ".xml", ".yaml", ".yml",
}
characters = set()
for directory_name in scan_dirs:
directory = project_root / directory_name
if not directory.exists():
continue
for file_path in directory.rglob("*"):
if not file_path.is_file() or file_path.suffix.lower() not in text_suffixes:
continue
# 不读取旧字体的字符基线和许可文件,否则已删除的字符会一直残留
if output_file.parent in file_path.parents:
continue
try:
characters.update(file_path.read_text(encoding="utf-8"))
except UnicodeDecodeError:
print(f"跳过非 UTF-8 文件:{file_path}")
# 英文字母、数字、半角标点和空格
characters.update(chr(code) for code in range(32, 127))
# 给以后常用的中文标点留一点余量
characters.update(",。、;:?!「」『』()《》〈〉——……·“”‘’【】〔〕~¥×")
# 换行、制表符等控制字符不需要进入字体
characters = {char for char in characters if char.isprintable()}
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text("".join(sorted(characters)), encoding="utf-8")
print(f"已写入 {output_file},共 {len(characters)} 个不同字符")
PY
这里生成的是当前网站的字符快照。以后新增文章出现了新字,需要重新运行脚本并生成字体;不重切也不会让页面报错,缺少的单个字会沿 font-family 字体栈回退到系统字体,只是字形可能略有不同。
生成 woff2 子集
字符表准备好后,分别处理两个字重:
# 正文字体:Regular → 400
python3 -m fontTools.subset \
/tmp/blog-font-source/NotoSerifSC-Regular.otf \
--text-file=static/fonts/charset.txt \
--flavor=woff2 \
--layout-features='*' \
--output-file=static/fonts/NotoSerifSC-400.woff2
# 粗体字体:Bold → 700
python3 -m fontTools.subset \
/tmp/blog-font-source/NotoSerifSC-Bold.otf \
--text-file=static/fonts/charset.txt \
--flavor=woff2 \
--layout-features='*' \
--output-file=static/fonts/NotoSerifSC-700.woff2
几个参数分别做这些事:
| 参数 | 用途 |
|---|---|
--text-file |
指定需要保留的字符 |
--flavor=woff2 |
输出浏览器使用的 woff2 压缩格式 |
--layout-features='*' |
保留 kerning、标点排版等 OpenType 特性 |
--output-file |
指定生成文件的位置和名称 |
生成后先看一眼文件大小:
ls -lh static/fonts/*.woff2
具体体积会随字符数和字体结构变化,不必追求某个固定数字。重点是别把 8–12MB 的源字体误当成子集传上去。
用 CSS 引入字体
每个实际使用的字重都要写一条 @font-face。font-family 是自己给这套网页字体起的名字,后面引用时必须完全一致。
@font-face {
font-family: "Noto Serif SC Selfhost";
font-style: normal;
font-weight: 400;
font-display: optional;
src: url("fonts/NotoSerifSC-400.woff2") format("woff2");
}
@font-face {
font-family: "Noto Serif SC Selfhost";
font-style: normal;
font-weight: 700;
font-display: optional;
src: url("fonts/NotoSerifSC-700.woff2") format("woff2");
}
body {
/* 英文和数字先用 Georgia,汉字再落到自托管的 Noto Serif SC */
font-family: Georgia, "Iowan Old Style", "Noto Serif SC Selfhost",
"Songti SC", SimSun, serif;
}
CSS 的 url() 相对于 CSS 文件本身解析。上面的写法要求 style.css 和 fonts/ 同在 static/ 下;无论文章页面有几层目录,都不用跟着改路径。
字体栈的顺序也不是随便排的。浏览器会逐字从左到右找字体:Georgia 有英文字母和数字,却没有汉字,所以拉丁字符用 Georgia,汉字继续落到自托管字体。如果希望中英文都采用自托管字体自带的字形,把 "Noto Serif SC Selfhost" 放在 Georgia 前面即可。
这里选择 font-display: optional,是因为阅读页面更在意排版稳定:字体没在很短时间内加载完成,本次访问就使用系统回退字体,不会读到一半突然整页换字。若品牌字体必须在首次访问时出现,可以改为 swap,代价是字体加载完成时可能重新断行和跳动。
不建议默认在 src 前面加 local("Noto Serif SC")。它确实能让已经安装字体的访客免下载,但本机字体的版本不受网站控制,最后可能出现不同设备字形不一致。对个人博客来说,这是一项可以自行取舍的流量优化。
缓存与发布
如果字体文件长期使用同一个文件名,而服务器又设置了强缓存,更新子集后访客可能继续拿到旧文件。最省事的处理是在 URL 后加一个版本号:
src: url("fonts/NotoSerifSC-400.woff2?v=20260808") format("woff2");
每次重切字体时修改版本号即可。若构建系统会自动给静态资源加内容哈希,就不需要手工维护这个参数。
发布前按下面几项检查:
- 构建产物里存在两个 woff2 文件,许可文件也已经复制进去
- 浏览器开发者工具的 Network 面板过滤
font,请求状态是 200 - Elements 面板查看 Rendered Fonts,确认正文实际使用了自托管字体
- 分别检查普通正文和粗体标题,避免 700 文件没有加载而出现浏览器合成的伪粗体
- 随便找几个新文章里的生僻字,看看是否发生明显的单字回退
完整脚本
如果不想每次手工执行,把下面内容保存为 tools/subset-fonts.sh。它会检查依赖、下载源字体、重新提取字符、生成两个字重,并下载 Noto 的许可文件。
#!/usr/bin/env bash
set -Eeuo pipefail
# 脚本位于 tools/,因此上一级目录就是网站项目根目录
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FONT_DIR="${PROJECT_ROOT}/static/fonts"
CHARSET_FILE="${FONT_DIR}/charset.txt"
WORK_DIR="${TMPDIR:-/tmp}/blog-font-source"
# 每项格式:源文件标签|CSS 字重|下载地址
# 换字体时,主要修改这里以及下方的输出文件名前缀
FONT_SOURCES=(
"Regular|400|https://raw.githubusercontent.com/notofonts/noto-cjk/main/Serif/SubsetOTF/SC/NotoSerifSC-Regular.otf"
"Bold|700|https://raw.githubusercontent.com/notofonts/noto-cjk/main/Serif/SubsetOTF/SC/NotoSerifSC-Bold.otf"
)
for command_name in python3 curl; do
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "缺少命令:${command_name}"
echo "Debian/Ubuntu/WSL 可执行:sudo apt-get install python3-fonttools python3-brotli curl"
exit 1
fi
done
# 单独检查 Python 模块,报错比运行到一半再失败更容易看懂
if ! python3 -c 'import fontTools.subset, brotli' >/dev/null 2>&1; then
echo "缺少 fonttools 或 brotli,请执行:"
echo "sudo apt-get install python3-fonttools python3-brotli"
exit 1
fi
mkdir -p "${FONT_DIR}" "${WORK_DIR}"
echo "1/4 下载字体源文件"
for source_item in "${FONT_SOURCES[@]}"; do
IFS='|' read -r source_name css_weight source_url <<<"${source_item}"
source_file="${WORK_DIR}/NotoSerifSC-${source_name}.otf"
# 已下载的源文件直接复用;需要强制更新时删除 WORK_DIR 后再运行
if [[ ! -s "${source_file}" ]]; then
echo "下载 ${source_name}(CSS ${css_weight})"
curl --fail --location --retry 3 \
--output "${source_file}" \
"${source_url}"
else
echo "复用 ${source_file}"
fi
done
echo "2/4 提取网站字符"
python3 - "${PROJECT_ROOT}" "${CHARSET_FILE}" <<'PY'
import sys
from pathlib import Path
project_root = Path(sys.argv[1])
output_file = Path(sys.argv[2])
scan_dirs = ["src", "templates", "pages", "static"]
text_suffixes = {
".md", ".html", ".htm", ".css", ".js", ".json",
".svg", ".txt", ".xml", ".yaml", ".yml",
}
characters = set()
for directory_name in scan_dirs:
directory = project_root / directory_name
if not directory.exists():
continue
for file_path in directory.rglob("*"):
if not file_path.is_file() or file_path.suffix.lower() not in text_suffixes:
continue
if output_file.parent in file_path.parents:
continue
try:
characters.update(file_path.read_text(encoding="utf-8"))
except UnicodeDecodeError:
print(f"跳过非 UTF-8 文件:{file_path}")
characters.update(chr(code) for code in range(32, 127))
characters.update(",。、;:?!「」『』()《》〈〉——……·“”‘’【】〔〕~¥×")
characters = {char for char in characters if char.isprintable()}
output_file.write_text("".join(sorted(characters)), encoding="utf-8")
print(f"字符表:{output_file}")
print(f"字符数:{len(characters)}")
PY
echo "3/4 生成 woff2 子集"
for source_item in "${FONT_SOURCES[@]}"; do
IFS='|' read -r source_name css_weight source_url <<<"${source_item}"
source_file="${WORK_DIR}/NotoSerifSC-${source_name}.otf"
output_file="${FONT_DIR}/NotoSerifSC-${css_weight}.woff2"
temporary_file="${output_file}.tmp"
python3 -m fontTools.subset "${source_file}" \
--text-file="${CHARSET_FILE}" \
--flavor=woff2 \
--layout-features='*' \
--output-file="${temporary_file}"
# 生成成功后再替换旧文件,避免中途失败留下半个字体
mv "${temporary_file}" "${output_file}"
echo "已生成 ${output_file}"
done
echo "4/4 保存字体许可"
curl --fail --location --retry 3 \
--output "${FONT_DIR}/LICENSE.txt" \
"https://raw.githubusercontent.com/notofonts/noto-cjk/main/LICENSE"
echo "完成,生成文件如下:"
ls -lh "${FONT_DIR}"/*.woff2 "${CHARSET_FILE}" "${FONT_DIR}/LICENSE.txt"
第一次运行前给脚本执行权限:
chmod +x tools/subset-fonts.sh
tools/subset-fonts.sh
以后发新文章时,如果构建过程发现缺字,重新运行一次脚本,再构建和发布即可。换其它字体时,除了改 FONT_SOURCES,还要同步修改脚本中的源文件名、输出文件名前缀和 CSS 里的 font-family、src;这四处对齐,替换工作就完成了。
unicode-range 分包
另一条路线是把完整中文字库按 unicode-range 切成几百个小文件,浏览器根据当前页面的字符下载其中一部分。优点是以后遇到任何生僻字都不用重切,代价是文件数量多、单篇文章会产生多个字体请求,站点最终还要保存接近完整字库的内容。
对于内容由自己构建和发布的个人博客,字符快照更直接:平时只加载一两个字体文件,新文章缺字时重新运行一次脚本。只有内容来源不可控,或者无法在发布阶段重切字体时,分包方案才更值得考虑。
字体自托管真正麻烦的不是那两条 @font-face,而是授权、缺字和缓存这三个收尾环节。把字符提取和子集生成收进脚本后,日常维护就只剩一句命令,不必每次重新翻操作记录。