feat: 实现 DOCX 可编辑字体嵌入
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user