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
@@ -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
};