feat: 接入可选字体包同源渲染链
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
} from "./docx-export-service.js";
|
||||
export {
|
||||
createThemeRegistry,
|
||||
type ThemeFontPackStatus,
|
||||
type ThemeRecord,
|
||||
type ThemeRegistryOptions
|
||||
} from "./theme-registry.js";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user