在使用 Hexo 生成博客页面时,有时会需要自定义页面上的字体,然而有些字体并非所有用户设备上都已经安装,系统默认的字体可能达不到需求的要求,直接修改 font-family 不能保证一致的浏览体验。
想要在所有设备都能以指定字体显示,可以通过引入字体文件结合 CSS 的 fontface 为用户提供自定义的字体文件。在 fontface 中指定字体文件资源的路径,浏览器就会下载对应的字体文件。

字体文件压缩

通常一个字体文件的大小较大,例如 MiSans

1
2
3
Mode                 LastWriteTime         Length Name
---- ------------- ------ ----
------ 2023/8/22 17:18 5409536 MiSans-Regular.woff

单是一个字体文件就超过 5MB,对于带宽较小的服务器来说,加载耗时会很长。页面加载出来过一段时间才能使用到自定义的字体,这显然体验不好。
使用 CDN 加速是一种方案,然而并非所有的字体都有免费 CDN 的支持,因此这里引入一个第三方库 Fontmin 对完整的字体文件进行压缩,压缩后的文件通常只有几十KB,较大也只有一两百KB,不会造成过大网络压力。

Fontmin

Fontmin 是一个缩小字体文件的第三方库,相关链接:
官方文档 | Github
利用这一第三方库提供的字体文件处理功能,可以实现按需生成字体文件。只有 Hexo 生成的页面上存在的文字才提供对应的字体文件,能大幅度缩小字体文件的大小。

脚本处理

本文脚本基于 Fontmin 进行了二次封装,分析生成的 HTML 所包含的字体,生成压缩后的字体文件,注入引用到 HTML 中,实现自定义字体。
使用方法:

  1. 安装依赖
    • npm install -D cheerio fontmin@1
  2. 在项目根目录新建文件 /scripts/font.js,并写入以下内容。(没有scripts文件夹可以新建)
  3. 替换配置项的内容
    • fontPath 需要使用的字体源文件路径
    • outputDir 字体压缩后文件输出的路径
    • FONT_NAME 字体名称
    • DIST_PATH 字体在页面上引用的路径(通常是相对于生成的文件目录,默认为public下)
    • CSS_PATH 在页面上引用自定义字体的 CSS 文件路径
  4. Hexo 在生成文件时会自动运行 scripts 下的脚本文件,生成成功将在 输出文件夹/fonts/xxx.woff2 路径下生成压缩后的字体文件。

注意:脚本执行环境要求 Node.js 18 以上,没有相应环境的可以参考上篇的解决方案。

Fontmin 2 只支持 ES Module 的导入方法,只有 v1 才支持 CommonJS 的导入,建议安装 v1 使用该脚本。

脚本内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const Fontmin = require("fontmin");
const cheerio = require("cheerio");
const path = require("path");
const { promisify } = require("util");
const { finished } = require("stream");
const log = hexo.log || log.log;

// 配置项
const fontPath = path.join(
__dirname,
"../source",
"/path/to/font/xxx.ttf"
); // 原始 TTF 字体路径
const outputDir = path.join(__dirname, "../public", "path/to/font/"); // 输出 WOFF2 的目录
const FONT_NAME = "xxx"; // 字体名称
const DIST_PATH = `/path/to/font/${FONT_NAME}`; // 输出 WOFF2 的路径
const CSS_PATH = `/assets/styles/font.css`; // 输出 CSS 的路径

// 向 HTML 注入 @font-face
const styleData = `@font-face {
font-family: '${FONT_NAME}';
src: url('${DIST_PATH}.woff2') format('woff2'),
url('${DIST_PATH}.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
body {
font-family: '${FONT_NAME}', system-ui, "Microsoft Yahei", "Segoe UI", -apple-system,
Roboto, Ubuntu, "Helvetica Neue", Arial, "WenQuanYi Micro Hei", sans-serif;
}
.code *,
code *{
font-family: Consolas, '${FONT_NAME}', Menlo, Monaco, Consolas, system-ui,
"Courier New", monospace, sans-serif;
}`;

hexo.extend.filter.register("after_generate", async () => {
// 获取所有生成的 HTML 文件
const htmlFiles = hexo.route.list().filter((path) => path.endsWith(".html"));

let text = "";

const processFile = async (filePath) => {
let html = "";
const stream = hexo.route.get(filePath);
stream.on("data", (chunk) => {
html += chunk;
});
stream.on("end", () => {
const $ = cheerio.load(html);
$("head").append(
`<link rel="stylesheet" href="${CSS_PATH}" type="text/css">`
);
hexo.route.set(filePath, $.html());
text += html;
});
return promisify(finished)(stream);
};

log.log("Generating font...");
let start = Date.now();

try {
await Promise.all(htmlFiles.map(processFile));
const textLib = [...new Set(text)].join("");
const fontmin = new Fontmin()
.src(fontPath)
.use(
Fontmin.glyph({
text: textLib,
hinting: false,
})
)
.use(Fontmin.ttf2woff2())
.dest(outputDir);
await promisify(fontmin.run.bind(fontmin))();
hexo.route.set(CSS_PATH, styleData); // 输出 CSS 文件
let end = Date.now();
log.log(
`Font [${FONT_NAME}] generated successfully in ${(
(end - start) /
1000
).toFixed(1)} s`
);
} catch (error) {
log.error(`Font ${FONT_NAME} generation failed: ${error}`);
console.error(error);
}
});