Files
MorphDoc/packages/font-pack-builder/src/builder.ts
T

526 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
}