feat: 实现 DOCX 可编辑字体嵌入

This commit is contained in:
SkyJourney
2026-07-31 16:18:30 +08:00
parent f3847f3e4b
commit 10bc62cee4
32 changed files with 2195 additions and 76 deletions
@@ -8,6 +8,7 @@ import {
type RenderedMarkdownDocument,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxFontSource } from "@md-to-pdf/docx-engine";
import {
createThemeRegistry,
type ThemeRegistryOptions
@@ -48,6 +49,7 @@ export interface PreparedDocxExport {
manifest: ThemeManifest;
source: "bundled" | "local";
css: string;
fonts: DocxFontSource[];
};
}
@@ -186,11 +188,12 @@ export function createApplicationService(
);
}
const [document, themeCss] = await Promise.all([
const [document, themeCss, themeFonts] = await Promise.all([
render(parsed.data, context),
themes.getCss(theme.manifest.id)
themes.getCss(theme.manifest.id),
themes.getDocxFonts(theme.manifest.id)
]);
if (themeCss === undefined) {
if (themeCss === undefined || themeFonts === undefined) {
throw new ApplicationRequestError(
404,
"THEME_NOT_FOUND",
@@ -204,7 +207,8 @@ export function createApplicationService(
theme: {
manifest: theme.manifest,
source: theme.source,
css: themeCss
css: themeCss,
fonts: themeFonts
}
};
}
@@ -489,6 +489,7 @@ export class DocxExportService {
exportConfig: prepared.request.exportConfig,
theme: prepared.theme.manifest,
themeTokens: themeTokens.tokens,
fonts: prepared.theme.fonts,
metadata: prepared.document.metadata,
semanticDocument: prepared.document.semanticDocument,
media
+92 -4
View File
@@ -13,7 +13,9 @@ import {
sep
} from "node:path";
import {
MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES,
themeManifestSchema,
type DocxFontFace,
type ThemeManifest
} from "@md-to-pdf/core";
@@ -23,6 +25,10 @@ export interface ThemeRecord {
source: "bundled" | "local";
}
export interface ThemeFontAsset extends DocxFontFace {
content: Uint8Array;
}
export interface ThemeRegistryOptions {
bundledRoot: string;
localRoot: string;
@@ -48,6 +54,7 @@ const cssImportPattern =
const maximumCssImportDepth = 8;
const sharedThemeAssetId = "_shared";
const sharedThemeAssetScheme = "theme-shared:";
const sharedFontCssPath = "official-fonts/fonts.css";
function isMissingFileError(error: unknown) {
return (
@@ -314,6 +321,40 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
promise: Promise<ThemeRecord[]>;
}
| undefined;
let sharedFontCssPromise: Promise<string> | undefined;
function loadSharedFontCss() {
if (!sharedFontCssPromise) {
sharedFontCssPromise = (async () => {
const sharedRoot = resolve(
options.bundledRoot,
sharedThemeAssetId
);
try {
const cssPath = await resolveThemeFile(
sharedRoot,
sharedFontCssPath
);
return prepareCssSegment(
await readFile(cssPath, "utf8"),
sharedThemeAssetId,
posix.dirname(sharedFontCssPath),
createAssetUrl,
false
);
} catch (error) {
if (isMissingFileError(error)) {
return "";
}
throw error;
}
})();
void sharedFontCssPromise.catch(() => {
sharedFontCssPromise = undefined;
});
}
return sharedFontCssPromise;
}
async function scanThemes() {
const [bundledThemes, localThemes] = await Promise.all([
@@ -366,6 +407,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
function invalidate() {
cachedThemes = undefined;
sharedFontCssPromise = undefined;
}
async function get(themeId: string) {
@@ -385,13 +427,15 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
? [theme.manifest.print]
: [])
];
return (
await Promise.all(
const css = await Promise.all(
cssFiles.map((path) =>
loadThemeCssFile(theme, path, createAssetUrl)
)
)
).join("\n");
);
if (theme.source === "bundled") {
css.unshift(await loadSharedFontCss());
}
return css.filter(Boolean).join("\n");
}
async function getAsset(themeId: string, assetPath: string) {
@@ -427,10 +471,54 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
};
}
async function getDocxFonts(
themeId: string
): Promise<ThemeFontAsset[] | undefined> {
const theme = await get(themeId);
if (!theme) {
return undefined;
}
const faces = theme.manifest.docxFonts?.faces ?? [];
const fonts = await Promise.all(
faces.map(async (face) => {
const shared = face.source.startsWith(
sharedThemeAssetScheme
);
if (shared && theme.source !== "bundled") {
throw new Error("本地主题不能嵌入内置共享字体");
}
const asset = await getAsset(
shared ? sharedThemeAssetId : theme.manifest.id,
shared
? face.source.slice(sharedThemeAssetScheme.length)
: face.source
);
if (!asset || !asset.contentType.startsWith("font/")) {
throw new Error(
`DOCX 字体资源无效:${face.source}`
);
}
return {
...face,
content: asset.content
};
})
);
const totalBytes = fonts.reduce(
(total, font) => total + font.content.byteLength,
0
);
if (totalBytes > MAXIMUM_DOCX_FONT_TOTAL_SOURCE_BYTES) {
throw new Error("DOCX 字体资源总大小超过限制");
}
return fonts;
}
return {
get,
getAsset,
getCss,
getDocxFonts,
invalidate,
list
};
@@ -63,6 +63,21 @@ async function createThemeFixture() {
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["code", "table"],
docxFonts: {
faces: [
{
family: "Test",
source: "fonts/test.woff2",
license: "OFL-1.1"
},
{
family: "SharedTest",
source:
"theme-shared:official-fonts/shared.woff2",
license: "GPL-3.0-with-font-exception"
}
]
},
pageDefaults: {
margins: {
top: "37mm",
@@ -98,6 +113,16 @@ async function createThemeFixture() {
join(sharedFontRoot, "shared.woff2"),
Buffer.from([4, 5, 6, 7])
);
await writeFile(
join(sharedFontRoot, "fonts.css"),
[
"@font-face {",
" font-family: SharedTest;",
" src: url('./shared.woff2') format('woff2');",
"}"
].join("\n"),
"utf8"
);
return { bundledRoot, localRoot };
}
@@ -198,6 +223,9 @@ describe("共享应用服务", () => {
).resolves.toContain(
"mdpdf://theme/_shared/official-fonts/shared.woff2"
);
await expect(
service.getThemeCss("test-theme")
).resolves.toContain("font-family: SharedTest");
});
it("安全读取主题二进制资源", async () => {
@@ -308,6 +336,18 @@ describe("共享应用服务", () => {
expect(prepared.theme.css).toContain(
"#write { font-family: Test; }"
);
expect(prepared.theme.fonts).toEqual([
expect.objectContaining({
family: "Test",
source: "fonts/test.woff2",
content: Buffer.from([0, 1, 2, 3])
}),
expect.objectContaining({
family: "SharedTest",
source: "theme-shared:official-fonts/shared.woff2",
content: Buffer.from([4, 5, 6, 7])
})
]);
});
it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => {
@@ -74,7 +74,8 @@ const prepared: PreparedDocxExport = {
theme: {
manifest: theme(),
source: "bundled",
css: ""
css: "",
fonts: []
}
};
@@ -126,7 +127,8 @@ function conversionResult(): PandocDocxConversionResult {
xmlPartCount: 9,
relationshipCount: 3,
headerCount: 0,
footerCount: 1
footerCount: 1,
embeddedFontCount: 0
},
timings: {
referenceMs: 3,
@@ -84,7 +84,8 @@ const prepared: PreparedDocxExport = {
theme: {
manifest,
source: "bundled",
css: "#write { color: black; }"
css: "#write { color: black; }",
fonts: []
}
};
@@ -77,7 +77,8 @@ function prepared(
theme: {
manifest: manifest(docxStyle),
source: "bundled",
css: "#write { font-family: Test; }"
css: "#write { font-family: Test; }",
fonts: []
}
};
}