feat: 建立 DOCX 共享协议与准备服务
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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` 地址暂存本地链接,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user