feat: 建立可复现字体包构建链

This commit is contained in:
SkyJourney
2026-07-31 21:52:36 +08:00
parent 9f96d51922
commit ae9fde3ac6
14 changed files with 1225 additions and 5 deletions
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@md-to-pdf/font-pack-builder",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/font-pack-registry": "0.1.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/node": "^24.10.1",
"vitest": "^4.1.10"
}
}
+525
View File
@@ -0,0 +1,525 @@
import { createHash, randomUUID } from "node:crypto";
import {
lstat,
mkdir,
readFile,
readdir,
realpath,
rename,
rm,
stat,
writeFile
} from "node:fs/promises";
import {
dirname,
isAbsolute,
join,
relative,
resolve,
sep
} from "node:path";
import { prepareDocxFonts, type PreparedDocxFont } from "@md-to-pdf/docx-engine";
import {
discoverFontPacks,
fontPackManifestSchema,
readInstalledFontPackAsset,
type FontPackManifest,
type InstalledFontPack
} from "@md-to-pdf/font-pack-registry";
import { fontPackRecipeSchema, type FontPackRecipe } from "./recipe.js";
const MAXIMUM_RECIPE_BYTES = 256 * 1024;
const MAXIMUM_NOTICE_BYTES = 256 * 1024;
export interface BuildFontPackOptions {
recipePath: string;
sourceRoot: string;
outputRoot: string;
reportPath?: string;
}
export interface VerifyFontPackOptions {
root: string;
appVersion: string;
packId: string;
packVersion: string;
}
export interface FontPackBuildReport {
recipeSha256?: string;
packId: string;
packVersion: string;
outputDirectory: string;
unchanged: boolean;
packFingerprint: string;
registryFingerprint: string;
totalBytes: number;
faces: Array<{
id: string;
targets: string[];
weight: number;
style: "normal" | "italic";
family: string;
subfamily: string;
postscriptName?: string;
permission: "installable" | "editable";
webSha256: string;
docxSha256: string;
}>;
}
function sha256(content: Uint8Array | string) {
return createHash("sha256").update(content).digest("hex");
}
function normalizeText(content: string) {
return content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
}
function isContainedPath(root: string, target: string) {
const path = relative(root, target);
return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
}
async function trustedRoot(root: string) {
const requested = resolve(root);
const status = await lstat(requested);
if (status.isSymbolicLink() || !status.isDirectory()) {
throw new Error(`目录不是受信任的普通目录:${requested}`);
}
return realpath(requested);
}
async function readTrustedFile(root: string, path: string, maximumBytes: number) {
const candidate = resolve(root, ...path.split("/"));
if (!isContainedPath(root, candidate)) {
throw new Error(`资源越过受信任目录:${path}`);
}
const linkStatus = await lstat(candidate);
if (linkStatus.isSymbolicLink() || !linkStatus.isFile()) {
throw new Error(`资源不是普通文件:${path}`);
}
const realCandidate = await realpath(candidate);
if (!isContainedPath(root, realCandidate)) {
throw new Error(`资源真实路径越过受信任目录:${path}`);
}
const fileStatus = await stat(realCandidate);
if (fileStatus.size > maximumBytes) {
throw new Error(`资源超过容量限制:${path}`);
}
return new Uint8Array(await readFile(realCandidate));
}
async function readRecipe(recipePath: string) {
const absolutePath = resolve(recipePath);
const status = await lstat(absolutePath);
if (
status.isSymbolicLink() ||
!status.isFile() ||
status.size > MAXIMUM_RECIPE_BYTES
) {
throw new Error("字体包配方不是受支持的普通文件");
}
const content = await readFile(absolutePath);
const recipe = fontPackRecipeSchema.parse(
JSON.parse(content.toString("utf8")) as unknown
);
return {
recipe,
recipeDirectory: await trustedRoot(dirname(absolutePath)),
recipeSha256: sha256(content)
};
}
async function readVerifiedSource(
root: string,
asset: { source: string; bytes: number; sha256: string }
) {
const content = await readTrustedFile(root, asset.source, asset.bytes);
if (content.byteLength !== asset.bytes) {
throw new Error(`字体源大小不匹配:${asset.source}`);
}
if (sha256(content) !== asset.sha256) {
throw new Error(`字体源 SHA-256 不匹配:${asset.source}`);
}
return content;
}
function assertWoff2(content: Uint8Array, path: string) {
if (
content.byteLength < 4 ||
String.fromCharCode(...content.subarray(0, 4)) !== "wOF2"
) {
throw new Error(`Web 字体不是 WOFF2${path}`);
}
}
function assertTrueType(content: Uint8Array, path: string) {
if (
content.byteLength < 4 ||
content[0] !== 0 ||
content[1] !== 1 ||
content[2] !== 0 ||
content[3] !== 0
) {
throw new Error(`DOCX 字体不是原生静态 TrueType${path}`);
}
}
function assertPreparedFont(
font: PreparedDocxFont,
recipe: FontPackRecipe,
face: FontPackRecipe["faces"][number],
kind: "web" | "docx"
) {
if (font.metadata.family !== recipe.internalFamily) {
throw new Error(
`${kind} 字体内部家族不匹配:${font.metadata.family} != ${recipe.internalFamily}`
);
}
if (font.metadata.weightClass !== face.weight) {
throw new Error(
`${kind} 字体内部字重不匹配:${font.metadata.weightClass} != ${face.weight}`
);
}
if (font.metadata.permission !== "installable" && font.metadata.permission !== "editable") {
throw new Error(`${kind} 字体不允许文档嵌入`);
}
if (kind === "docx" && font.metadata.signature !== "\u0000\u0001\u0000\u0000") {
throw new Error("DOCX 字体解码后不是 TrueType SFNT");
}
}
async function prepareRecipeAssets(recipe: FontPackRecipe, sourceRoot: string) {
const assets = await Promise.all(
recipe.faces.map(async (face) => {
const [web, docx] = await Promise.all([
readVerifiedSource(sourceRoot, face.web),
readVerifiedSource(sourceRoot, face.docx)
]);
assertWoff2(web, face.web.source);
assertTrueType(docx, face.docx.source);
return { face, web, docx };
})
);
const createSources = (kind: "web" | "docx") =>
assets.map(({ face, web, docx }) => ({
family: recipe.internalFamily,
aliases: [...face.targets],
source: face[kind].source,
weight: face.weight,
style: face.style,
license: recipe.license,
content: kind === "web" ? web : docx
}));
const [webFonts, docxFonts] = await Promise.all([
prepareDocxFonts(createSources("web")),
prepareDocxFonts(createSources("docx"))
]);
for (const [index, item] of assets.entries()) {
const webFont = webFonts[index];
const docxFont = docxFonts[index];
if (!webFont || !docxFont) {
throw new Error("字体元数据验证结果不完整");
}
assertPreparedFont(webFont, recipe, item.face, "web");
assertPreparedFont(docxFont, recipe, item.face, "docx");
if (docxFont.fingerprint !== item.face.docx.sha256) {
throw new Error(
`DOCX 字体需要规范化后才能使用,不是冻结的原生静态资产:${item.face.docx.source}`
);
}
if (
webFont.metadata.family !== docxFont.metadata.family ||
webFont.metadata.weightClass !== docxFont.metadata.weightClass ||
webFont.metadata.subfamily !== docxFont.metadata.subfamily
) {
throw new Error(`Web 与 DOCX 字体元数据不一致:${item.face.id}`);
}
}
return assets.map((item, index) => ({
...item,
metadata: docxFonts[index]!.metadata
}));
}
function createManifest(
recipe: FontPackRecipe,
licenseContent: Uint8Array
): FontPackManifest {
return fontPackManifestSchema.parse({
manifestVersion: recipe.manifestVersion,
id: recipe.id,
version: recipe.version,
name: recipe.name,
description: recipe.description,
license: recipe.license,
licenseFile: recipe.licensePath,
licenseSha256: sha256(licenseContent),
licenseBytes: licenseContent.byteLength,
compatibility: recipe.compatibility,
faces: recipe.faces.map((face) => ({
id: face.id,
targets: face.targets,
weight: face.weight,
style: face.style,
web: {
path: face.web.path,
format: "woff2",
bytes: face.web.bytes,
sha256: face.web.sha256
},
docx: {
path: face.docx.path,
format: "truetype",
bytes: face.docx.bytes,
sha256: face.docx.sha256
}
}))
});
}
async function writePackFile(root: string, path: string, content: Uint8Array | string) {
const target = resolve(root, ...path.split("/"));
if (!isContainedPath(root, target)) {
throw new Error(`输出路径越过字体包目录:${path}`);
}
await mkdir(dirname(target), { recursive: true });
await writeFile(target, content);
}
async function directoryFingerprint(root: string) {
const files: Array<{ path: string; sha256: string }> = [];
async function visit(directory: string) {
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
(left, right) => left.name.localeCompare(right.name, "en")
)) {
const target = join(directory, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`字体包输出包含符号链接:${target}`);
}
if (entry.isDirectory()) {
await visit(target);
} else if (entry.isFile()) {
files.push({
path: relative(root, target).split(sep).join("/"),
sha256: sha256(await readFile(target))
});
} else {
throw new Error(`字体包输出包含不支持的文件类型:${target}`);
}
}
}
await visit(root);
return sha256(JSON.stringify(files));
}
async function installStagedPack(stagePack: string, outputRoot: string, recipe: FontPackRecipe) {
const idDirectory = resolve(outputRoot, recipe.id);
const finalDirectory = resolve(idDirectory, recipe.version);
await mkdir(idDirectory, { recursive: true });
try {
const existing = await lstat(finalDirectory);
if (existing.isSymbolicLink() || !existing.isDirectory()) {
throw new Error(`同版本目标不是普通目录:${finalDirectory}`);
}
const [existingFingerprint, stagedFingerprint] = await Promise.all([
directoryFingerprint(finalDirectory),
directoryFingerprint(stagePack)
]);
if (existingFingerprint !== stagedFingerprint) {
throw new Error(
`拒绝覆盖内容不同的已发行字体包版本:${recipe.id}@${recipe.version}`
);
}
return { directory: finalDirectory, unchanged: true };
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
await rename(stagePack, finalDirectory);
return { directory: finalDirectory, unchanged: false };
}
throw error;
}
}
async function validateInstalledPack(
pack: InstalledFontPack,
expectedVersion: string
) {
if (pack.version !== expectedVersion) {
throw new Error(`字体包版本不匹配:${pack.version} != ${expectedVersion}`);
}
const contents = await Promise.all(
pack.faces.map(async (face) => {
const [web, docx] = await Promise.all([
readInstalledFontPackAsset(face.web),
readInstalledFontPackAsset(face.docx)
]);
assertWoff2(web, face.web.path);
assertTrueType(docx, face.docx.path);
return { face, web, docx };
})
);
const createSources = (kind: "web" | "docx") =>
contents.map(({ face, web, docx }) => ({
family: face.targets[0]!,
aliases: face.targets.slice(1),
source: face[kind].absolutePath,
weight: face.weight,
style: face.style,
license: pack.license,
content: kind === "web" ? web : docx
}));
const [webFonts, docxFonts] = await Promise.all([
prepareDocxFonts(createSources("web")),
prepareDocxFonts(createSources("docx"))
]);
for (const [index, font] of docxFonts.entries()) {
const face = pack.faces[index]!;
const webFont = webFonts[index];
if (
!webFont ||
font.metadata.signature !== "\u0000\u0001\u0000\u0000" ||
font.metadata.weightClass !== face.weight ||
font.fingerprint !== face.docx.sha256 ||
webFont.metadata.family !== font.metadata.family ||
webFont.metadata.subfamily !== font.metadata.subfamily ||
webFont.metadata.weightClass !== font.metadata.weightClass
) {
throw new Error(`已安装字体包元数据不匹配:${face.id}`);
}
}
return docxFonts;
}
export async function verifyFontPack(
options: VerifyFontPackOptions
): Promise<FontPackBuildReport> {
const result = await discoverFontPacks({
roots: [options.root],
appVersion: options.appVersion
});
const blocking = result.diagnostics.filter((item) => item.severity !== "info");
if (blocking.length > 0) {
throw new Error(
`字体包注册验证失败:${blocking.map((item) => `${item.code} ${item.message}`).join("")}`
);
}
const pack = result.packs.find((item) => item.id === options.packId);
if (!pack) {
throw new Error(`未发现字体包:${options.packId}`);
}
const fonts = await validateInstalledPack(pack, options.packVersion);
return {
packId: pack.id,
packVersion: pack.version,
outputDirectory: pack.directory,
unchanged: true,
packFingerprint: pack.fingerprint,
registryFingerprint: result.fingerprint,
totalBytes: pack.faces.reduce(
(total, face) => total + face.web.bytes + face.docx.bytes,
pack.licenseBytes
),
faces: pack.faces.map((face, index) => ({
id: face.id,
targets: [...face.targets],
weight: face.weight,
style: face.style,
family: fonts[index]!.metadata.family,
subfamily: fonts[index]!.metadata.subfamily,
...(fonts[index]!.metadata.postscriptName
? { postscriptName: fonts[index]!.metadata.postscriptName }
: {}),
permission: fonts[index]!.metadata.permission,
webSha256: face.web.sha256,
docxSha256: face.docx.sha256
}))
};
}
export async function buildFontPack(
options: BuildFontPackOptions
): Promise<FontPackBuildReport> {
const { recipe, recipeDirectory, recipeSha256 } = await readRecipe(
options.recipePath
);
const sourceRoot = await trustedRoot(options.sourceRoot);
const [licenseText, noticeText, assets] = await Promise.all([
readTrustedFile(recipeDirectory, recipe.licenseSource, recipe.licenseBytes),
readTrustedFile(recipeDirectory, recipe.noticeSource, MAXIMUM_NOTICE_BYTES),
prepareRecipeAssets(recipe, sourceRoot)
]);
const normalizedLicense = new TextEncoder().encode(
normalizeText(new TextDecoder().decode(licenseText))
);
if (
normalizedLicense.byteLength !== recipe.licenseBytes ||
sha256(normalizedLicense) !== recipe.licenseSha256
) {
throw new Error("字体许可证大小或 SHA-256 与配方不匹配");
}
const manifest = createManifest(recipe, normalizedLicense);
const outputRoot = resolve(options.outputRoot);
await mkdir(outputRoot, { recursive: true });
const stageRoot = resolve(
dirname(outputRoot),
`.font-pack-stage-${recipe.id}-${randomUUID()}`
);
const stagePack = resolve(stageRoot, recipe.id, recipe.version);
await mkdir(stagePack, { recursive: true });
try {
await Promise.all([
writePackFile(stagePack, recipe.licensePath, normalizedLicense),
writePackFile(
stagePack,
recipe.noticePath,
normalizeText(new TextDecoder().decode(noticeText))
),
...assets.flatMap((item) => [
writePackFile(stagePack, item.face.web.path, item.web),
writePackFile(stagePack, item.face.docx.path, item.docx)
])
]);
await writePackFile(
stagePack,
"font-pack.json",
`${JSON.stringify(manifest, null, 2)}\n`
);
const stagedRegistry = await discoverFontPacks({
roots: [stageRoot],
appVersion: recipe.compatibility.minimumAppVersion
});
const stagedBlocking = stagedRegistry.diagnostics.filter(
(item) => item.severity !== "info"
);
if (stagedBlocking.length > 0 || stagedRegistry.packs.length !== 1) {
throw new Error(
`暂存字体包注册验证失败:${stagedBlocking
.map((item) => `${item.code} ${item.message}`)
.join("")}`
);
}
const installed = await installStagedPack(stagePack, outputRoot, recipe);
const report = await verifyFontPack({
root: outputRoot,
appVersion: recipe.compatibility.minimumAppVersion,
packId: recipe.id,
packVersion: recipe.version
});
const finalReport: FontPackBuildReport = {
...report,
recipeSha256,
outputDirectory: installed.directory,
unchanged: installed.unchanged
};
if (options.reportPath) {
const reportPath = resolve(options.reportPath);
await mkdir(dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(finalReport, null, 2)}\n`, "utf8");
}
return finalReport;
} finally {
await rm(stageRoot, { recursive: true, force: true });
}
}
+76
View File
@@ -0,0 +1,76 @@
import { resolve } from "node:path";
import { buildFontPack, verifyFontPack } from "./builder.js";
function argumentsMap(values: string[]) {
const result = new Map<string, string>();
for (let index = 0; index < values.length; index += 2) {
const key = values[index];
const value = values[index + 1];
if (!key?.startsWith("--") || !value || value.startsWith("--")) {
throw new Error(`无效命令参数:${key ?? ""}`);
}
result.set(key.slice(2), value);
}
return result;
}
function option(
values: Map<string, string>,
name: string,
fallback?: string
) {
const value = values.get(name) ?? fallback;
if (!value) {
throw new Error(`缺少参数 --${name}`);
}
return value;
}
async function main() {
const command = process.argv[2];
const values = argumentsMap(process.argv.slice(3));
if (command === "build") {
return buildFontPack({
recipePath: resolve(
option(
values,
"recipe",
"font-packs/recipes/mdtp-serif-sc/recipe.json"
)
),
sourceRoot: resolve(
option(
values,
"source-root",
".local/font-pack-sources/mdtp-serif-sc/1.0.0"
)
),
outputRoot: resolve(
option(values, "output-root", "output/font-packs/root")
),
reportPath: resolve(
option(values, "report", "output/font-packs/build-report.json")
)
});
}
if (command === "verify") {
return verifyFontPack({
root: resolve(option(values, "root", "output/font-packs/root")),
appVersion: option(values, "app-version", "0.6.0"),
packId: option(values, "pack-id", "mdtp-serif-sc"),
packVersion: option(values, "pack-version", "1.0.0")
});
}
throw new Error("命令必须是 build 或 verify");
}
main()
.then((result) => {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
})
.catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`
);
process.exitCode = 1;
});
+2
View File
@@ -0,0 +1,2 @@
export * from "./builder.js";
export * from "./recipe.js";
+152
View File
@@ -0,0 +1,152 @@
import { z } from "zod";
import {
FONT_PACK_MANIFEST_VERSION,
MAXIMUM_FONT_PACK_DOCX_FONT_BYTES,
MAXIMUM_FONT_PACK_FACE_COUNT,
MAXIMUM_FONT_PACK_LICENSE_BYTES,
MAXIMUM_FONT_PACK_TOTAL_BYTES,
MAXIMUM_FONT_PACK_WEB_FONT_BYTES,
semanticVersionSchema
} from "@md-to-pdf/font-pack-registry";
export const FONT_PACK_RECIPE_VERSION = 1;
const identifierSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
const fontNameSchema = z
.string()
.trim()
.min(1)
.max(100)
.regex(/^[\p{L}\p{N}\s._-]+$/u);
const safeRelativePathSchema = z
.string()
.trim()
.min(1)
.max(300)
.refine(
(value) =>
!value.includes("\\") &&
!value.includes("\0") &&
!value.startsWith("/") &&
!value.split("/").some((segment) => segment === ".." || segment === ""),
"路径必须是安全的 POSIX 相对路径"
);
const sourceAssetSchema = z
.object({
source: safeRelativePathSchema,
path: safeRelativePathSchema,
bytes: z.number().int().positive(),
sha256: sha256Schema
})
.strict();
const faceSchema = z
.object({
id: identifierSchema,
targets: z.array(fontNameSchema).min(1).max(32),
weight: z.number().int().min(100).max(900).multipleOf(100),
style: z.enum(["normal", "italic"]),
web: sourceAssetSchema.extend({
bytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_WEB_FONT_BYTES)
}),
docx: sourceAssetSchema.extend({
bytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_DOCX_FONT_BYTES)
})
})
.strict();
export const fontPackRecipeSchema = z
.object({
recipeVersion: z.literal(FONT_PACK_RECIPE_VERSION),
manifestVersion: z.literal(FONT_PACK_MANIFEST_VERSION),
id: identifierSchema,
version: semanticVersionSchema,
name: z.string().trim().min(1).max(100),
description: z.string().trim().max(500),
internalFamily: fontNameSchema,
license: z.string().trim().min(1).max(100),
licenseSource: safeRelativePathSchema,
licensePath: safeRelativePathSchema,
licenseBytes: z.number().int().positive().max(MAXIMUM_FONT_PACK_LICENSE_BYTES),
licenseSha256: sha256Schema,
noticeSource: safeRelativePathSchema,
noticePath: safeRelativePathSchema,
compatibility: z
.object({
minimumAppVersion: semanticVersionSchema,
maximumAppVersionExclusive: semanticVersionSchema.optional()
})
.strict(),
faces: z.array(faceSchema).min(1).max(MAXIMUM_FONT_PACK_FACE_COUNT)
})
.strict()
.superRefine((recipe, context) => {
const paths = new Set([
recipe.licensePath.toLowerCase(),
recipe.noticePath.toLowerCase()
]);
const sourcePaths = new Set<string>();
const ids = new Set<string>();
for (const [index, face] of recipe.faces.entries()) {
if (ids.has(face.id)) {
context.addIssue({
code: "custom",
message: "字体面 ID 重复",
path: ["faces", index, "id"]
});
}
ids.add(face.id);
for (const [kind, asset, extension] of [
["web", face.web, ".woff2"],
["docx", face.docx, ".ttf"]
] as const) {
if (!asset.source.toLowerCase().endsWith(extension)) {
context.addIssue({
code: "custom",
message: `源资源必须使用 ${extension} 扩展名`,
path: ["faces", index, kind, "source"]
});
}
if (!asset.path.toLowerCase().endsWith(extension)) {
context.addIssue({
code: "custom",
message: `目标资源必须使用 ${extension} 扩展名`,
path: ["faces", index, kind, "path"]
});
}
const outputPath = asset.path.toLowerCase();
if (paths.has(outputPath)) {
context.addIssue({
code: "custom",
message: "字体包输出路径重复",
path: ["faces", index, kind, "path"]
});
}
paths.add(outputPath);
const sourcePath = asset.source.toLowerCase();
if (sourcePaths.has(sourcePath)) {
context.addIssue({
code: "custom",
message: "字体源路径重复",
path: ["faces", index, kind, "source"]
});
}
sourcePaths.add(sourcePath);
}
}
const totalBytes = recipe.faces.reduce(
(total, face) => total + face.web.bytes + face.docx.bytes,
0
);
if (totalBytes > MAXIMUM_FONT_PACK_TOTAL_BYTES) {
context.addIssue({
code: "custom",
message: "字体包资源总大小超过限制",
path: ["faces"]
});
}
});
export type FontPackRecipe = z.infer<typeof fontPackRecipeSchema>;
@@ -0,0 +1,157 @@
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { buildFontPack, verifyFontPack } from "../src/index.js";
const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url));
const katexFontRoot = join(repositoryRoot, "node_modules", "katex", "dist", "fonts");
const temporaryDirectories: string[] = [];
function hash(content: Uint8Array | string) {
return createHash("sha256").update(content).digest("hex");
}
async function temporaryDirectory() {
const directory = await mkdtemp(join(tmpdir(), "mdtp-font-pack-builder-"));
temporaryDirectories.push(directory);
return directory;
}
async function createFixture(root: string) {
const sourceRoot = join(root, "source");
const recipeRoot = join(root, "recipe");
const outputRoot = join(root, "output", "root");
const reportPath = join(root, "output", "report.json");
await Promise.all([mkdir(sourceRoot, { recursive: true }), mkdir(recipeRoot, { recursive: true })]);
const webName = "KaTeX_Main-Regular.woff2";
const docxName = "KaTeX_Main-Regular.ttf";
await Promise.all([
copyFile(join(katexFontRoot, webName), join(sourceRoot, webName)),
copyFile(join(katexFontRoot, docxName), join(sourceRoot, docxName))
]);
const [web, docx] = await Promise.all([
readFile(join(sourceRoot, webName)),
readFile(join(sourceRoot, docxName))
]);
const license = "Test Font License\n";
const notice = "# Test font\n";
await Promise.all([
writeFile(join(recipeRoot, "LICENSE.txt"), license, "utf8"),
writeFile(join(recipeRoot, "SOURCE.md"), notice, "utf8")
]);
const recipe = {
recipeVersion: 1,
manifestVersion: 1,
id: "test-serif",
version: "1.0.0",
name: "测试字体包",
description: "构建器测试",
internalFamily: "KaTeX_Main",
license: "Test",
licenseSource: "LICENSE.txt",
licensePath: "LICENSE.txt",
licenseBytes: Buffer.byteLength(license),
licenseSha256: hash(license),
noticeSource: "SOURCE.md",
noticePath: "SOURCE.md",
compatibility: {
minimumAppVersion: "0.6.0",
maximumAppVersionExclusive: "0.7.0"
},
faces: [
{
id: "main-regular",
targets: ["KaTeX_Main"],
weight: 400,
style: "normal",
web: {
source: webName,
path: `fonts/${webName}`,
bytes: web.byteLength,
sha256: hash(web)
},
docx: {
source: docxName,
path: `fonts/${docxName}`,
bytes: docx.byteLength,
sha256: hash(docx)
}
}
]
};
const recipePath = join(recipeRoot, "recipe.json");
await writeFile(recipePath, `${JSON.stringify(recipe, null, 2)}\n`, "utf8");
return { sourceRoot, recipeRoot, outputRoot, reportPath, recipePath, recipe };
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
);
});
describe("字体包构建器", () => {
it("从冻结资源构建可注册、可重复验证的字体包", async () => {
const fixture = await createFixture(await temporaryDirectory());
const first = await buildFontPack(fixture);
expect(first.unchanged).toBe(false);
expect(first.packId).toBe("test-serif");
expect(first.faces[0]).toMatchObject({
family: "KaTeX_Main",
weight: 400,
permission: "installable"
});
expect(JSON.parse(await readFile(fixture.reportPath, "utf8"))).toMatchObject({
packId: "test-serif",
packVersion: "1.0.0"
});
const second = await buildFontPack(fixture);
expect(second.unchanged).toBe(true);
expect(second.packFingerprint).toBe(first.packFingerprint);
const verified = await verifyFontPack({
root: fixture.outputRoot,
appVersion: "0.6.0",
packId: "test-serif",
packVersion: "1.0.0"
});
expect(verified.faces).toHaveLength(1);
});
it("拒绝哈希不匹配的冻结字体源", async () => {
const fixture = await createFixture(await temporaryDirectory());
const webPath = join(fixture.sourceRoot, "KaTeX_Main-Regular.woff2");
const content = await readFile(webPath);
content[content.byteLength - 1] = (content[content.byteLength - 1] ?? 0) ^ 1;
await writeFile(webPath, content);
await expect(buildFontPack(fixture)).rejects.toThrow("SHA-256");
});
it("拒绝覆盖内容不同的同版本发行包", async () => {
const fixture = await createFixture(await temporaryDirectory());
await buildFontPack(fixture);
const changedRecipe = {
...fixture.recipe,
description: "同版本内容发生变化"
};
await writeFile(
fixture.recipePath,
`${JSON.stringify(changedRecipe, null, 2)}\n`,
"utf8"
);
await expect(buildFontPack(fixture)).rejects.toThrow("拒绝覆盖");
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": [
"node"
]
},
"include": [
"src"
]
}