From f7d4efeafcf17c1daea6da7e41ed5c0bc7097491 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 30 Jul 2026 11:04:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=20DOCX=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E5=8D=8F=E8=AE=AE=E4=B8=8E=E5=87=86=E5=A4=87=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 8 +- docs/V0.6.0_DESIGN.md | 15 +- packages/application/README.md | 5 + .../application/src/application-service.ts | 97 ++++++++++++ packages/application/src/index.ts | 3 +- .../tests/application-service.test.ts | 110 +++++++++++++ packages/core/README.md | 13 ++ packages/core/src/docx.ts | 149 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/tests/docx.test.ts | 114 ++++++++++++++ 10 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/docx.ts create mode 100644 packages/core/tests/docx.test.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 32233c3..3697360 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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 导出交互; diff --git a/docs/V0.6.0_DESIGN.md b/docs/V0.6.0_DESIGN.md index 738b64a..213cba4 100644 --- a/docs/V0.6.0_DESIGN.md +++ b/docs/V0.6.0_DESIGN.md @@ -1,6 +1,6 @@ # v0.6.0 DOCX 导出与 Markdown 工具栏设计 -状态:阶段 1 架构已冻结;阶段 2 Pandoc 运行时与许可证方案已冻结。 +状态:阶段 1~3 已完成,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、主题和导出配置; diff --git a/packages/application/README.md b/packages/application/README.md index aa60643..3ae02e8 100644 --- a/packages/application/README.md +++ b/packages/application/README.md @@ -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 冲突时以内置主题为准。 diff --git a/packages/application/src/application-service.ts b/packages/application/src/application-service.ts index ad84969..0da7b9b 100644 --- a/packages/application/src/application-service.ts +++ b/packages/application/src/application-service.ts @@ -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 { + 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 }; } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 7eaf126..1243fbc 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -4,7 +4,8 @@ export { createApplicationService, type ApplicationService, type ApplicationServiceOptions, - type MarkdownRenderRequest + type MarkdownRenderRequest, + type PreparedDocxExport } from "./application-service.js"; export { MAXIMUM_IMAGE_BYTES, diff --git a/packages/application/tests/application-service.test.ts b/packages/application/tests/application-service.test.ts index 3fb32aa..d00a6d5 100644 --- a/packages/application/tests/application-service.test.ts +++ b/packages/application/tests/application-service.test.ts @@ -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({ + statusCode: 400, + code: "INVALID_EXPORT_CONFIG" + }) + ); + + await expect( + service.prepareDocxExport({ + markdown: "# 文档", + exportConfig: { + ...defaultExportConfig, + themeId: "missing-theme" + } + }) + ).rejects.toEqual( + expect.objectContaining({ + statusCode: 404, + code: "THEME_NOT_FOUND" + }) + ); + + await expect( + service.prepareDocxExport({ + markdown: 42, + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }) + ).rejects.toEqual( + expect.objectContaining({ + 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({ + statusCode: 413, + code: "MARKDOWN_TOO_LARGE" + }) + ); + + await expect( + service.prepareDocxExport({ + markdown: "# 文档", + resources: [{ path: "", data: "" }], + exportConfig: { + ...defaultExportConfig, + themeId: "test-theme" + } + }) + ).rejects.toEqual( + expect.objectContaining({ + statusCode: 400, + code: "INVALID_IMAGE_RESOURCES" + }) + ); + }); }); diff --git a/packages/core/README.md b/packages/core/README.md index 3143684..46fc055 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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` 地址暂存本地链接, diff --git a/packages/core/src/docx.ts b/packages/core/src/docx.ts new file mode 100644 index 0000000..5f9564f --- /dev/null +++ b/packages/core/src/docx.ts @@ -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; + +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}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36ea84c..d1c19d7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/tests/docx.test.ts b/packages/core/tests/docx.test.ts new file mode 100644 index 0000000..dfd5fc5 --- /dev/null +++ b/packages/core/tests/docx.test.ts @@ -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 + }); + }); +});