feat: 接入可选字体包同源渲染链

This commit is contained in:
SkyJourney
2026-07-31 21:15:45 +08:00
parent c76454be9d
commit 9f96d51922
17 changed files with 720 additions and 24 deletions
+1
View File
@@ -24,6 +24,7 @@
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/docx-engine": "0.1.0",
"@md-to-pdf/docx-theme-engine": "0.1.0",
"@md-to-pdf/font-pack-registry": "0.1.0",
"@md-to-pdf/renderer": "0.1.0"
},
"devDependencies": {
@@ -11,6 +11,7 @@ import {
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
import {
createThemeRegistry,
type ThemeFontPackStatus,
type ThemeRegistryOptions
} from "./theme-registry.js";
import {
@@ -50,6 +51,7 @@ export interface PreparedDocxExport {
source: "bundled" | "local";
css: string;
fonts: DocxFontSource[];
fontPack?: ThemeFontPackStatus;
};
}
@@ -188,10 +190,11 @@ export function createApplicationService(
);
}
const [document, themeCss, themeFonts] = await Promise.all([
const [document, themeCss, themeFonts, fontPack] = await Promise.all([
render(parsed.data, context),
themes.getCss(theme.manifest.id),
themes.getDocxFonts(theme.manifest.id)
themes.getDocxFonts(theme.manifest.id),
themes.getFontPackStatus(theme.manifest.id)
]);
if (themeCss === undefined || themeFonts === undefined) {
throw new ApplicationRequestError(
@@ -208,7 +211,8 @@ export function createApplicationService(
manifest: theme.manifest,
source: theme.source,
css: themeCss,
fonts: themeFonts
fonts: themeFonts,
...(fontPack ? { fontPack } : {})
}
};
}
@@ -216,6 +220,7 @@ export function createApplicationService(
return {
getThemeAsset: themes.getAsset,
getThemeCss: themes.getCss,
getFontPackAsset: themes.getFontPackAsset,
invalidateThemes: themes.invalidate,
listThemes,
prepareDocxExport,
@@ -407,7 +407,10 @@ export class DocxExportService {
diagnostics: {
warnings: [
...media.warnings,
...themeTokens.warnings
...themeTokens.warnings,
...(prepared.theme.fontPack?.diagnostics ?? [])
.filter((diagnostic) => diagnostic.severity !== "info")
.map((diagnostic) => diagnostic.message)
],
echartsErrors: media.echartsErrors,
mermaidErrors: media.mermaidErrors
+1
View File
@@ -47,6 +47,7 @@ export {
} from "./docx-export-service.js";
export {
createThemeRegistry,
type ThemeFontPackStatus,
type ThemeRecord,
type ThemeRegistryOptions
} from "./theme-registry.js";
+251 -3
View File
@@ -18,6 +18,15 @@ import {
type DocxFontFace,
type ThemeManifest
} from "@md-to-pdf/core";
import {
discoverFontPacks,
readInstalledFontPackAsset,
resolveFontPackFaces,
type FontPackDiagnostic,
type FontPackFaceRequest,
type FontPackRegistryResult,
type ResolvedFontPackFace
} from "@md-to-pdf/font-pack-registry";
export interface ThemeRecord {
manifest: ThemeManifest;
@@ -35,6 +44,34 @@ export interface ThemeRegistryOptions {
onWarning?: (message: string) => void;
cacheTtlMs?: number;
createAssetUrl?: (themeId: string, assetPath: string) => string;
fontPacks?: {
roots: readonly string[];
appVersion: string;
cacheTtlMs?: number;
preferredPackIds?: readonly string[];
createAssetUrl?: (
packId: string,
packVersion: string,
faceId: string,
kind: "web" | "docx",
sha256: string
) => string;
};
}
export interface ThemeFontPackStatus {
registryFingerprint: string;
appliedFaces: Array<{
family: string;
weight: number;
style: "normal" | "italic";
packId: string;
packVersion: string;
faceId: string;
webSha256: string;
docxSha256: string;
}>;
diagnostics: FontPackDiagnostic[];
}
const assetContentTypes: Record<string, string> = {
@@ -322,6 +359,148 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
}
| undefined;
let sharedFontCssPromise: Promise<string> | undefined;
let cachedFontPacks:
| {
expiresAt: number;
promise: Promise<FontPackRegistryResult>;
}
| undefined;
const reportedFontPackDiagnostics = new Set<string>();
const fontPackCacheTtlMs =
options.fontPacks?.cacheTtlMs ?? 5 * 60_000;
if (!Number.isFinite(fontPackCacheTtlMs) || fontPackCacheTtlMs < 0) {
throw new Error("字体包缓存有效期必须是非负有限数值");
}
const createFontPackAssetUrl =
options.fontPacks?.createAssetUrl ??
((
packId: string,
packVersion: string,
faceId: string,
kind: "web" | "docx",
fingerprint: string
) =>
`/api/font-packs/${encodeURIComponent(
packId
)}/${encodeURIComponent(packVersion)}/${encodeURIComponent(
faceId
)}/${kind}?v=${fingerprint}`);
function listFontPacks() {
if (!options.fontPacks) {
return Promise.resolve<FontPackRegistryResult>({
packs: [],
diagnostics: [],
fingerprint: "0".repeat(64)
});
}
const now = Date.now();
if (cachedFontPacks && now < cachedFontPacks.expiresAt) {
return cachedFontPacks.promise;
}
const promise = discoverFontPacks({
roots: options.fontPacks.roots,
appVersion: options.fontPacks.appVersion
}).then((result) => {
for (const item of result.diagnostics) {
if (item.severity === "info") {
continue;
}
const key = [
item.code,
item.root ?? "",
item.packId ?? "",
item.packVersion ?? "",
item.message
].join(":");
if (!reportedFontPackDiagnostics.has(key)) {
reportedFontPackDiagnostics.add(key);
options.onWarning?.(`字体包 ${item.code}${item.message}`);
}
}
return result;
});
cachedFontPacks = {
expiresAt: now + fontPackCacheTtlMs,
promise
};
void promise.catch(() => {
if (cachedFontPacks?.promise === promise) {
cachedFontPacks = undefined;
}
});
return promise;
}
async function resolveThemeFontPacks(theme: ThemeRecord) {
const registry = await listFontPacks();
const faces = theme.manifest.docxFonts?.faces ?? [];
const requests: FontPackFaceRequest[] = faces.map((face) => ({
family: face.family,
aliases: face.aliases,
weight: face.weight,
style: face.style
}));
const resolution = resolveFontPackFaces(requests, registry.packs, {
preferredPackIds: options.fontPacks?.preferredPackIds ?? [],
reportMissing: registry.packs.length > 0
});
const matches = new Map<DocxFontFace, ResolvedFontPackFace>();
for (const match of resolution.resolved) {
const index = requests.indexOf(match.request);
const face = index < 0 ? undefined : faces[index];
if (face) {
matches.set(face, match);
}
}
return { registry, resolution, matches };
}
function createFontPackCss(
matches: ReadonlyMap<DocxFontFace, ResolvedFontPackFace>
) {
if (matches.size === 0) {
return "";
}
const fingerprints = new Set<string>();
const rules: string[] = [];
const emitted = new Set<string>();
for (const [request, match] of matches) {
fingerprints.add(
`${match.pack.id}@${match.pack.version}:${match.pack.fingerprint}`
);
for (const target of match.face.targets) {
const key = `${target.toLocaleLowerCase("en-US")}:${
request.weight
}:${request.style}`;
if (emitted.has(key)) {
continue;
}
emitted.add(key);
rules.push(
[
"@font-face {",
` font-family: "${target}";`,
` src: url("${createFontPackAssetUrl(
match.pack.id,
match.pack.version,
match.face.id,
"web",
match.face.web.sha256
)}") format("woff2");`,
` font-weight: ${request.weight};`,
` font-style: ${request.style};`,
" font-display: block;",
"}"
].join("\n")
);
}
}
return [
`/* md-to-pdf-font-packs:${[...fingerprints].sort().join(",")} */`,
...rules
].join("\n");
}
function loadSharedFontCss() {
if (!sharedFontCssPromise) {
@@ -408,6 +587,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
function invalidate() {
cachedThemes = undefined;
sharedFontCssPromise = undefined;
cachedFontPacks = undefined;
reportedFontPackDiagnostics.clear();
}
async function get(themeId: string) {
@@ -427,15 +608,67 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
? [theme.manifest.print]
: [])
];
const css = await Promise.all(
const [css, fontPacks] = await Promise.all([
Promise.all(
cssFiles.map((path) =>
loadThemeCssFile(theme, path, createAssetUrl)
)
);
),
resolveThemeFontPacks(theme)
]);
if (theme.source === "bundled") {
css.unshift(await loadSharedFontCss());
}
return css.filter(Boolean).join("\n");
return [...css, createFontPackCss(fontPacks.matches)]
.filter(Boolean)
.join("\n");
}
async function getFontPackAsset(
packId: string,
packVersion: string,
faceId: string,
kind: "web" | "docx"
) {
const registry = await listFontPacks();
const pack = registry.packs.find(
(candidate) =>
candidate.id === packId && candidate.version === packVersion
);
const face = pack?.faces.find((candidate) => candidate.id === faceId);
if (!face) {
return undefined;
}
const asset = face[kind];
return {
contentType: kind === "web" ? "font/woff2" : "font/ttf",
content: await readInstalledFontPackAsset(asset),
sha256: asset.sha256
};
}
async function getFontPackStatus(
themeId: string
): Promise<ThemeFontPackStatus | undefined> {
const theme = await get(themeId);
if (!theme) {
return undefined;
}
const { registry, resolution } = await resolveThemeFontPacks(theme);
return {
registryFingerprint: registry.fingerprint,
appliedFaces: resolution.resolved.map((match) => ({
family: match.request.family,
weight: match.request.weight,
style: match.request.style,
packId: match.pack.id,
packVersion: match.pack.version,
faceId: match.face.id,
webSha256: match.face.web.sha256,
docxSha256: match.face.docx.sha256
})),
diagnostics: [...registry.diagnostics, ...resolution.diagnostics]
};
}
async function getAsset(themeId: string, assetPath: string) {
@@ -479,8 +712,21 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
return undefined;
}
const faces = theme.manifest.docxFonts?.faces ?? [];
const fontPacks = await resolveThemeFontPacks(theme);
const fonts = await Promise.all(
faces.map(async (face) => {
const matched = fontPacks.matches.get(face);
if (matched) {
return {
...face,
aliases: [
...new Set([...face.aliases, ...matched.face.targets])
],
source: `font-pack:${matched.pack.id}/${matched.pack.version}/${matched.face.id}/docx`,
license: matched.pack.license,
content: await readInstalledFontPackAsset(matched.face.docx)
};
}
const shared = face.source.startsWith(
sharedThemeAssetScheme
);
@@ -519,6 +765,8 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
getAsset,
getCss,
getDocxFonts,
getFontPackAsset,
getFontPackStatus,
invalidate,
list
};
@@ -13,6 +13,7 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import {
ApplicationRequestError,
createApplicationService
@@ -126,6 +127,64 @@ async function createThemeFixture() {
return { bundledRoot, localRoot };
}
function sha256(content: Uint8Array | string) {
return createHash("sha256").update(content).digest("hex");
}
async function createFontPackFixture(
options: { corruptWebHash?: boolean; minimumAppVersion?: string } = {}
) {
const root = join(temporaryDirectory!, "font-packs");
const directory = join(root, "test-font-pack", "1.0.0");
const web = Buffer.from([10, 11, 12, 13, 14]);
const docx = Buffer.from([20, 21, 22, 23, 24, 25]);
const license = "SIL Open Font License 1.1";
await mkdir(join(directory, "fonts"), { recursive: true });
await writeFile(join(directory, "fonts", "test.woff2"), web);
await writeFile(join(directory, "fonts", "test.ttf"), docx);
await writeFile(join(directory, "OFL.txt"), license, "utf8");
await writeFile(
join(directory, "font-pack.json"),
JSON.stringify({
manifestVersion: 1,
id: "test-font-pack",
version: "1.0.0",
name: "测试字体包",
description: "应用服务字体包测试",
license: "OFL-1.1",
licenseFile: "OFL.txt",
licenseSha256: sha256(license),
licenseBytes: Buffer.byteLength(license),
compatibility: {
minimumAppVersion: options.minimumAppVersion ?? "0.6.0",
maximumAppVersionExclusive: "0.7.0"
},
faces: [
{
id: "test-regular",
targets: ["Test"],
weight: 400,
style: "normal",
web: {
path: "fonts/test.woff2",
format: "woff2",
bytes: web.byteLength,
sha256: options.corruptWebHash ? "0".repeat(64) : sha256(web)
},
docx: {
path: "fonts/test.ttf",
format: "truetype",
bytes: docx.byteLength,
sha256: sha256(docx)
}
}
]
}),
"utf8"
);
return { root, web, docx };
}
describe("共享应用服务", () => {
it("渲染安全 Markdown 文档", async () => {
const roots = await createThemeFixture();
@@ -350,6 +409,98 @@ describe("共享应用服务", () => {
]);
});
it("为主题 CSS 与 DOCX 选择同一字体包的双资源", async () => {
const roots = await createThemeFixture();
const fontPack = await createFontPackFixture();
const service = createApplicationService({
...roots,
fontPacks: {
roots: [fontPack.root],
appVersion: "0.6.0",
createAssetUrl: (packId, version, faceId, kind, hash) =>
`mdpdf://font-pack/${packId}/${version}/${faceId}/${kind}?v=${hash}`
}
});
const css = await service.getThemeCss("test-theme");
expect(css).toContain("md-to-pdf-font-packs:test-font-pack@1.0.0");
expect(css).toContain(
"mdpdf://font-pack/test-font-pack/1.0.0/test-regular/web?v="
);
const prepared = await service.prepareDocxExport({
markdown: "# 字体包",
fileName: "字体包.md",
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
});
expect(prepared.theme.fonts[0]).toEqual(
expect.objectContaining({
family: "Test",
source: "font-pack:test-font-pack/1.0.0/test-regular/docx",
license: "OFL-1.1",
content: new Uint8Array(fontPack.docx)
})
);
expect(prepared.theme.fontPack?.appliedFaces).toEqual([
expect.objectContaining({
family: "Test",
packId: "test-font-pack",
faceId: "test-regular"
})
]);
await expect(
service.getFontPackAsset(
"test-font-pack",
"1.0.0",
"test-regular",
"web"
)
).resolves.toEqual({
contentType: "font/woff2",
content: new Uint8Array(fontPack.web),
sha256: sha256(fontPack.web)
});
});
it("字体包损坏时记录诊断并回退到主题字体", async () => {
const roots = await createThemeFixture();
const fontPack = await createFontPackFixture({ corruptWebHash: true });
const onWarning = vi.fn();
const service = createApplicationService({
...roots,
onWarning,
fontPacks: {
roots: [fontPack.root],
appVersion: "0.6.0"
}
});
const prepared = await service.prepareDocxExport({
markdown: "# 回退",
fileName: "回退.md",
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
});
expect(prepared.theme.css).not.toContain("md-to-pdf-font-packs:");
expect(prepared.theme.fonts[0]).toEqual(
expect.objectContaining({
family: "Test",
source: "fonts/test.woff2",
content: Buffer.from([0, 1, 2, 3])
})
);
expect(prepared.theme.fontPack?.diagnostics[0]?.code).toBe(
"MANIFEST_INVALID"
);
expect(onWarning).toHaveBeenCalledWith(
expect.stringContaining("字体包 MANIFEST_INVALID")
);
});
it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
+2 -2
View File
@@ -9,9 +9,9 @@ import {
export const THEME_MANIFEST_VERSION = 1;
export const MAXIMUM_DOCX_FONT_FACE_COUNT = 16;
export const MAXIMUM_DOCX_FONT_SOURCE_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_DOCX_FONT_SOURCE_BYTES = 16 * 1024 * 1024;
export const MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES =
32 * 1024 * 1024;
48 * 1024 * 1024;
const docxFontNameSchema = z
.string()