feat: 建立 DOCX 共享协议与准备服务

This commit is contained in:
SkyJourney
2026-07-30 11:04:18 +08:00
parent fb3ddada39
commit f7d4efeafc
10 changed files with 512 additions and 3 deletions
+5
View File
@@ -31,12 +31,17 @@ const service = createApplicationService({
const themes = await service.listThemes();
const document = await service.render(request);
const preparedDocx = await service.prepareDocxExport(docxRequest);
```
Web 端通过 `apps/server` 的 HTTP API 调用;桌面端在主进程中创建同一
服务,并通过受限 IPC 暴露给渲染进程。Web 默认不接收本地素材目录,
Desktop 才会以 Markdown 所在目录为边界解析相对资源。
`prepareDocxExport()` 统一校验 DOCX 请求、解析图片资源、查找主题并返回
源请求、安全渲染文档、主题清单和主题 CSS。它不调用 Pandoc;后续 DOCX
引擎只消费该准备结果,避免 Server 与 Desktop 重复实现文档准备逻辑。
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
@@ -2,6 +2,12 @@ import {
MarkdownDocumentParseError,
renderMarkdown
} from "@md-to-pdf/renderer";
import {
docxExportRequestSchema,
type DocxExportRequest,
type RenderedMarkdownDocument,
type ThemeManifest
} from "@md-to-pdf/core";
import {
createThemeRegistry,
type ThemeRegistryOptions
@@ -35,6 +41,16 @@ export interface ApplicationServiceOptions
extends ThemeRegistryOptions,
ImageResourceResolverOptions {}
export interface PreparedDocxExport {
request: DocxExportRequest;
document: RenderedMarkdownDocument;
theme: {
manifest: ThemeManifest;
source: "bundled" | "local";
css: string;
};
}
export function createApplicationService(
options: ApplicationServiceOptions
) {
@@ -113,11 +129,92 @@ export function createApplicationService(
};
}
async function prepareDocxExport(
request: unknown,
context: ImageResolutionContext = {}
): Promise<PreparedDocxExport> {
const parsed = docxExportRequestSchema.safeParse(request);
if (!parsed.success) {
const firstField = parsed.error.issues[0]?.path[0];
if (firstField === "exportConfig") {
throw new ApplicationRequestError(
400,
"INVALID_EXPORT_CONFIG",
"导出配置无效"
);
}
if (firstField === "fileName") {
throw new ApplicationRequestError(
400,
"INVALID_FILE_NAME",
"fileName 必须是长度不超过 500 的字符串"
);
}
if (firstField === "markdown") {
const markdownTooLarge = parsed.error.issues.some(
(issue) =>
issue.path[0] === "markdown" && issue.code === "too_big"
);
throw new ApplicationRequestError(
markdownTooLarge ? 413 : 400,
markdownTooLarge ? "MARKDOWN_TOO_LARGE" : "INVALID_MARKDOWN",
markdownTooLarge
? "Markdown 内容不能超过 1.5 MB"
: "markdown 必须是字符串"
);
}
if (firstField === "resources") {
throw new ApplicationRequestError(
400,
"INVALID_IMAGE_RESOURCES",
"图片资源参数无效"
);
}
throw new ApplicationRequestError(
400,
"INVALID_DOCX_REQUEST",
"DOCX 导出请求无效"
);
}
const theme = await themes.get(parsed.data.exportConfig.themeId);
if (!theme) {
throw new ApplicationRequestError(
404,
"THEME_NOT_FOUND",
"未找到指定主题"
);
}
const [document, themeCss] = await Promise.all([
render(parsed.data, context),
themes.getCss(theme.manifest.id)
]);
if (themeCss === undefined) {
throw new ApplicationRequestError(
404,
"THEME_NOT_FOUND",
"未找到指定主题"
);
}
return {
request: parsed.data,
document,
theme: {
manifest: theme.manifest,
source: theme.source,
css: themeCss
}
};
}
return {
getThemeAsset: themes.getAsset,
getThemeCss: themes.getCss,
invalidateThemes: themes.invalidate,
listThemes,
prepareDocxExport,
render
};
}
+2 -1
View File
@@ -4,7 +4,8 @@ export {
createApplicationService,
type ApplicationService,
type ApplicationServiceOptions,
type MarkdownRenderRequest
type MarkdownRenderRequest,
type PreparedDocxExport
} from "./application-service.js";
export {
MAXIMUM_IMAGE_BYTES,
@@ -17,6 +17,10 @@ import {
ApplicationRequestError,
createApplicationService
} from "../src/index.js";
import {
MAXIMUM_DOCX_MARKDOWN_LENGTH,
defaultExportConfig
} from "@md-to-pdf/core";
let temporaryDirectory: string | undefined;
@@ -269,4 +273,110 @@ describe("共享应用服务", () => {
"已忽略与内置主题同 ID 的本地主题 test-theme"
);
});
it("为 DOCX 引擎准备统一文档、主题和源请求", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const prepared = await service.prepareDocxExport({
markdown: "# DOCX 文档",
fileName: "报告.md",
language: "zh-CN",
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
});
expect(prepared.request).toMatchObject({
markdown: "# DOCX 文档",
fileName: "报告.md",
language: "zh-CN",
resources: []
});
expect(prepared.document.metadata.title).toBe("DOCX 文档");
expect(prepared.theme.manifest.id).toBe("test-theme");
expect(prepared.theme.source).toBe("bundled");
expect(prepared.theme.css).toContain(
"#write { font-family: Test; }"
);
});
it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
await expect(
service.prepareDocxExport({
markdown: "# 文档",
exportConfig: {}
})
).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_EXPORT_CONFIG"
})
);
await expect(
service.prepareDocxExport({
markdown: "# 文档",
exportConfig: {
...defaultExportConfig,
themeId: "missing-theme"
}
})
).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 404,
code: "THEME_NOT_FOUND"
})
);
await expect(
service.prepareDocxExport({
markdown: 42,
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
})
).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_MARKDOWN"
})
);
await expect(
service.prepareDocxExport({
markdown: "x".repeat(MAXIMUM_DOCX_MARKDOWN_LENGTH + 1),
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
})
).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 413,
code: "MARKDOWN_TOO_LARGE"
})
);
await expect(
service.prepareDocxExport({
markdown: "# 文档",
resources: [{ path: "", data: "" }],
exportConfig: {
...defaultExportConfig,
themeId: "test-theme"
}
})
).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_IMAGE_RESOURCES"
})
);
});
});