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
+7 -1
View File
@@ -77,6 +77,12 @@ Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程
复制主题允许继续用于公司内部源码共享、部署和安装包分发,但不受项目
许可证重新授权;公开 GitHub 前必须移除、替换或取得公开分发授权。
`v0.6.0` 阶段 3 已完成 DOCX 共享协议:`packages/core` 统一定义请求
Schema、资源、Pandoc capability、错误码、结果、诊断、耗时、MIME 和
安全文件名;`packages/application` 新增 `prepareDocxExport()`,复用
现有主题、图片和 Markdown 安全渲染能力,为后续 Server 与 Desktop DOCX
引擎返回同一种准备结果。本阶段未新增 HTTP 路由、IPC 或 Pandoc 调用。
## 2. 已完成
### 2.1 项目骨架
@@ -955,7 +961,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- 阶段 1:已冻结架构、能力边界和 Word/WPS 验收门禁;
- 阶段 2:已冻结 Pandoc `3.9.0.2`、许可证、Docker 与 Desktop
分发方式;
- 阶段 3:实现 DOCX 共享模型和跨端导出协议;
- 阶段 3实现 DOCX 共享模型和跨端导出协议;
- 阶段 4:实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
- 阶段 5~8:依次实现资源预处理、动态 reference.docx、Pandoc 转换服务
以及 Web/Desktop 导出交互;
+14 -1
View File
@@ -1,6 +1,6 @@
# v0.6.0 DOCX 导出与 Markdown 工具栏设计
状态:阶段 1 架构已冻结;阶段 2 Pandoc 运行时与许可证方案已冻结。
状态:阶段 13 已完成,DOCX 架构、Pandoc 分发及共享协议已经冻结。
## 1. 版本目标
@@ -136,6 +136,19 @@ apps/web/
`packages/docx-engine` 不依赖 Fastify、Electron 或浏览器 UI。Server 和
Desktop 只提供平台运行时、传输与保存能力。
阶段 3 已在 `packages/core/src/docx.ts` 建立以下跨端协议:
- DOCX 请求及 Zod Schema
- 源图片资源;
- 固定 Pandoc 版本和运行时 capability
- HTTP/IPC 共用错误码与错误响应;
- DOCX 结果、媒体诊断和分阶段耗时;
- MIME、扩展名和安全输出文件名。
`packages/application` 提供 `prepareDocxExport()`,统一完成请求校验、
主题查找、图片解析和 Markdown 安全渲染,并返回 DOCX 引擎需要的源请求、
渲染文档、主题清单及 CSS。该方法不调用 Pandoc。
### 4.2 转换流程
1. 校验 Markdown、主题和导出配置;
+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"
})
);
});
});
+13
View File
@@ -9,12 +9,14 @@
src/
document.ts Markdown 文档、分页载荷、结果与耗时模型
document-link.ts 跨端链接分类与 PDF 本地链接编码协议
docx.ts DOCX 请求、能力、错误、结果与文件名协议
export-config.ts 纸张、边距、页眉页脚、页码与图表配置
theme.ts 主题清单、主题能力与 CSS 载荷模型
index.ts 公共导出入口
tests/
document.test.ts
document-link.test.ts
docx.test.ts
export-config.test.ts
```
@@ -23,13 +25,20 @@ tests/
```ts
import {
classifyDocumentLink,
createDocxFileName,
createPagedDocumentPayload,
defaultExportConfig,
docxExportRequestSchema,
exportConfigSchema
} from "@md-to-pdf/core";
const link = classifyDocumentLink("../docs/example.md");
const config = exportConfigSchema.parse(defaultExportConfig);
const docxRequest = docxExportRequestSchema.parse({
markdown: "# Example",
exportConfig: config
});
const docxFileName = createDocxFileName("example.md");
const payload = createPagedDocumentPayload({
document,
fileName: "example.md",
@@ -38,6 +47,10 @@ const payload = createPagedDocumentPayload({
});
```
DOCX 协议固定 Pandoc `3.9.0.2`,并统一定义跨 HTTP/IPC 使用的请求、
capability、错误码、结果、诊断和耗时类型。该包只描述数据协议,不启动
Pandoc 或读写临时文件。
`classifyDocumentLink()` 只负责稳定分类,不执行平台动作。Web 根据分类
处理锚点和网络链接;Desktop 决定是否调用浏览器、系统程序或打开新的
Markdown 窗口。精确 PDF 使用保留的 `.invalid` 地址暂存本地链接,
+149
View File
@@ -0,0 +1,149 @@
import { z } from "zod";
import { exportConfigSchema } from "./export-config.js";
export const DOCX_PANDOC_VERSION = "3.9.0.2";
export const DOCX_MIME_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
export const DOCX_FILE_EXTENSION = ".docx";
export const MAXIMUM_DOCX_MARKDOWN_LENGTH = 1_500_000;
export const MAXIMUM_DOCX_FILE_NAME_LENGTH = 500;
export const MAXIMUM_DOCX_RESOURCE_COUNT = 50;
export const documentExportFormatSchema = z.enum(["pdf", "docx"]);
export type DocumentExportFormat = z.infer<
typeof documentExportFormatSchema
>;
export const docxSourceResourceSchema = z.object({
path: z.string().min(1).max(1_000),
contentType: z.string().min(1).max(100).optional(),
data: z.string().min(1)
});
export type DocxSourceResource = z.infer<
typeof docxSourceResourceSchema
>;
export const docxExportRequestSchema = z.object({
markdown: z.string().max(MAXIMUM_DOCX_MARKDOWN_LENGTH),
fileName: z
.string()
.max(MAXIMUM_DOCX_FILE_NAME_LENGTH)
.default("文档.md"),
language: z.string().max(50).default("zh-CN"),
resources: z
.array(docxSourceResourceSchema)
.max(MAXIMUM_DOCX_RESOURCE_COUNT)
.default([]),
exportConfig: exportConfigSchema
});
export type DocxExportRequest = z.infer<
typeof docxExportRequestSchema
>;
export type DocxExportRequestInput = z.input<
typeof docxExportRequestSchema
>;
export const docxRuntimeStatusSchema = z.enum([
"available",
"not-found",
"version-mismatch",
"not-executable",
"probe-timeout"
]);
export type DocxRuntimeStatus = z.infer<
typeof docxRuntimeStatusSchema
>;
const availableDocxCapabilitySchema = z.object({
format: z.literal("docx"),
status: z.literal("available"),
expectedVersion: z.literal(DOCX_PANDOC_VERSION),
detectedVersion: z.literal(DOCX_PANDOC_VERSION)
});
const unavailableDocxCapabilitySchema = z.object({
format: z.literal("docx"),
status: docxRuntimeStatusSchema.exclude(["available"]),
expectedVersion: z.literal(DOCX_PANDOC_VERSION),
detectedVersion: z.string().max(100).optional(),
message: z.string().max(500)
});
export const docxCapabilitySchema = z.discriminatedUnion("status", [
availableDocxCapabilitySchema,
unavailableDocxCapabilitySchema
]);
export type DocxCapability = z.infer<typeof docxCapabilitySchema>;
export const docxExportErrorCodeSchema = z.enum([
"INVALID_DOCX_REQUEST",
"INVALID_EXPORT_CONFIG",
"INVALID_FILE_NAME",
"INVALID_MARKDOWN",
"MARKDOWN_TOO_LARGE",
"INVALID_IMAGE_RESOURCES",
"IMAGE_RESOURCES_TOO_LARGE",
"THEME_NOT_FOUND",
"DOCX_RUNTIME_NOT_FOUND",
"DOCX_RUNTIME_VERSION_MISMATCH",
"DOCX_RUNTIME_NOT_EXECUTABLE",
"DOCX_RUNTIME_PROBE_TIMEOUT",
"DOCX_QUEUE_FULL",
"DOCX_RENDER_TIMEOUT",
"DOCX_GENERATION_FAILED",
"DOCX_OUTPUT_INVALID"
]);
export type DocxExportErrorCode = z.infer<
typeof docxExportErrorCodeSchema
>;
export const docxExportErrorResponseSchema = z.object({
error: docxExportErrorCodeSchema,
message: z.string().min(1).max(1_000),
retryable: z.boolean().default(false)
});
export type DocxExportErrorResponse = z.infer<
typeof docxExportErrorResponseSchema
>;
export interface DocxGenerationTimings {
probeMs: number;
prepareMs: number;
mediaMs: number;
referenceMs: number;
pandocMs: number;
validationMs: number;
totalMs: number;
}
export interface DocxExportDiagnostics {
warnings: string[];
echartsErrors: string[];
mermaidErrors: string[];
}
export interface DocxExportResult {
docx: Uint8Array;
fileName: string;
diagnostics: DocxExportDiagnostics;
timings: DocxGenerationTimings;
}
export function createDocxFileName(fileName: unknown) {
const source =
typeof fileName === "string"
? fileName.split(/[\\/]/).at(-1) ?? ""
: "";
const stem = source
.replace(/\.(?:md|markdown|docx)$/i, "")
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/[.\s]+$/g, "")
.trim();
return `${stem || "文档"}${DOCX_FILE_EXTENSION}`;
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./document.js";
export * from "./docx.js";
export * from "./document-profile.js";
export * from "./document-link.js";
export * from "./export-config.js";
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
DOCX_MIME_TYPE,
DOCX_PANDOC_VERSION,
MAXIMUM_DOCX_MARKDOWN_LENGTH,
createDocxFileName,
defaultExportConfig,
docxCapabilitySchema,
docxExportErrorResponseSchema,
docxExportRequestSchema
} from "../src/index.js";
describe("DOCX 共享协议", () => {
it("解析跨端导出请求并补齐稳定默认值", () => {
expect(
docxExportRequestSchema.parse({
markdown: "# 文档",
exportConfig: defaultExportConfig
})
).toEqual({
markdown: "# 文档",
fileName: "文档.md",
language: "zh-CN",
resources: [],
exportConfig: defaultExportConfig
});
});
it("校验导出配置与请求资源结构", () => {
expect(
docxExportRequestSchema.safeParse({
markdown: "# 文档",
exportConfig: {}
}).success
).toBe(false);
expect(
docxExportRequestSchema.safeParse({
markdown: "# 文档",
resources: [{ path: "", data: "" }],
exportConfig: defaultExportConfig
}).success
).toBe(false);
});
it("拒绝超过共享协议上限的 Markdown", () => {
expect(
docxExportRequestSchema.safeParse({
markdown: "x".repeat(MAXIMUM_DOCX_MARKDOWN_LENGTH + 1),
exportConfig: defaultExportConfig
}).success
).toBe(false);
});
it("生成跨平台安全的 DOCX 文件名", () => {
expect(createDocxFileName("C:\\资料\\项目报告.markdown")).toBe(
"项目报告.docx"
);
expect(createDocxFileName("../../非法:*?.md")).toBe("非法___.docx");
expect(createDocxFileName(" ")).toBe("文档.docx");
expect(createDocxFileName(undefined)).toBe("文档.docx");
});
it("只将精确版本识别为可用能力", () => {
expect(
docxCapabilitySchema.parse({
format: "docx",
status: "available",
expectedVersion: DOCX_PANDOC_VERSION,
detectedVersion: DOCX_PANDOC_VERSION
})
).toEqual({
format: "docx",
status: "available",
expectedVersion: "3.9.0.2",
detectedVersion: "3.9.0.2"
});
expect(
docxCapabilitySchema.safeParse({
format: "docx",
status: "available",
expectedVersion: DOCX_PANDOC_VERSION,
detectedVersion: "3.8"
}).success
).toBe(false);
expect(
docxCapabilitySchema.safeParse({
format: "docx",
status: "version-mismatch",
expectedVersion: DOCX_PANDOC_VERSION,
detectedVersion: "3.8",
message: "Pandoc 版本不匹配"
}).success
).toBe(true);
});
it("暴露标准 DOCX MIME", () => {
expect(DOCX_MIME_TYPE).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
});
it("提供可跨 HTTP 与 IPC 传递的错误响应", () => {
expect(
docxExportErrorResponseSchema.parse({
error: "DOCX_RUNTIME_NOT_FOUND",
message: "未找到 Pandoc"
})
).toEqual({
error: "DOCX_RUNTIME_NOT_FOUND",
message: "未找到 Pandoc",
retryable: false
});
});
});