feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -23,12 +23,15 @@ function createRenderScript(request: DocxMediaCaptureRequest) {
|
|||||||
|
|
||||||
export async function captureDocxMediaWithElectronWebContents(
|
export async function captureDocxMediaWithElectronWebContents(
|
||||||
webContents: WebContents,
|
webContents: WebContents,
|
||||||
request: DocxMediaCaptureRequest
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
) {
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const plan = (await webContents.executeJavaScript(
|
const plan = (await webContents.executeJavaScript(
|
||||||
createRenderScript(request),
|
createRenderScript(request),
|
||||||
true
|
true
|
||||||
)) as DocxMediaCapturePlan;
|
)) as DocxMediaCapturePlan;
|
||||||
|
signal?.throwIfAborted();
|
||||||
const ownsDebugger = !webContents.debugger.isAttached();
|
const ownsDebugger = !webContents.debugger.isAttached();
|
||||||
if (ownsDebugger) {
|
if (ownsDebugger) {
|
||||||
webContents.debugger.attach("1.3");
|
webContents.debugger.attach("1.3");
|
||||||
@@ -37,6 +40,7 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
try {
|
try {
|
||||||
const captures = [];
|
const captures = [];
|
||||||
for (const target of plan.targets) {
|
for (const target of plan.targets) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const result = (await webContents.debugger.sendCommand(
|
const result = (await webContents.debugger.sendCommand(
|
||||||
"Page.captureScreenshot",
|
"Page.captureScreenshot",
|
||||||
{
|
{
|
||||||
@@ -57,6 +61,7 @@ export async function captureDocxMediaWithElectronWebContents(
|
|||||||
png: Buffer.from(result.data, "base64")
|
png: Buffer.from(result.data, "base64")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
return { plan, captures };
|
return { plan, captures };
|
||||||
} finally {
|
} finally {
|
||||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||||
@@ -70,10 +75,11 @@ export class ElectronDocxMediaCaptureAdapter
|
|||||||
{
|
{
|
||||||
constructor(private readonly webContents: WebContents) {}
|
constructor(private readonly webContents: WebContents) {}
|
||||||
|
|
||||||
capture(request: DocxMediaCaptureRequest) {
|
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||||
return captureDocxMediaWithElectronWebContents(
|
return captureDocxMediaWithElectronWebContents(
|
||||||
this.webContents,
|
this.webContents,
|
||||||
request
|
request,
|
||||||
|
signal
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,17 @@ async function renderCapturePlan(
|
|||||||
|
|
||||||
export async function captureDocxMediaWithPlaywrightPage(
|
export async function captureDocxMediaWithPlaywrightPage(
|
||||||
page: Page,
|
page: Page,
|
||||||
request: DocxMediaCaptureRequest
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
) {
|
) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const plan = await renderCapturePlan(page, request);
|
const plan = await renderCapturePlan(page, request);
|
||||||
|
signal?.throwIfAborted();
|
||||||
const session = await page.context().newCDPSession(page);
|
const session = await page.context().newCDPSession(page);
|
||||||
try {
|
try {
|
||||||
const captures = [];
|
const captures = [];
|
||||||
for (const target of plan.targets) {
|
for (const target of plan.targets) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const result = (await session.send("Page.captureScreenshot", {
|
const result = (await session.send("Page.captureScreenshot", {
|
||||||
format: "png",
|
format: "png",
|
||||||
fromSurface: true,
|
fromSurface: true,
|
||||||
@@ -58,6 +62,7 @@ export async function captureDocxMediaWithPlaywrightPage(
|
|||||||
png: Buffer.from(result.data, "base64")
|
png: Buffer.from(result.data, "base64")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
return { plan, captures };
|
return { plan, captures };
|
||||||
} finally {
|
} finally {
|
||||||
await session.detach();
|
await session.detach();
|
||||||
@@ -69,7 +74,11 @@ export class PlaywrightDocxMediaCaptureAdapter
|
|||||||
{
|
{
|
||||||
constructor(private readonly page: Page) {}
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
capture(request: DocxMediaCaptureRequest) {
|
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||||
return captureDocxMediaWithPlaywrightPage(this.page, request);
|
return captureDocxMediaWithPlaywrightPage(
|
||||||
|
this.page,
|
||||||
|
request,
|
||||||
|
signal
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -106,6 +106,15 @@ Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium,
|
|||||||
配置和元数据。本机真实 Pandoc 验收覆盖公文 A4 和自定义横向两组模板,
|
配置和元数据。本机真实 Pandoc 验收覆盖公文 A4 和自定义横向两组模板,
|
||||||
输出仍由标准 Word 段落、文本 Run、表格和代码文本组成,可继续编辑。
|
输出仍由标准 Word 段落、文本 Run、表格和代码文本组成,可继续编辑。
|
||||||
|
|
||||||
|
`v0.6.0` 阶段 7 已完成 Pandoc 转换服务:运行时按受信任配置、Desktop
|
||||||
|
资源、Docker 固定目录和 PATH 顺序发现并精确校验 3.9.0.2;默认模板和
|
||||||
|
动态模板使用稳定指纹与有界缓存。静态 Lua Filter 在 Pandoc AST 层将
|
||||||
|
普通图片、Mermaid 和 ECharts 替换为高分辨率 PNG,正文、表格、代码和
|
||||||
|
公式继续由 Pandoc 生成可编辑结构。每次转换使用独立临时目录和隔离的
|
||||||
|
data 目录,完成后校验 OOXML 并清理。共享应用服务提供有界并发、排队、
|
||||||
|
总超时、取消、关闭、capability、错误码和完整分阶段耗时;尚未新增
|
||||||
|
HTTP 路由、Desktop IPC 或导出按钮。
|
||||||
|
|
||||||
## 2. 已完成
|
## 2. 已完成
|
||||||
|
|
||||||
### 2.1 项目骨架
|
### 2.1 项目骨架
|
||||||
@@ -985,8 +994,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
||||||
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
||||||
- `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX
|
- `packages/docx-engine` 负责安全读取 Pandoc 默认模板、解析主题 DOCX
|
||||||
样式、生成动态 OOXML 部件和执行结构校验;Pandoc 进程定位、临时目录
|
样式、发现并校验固定 Pandoc、生成动态 OOXML、执行静态 Lua 媒体替换、
|
||||||
与转换超时仍属于后续转换服务。
|
请求级临时转换和最终结构校验。
|
||||||
|
- `packages/application/src/docx-export-service.ts` 负责 DOCX capability、
|
||||||
|
并发排队、总超时、取消、跨端转换编排、错误码和耗时诊断。
|
||||||
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
|
- `apps/desktop/src/desktop-application-controller.ts` 负责 Electron
|
||||||
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
|
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
|
||||||
组装、协议和生命周期。
|
组装、协议和生命周期。
|
||||||
@@ -1022,7 +1033,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
|
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
|
||||||
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
||||||
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
||||||
- 阶段 7~8:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互;
|
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
||||||
|
- 阶段 8:实现 Web/Desktop DOCX 导出交互;
|
||||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
|
|||||||
Generated
+1
@@ -9903,6 +9903,7 @@
|
|||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0"
|
"@md-to-pdf/renderer": "0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+4
-2
@@ -11,19 +11,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
"build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
|
||||||
"build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web",
|
"build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web",
|
||||||
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"",
|
||||||
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
|
||||||
"dev:core": "npm run dev -w @md-to-pdf/core",
|
"dev:core": "npm run dev -w @md-to-pdf/core",
|
||||||
|
"dev:docx-engine": "npm run dev -w @md-to-pdf/docx-engine",
|
||||||
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
|
||||||
"dev:application": "npm run dev -w @md-to-pdf/application",
|
"dev:application": "npm run dev -w @md-to-pdf/application",
|
||||||
"dev:preview-engine": "npm run dev -w @md-to-pdf/preview-engine",
|
"dev:preview-engine": "npm run dev -w @md-to-pdf/preview-engine",
|
||||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||||
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
|
||||||
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
|
||||||
"desktop:package": "npm run package -w @md-to-pdf/desktop",
|
"desktop:package": "npm run package -w @md-to-pdf/desktop",
|
||||||
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
"theme:import-typora": "node scripts/import-typora-theme.mjs",
|
||||||
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
|
"verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine",
|
||||||
|
"verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine",
|
||||||
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
|
||||||
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
# @md-to-pdf/application
|
# @md-to-pdf/application
|
||||||
|
|
||||||
Web Server 与 Electron Desktop 共用的应用服务层,组合
|
Web Server 与 Electron Desktop 共用的应用服务层,组合
|
||||||
`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、
|
`@md-to-pdf/core`、`@md-to-pdf/renderer` 和
|
||||||
主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和
|
`@md-to-pdf/docx-engine`,统一处理 Markdown 渲染、主题发现、主题资源、
|
||||||
平台能力适配。
|
图片资源和 DOCX 导出编排。Fastify 与 Electron IPC 只负责传输和平台
|
||||||
|
能力适配。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
application-service.ts 渲染、主题和资源用例入口
|
application-service.ts 渲染、主题和资源用例入口
|
||||||
|
docx-export-service.ts DOCX 并发、超时、错误和完整导出编排
|
||||||
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
||||||
image-resources.ts 本地、Base64 与受限远程图片处理
|
image-resources.ts 本地、Base64 与受限远程图片处理
|
||||||
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
||||||
@@ -17,6 +19,7 @@ src/
|
|||||||
tests/
|
tests/
|
||||||
application-service.test.ts
|
application-service.test.ts
|
||||||
bundled-themes.test.ts
|
bundled-themes.test.ts
|
||||||
|
docx-export-service.test.ts
|
||||||
docx-media-service.test.ts
|
docx-media-service.test.ts
|
||||||
image-resources.test.ts
|
image-resources.test.ts
|
||||||
```
|
```
|
||||||
@@ -53,6 +56,12 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
|||||||
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
||||||
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
||||||
|
|
||||||
|
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、
|
||||||
|
Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒;
|
||||||
|
可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS`
|
||||||
|
配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议
|
||||||
|
返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。
|
||||||
|
|
||||||
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||||
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
||||||
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
"@md-to-pdf/docx-engine": "0.1.0",
|
||||||
"@md-to-pdf/renderer": "0.1.0"
|
"@md-to-pdf/renderer": "0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import {
|
||||||
|
createDocxFileName,
|
||||||
|
type DocxCapability,
|
||||||
|
type DocxExportErrorCode,
|
||||||
|
type DocxExportResult
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
PandocDocxConversionError,
|
||||||
|
type PandocDocxConversionInput,
|
||||||
|
type PandocDocxConversionResult,
|
||||||
|
type PandocRuntimeResolution
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
|
import {
|
||||||
|
ApplicationRequestError,
|
||||||
|
type ApplicationService,
|
||||||
|
type PreparedDocxExport
|
||||||
|
} from "./application-service.js";
|
||||||
|
import {
|
||||||
|
prepareDocxMedia,
|
||||||
|
type DocxMediaCaptureAdapter
|
||||||
|
} from "./docx-media-service.js";
|
||||||
|
import type { ImageResolutionContext } from "./image-resources.js";
|
||||||
|
|
||||||
|
const DEFAULT_DOCX_CONCURRENCY = 1;
|
||||||
|
const DEFAULT_DOCX_MAX_QUEUE = 4;
|
||||||
|
const DEFAULT_DOCX_TIMEOUT_MS = 90_000;
|
||||||
|
|
||||||
|
export interface DocxExportRuntimeLimits {
|
||||||
|
concurrency: number;
|
||||||
|
maxQueue: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxCapabilityProvider {
|
||||||
|
probe(): Promise<PandocRuntimeResolution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxConversionPort {
|
||||||
|
convert(
|
||||||
|
input: PandocDocxConversionInput,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PandocDocxConversionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxExportServiceOptions {
|
||||||
|
application: Pick<ApplicationService, "prepareDocxExport">;
|
||||||
|
runtime: DocxCapabilityProvider;
|
||||||
|
converter: DocxConversionPort;
|
||||||
|
limits?: Partial<DocxExportRuntimeLimits>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateDocxOptions {
|
||||||
|
mediaAdapter: DocxMediaCaptureAdapter;
|
||||||
|
imageContext?: ImageResolutionContext;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxExportServiceError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly statusCode: 429 | 500 | 503 | 504,
|
||||||
|
readonly code: DocxExportErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly retryable: boolean,
|
||||||
|
options?: ErrorOptions
|
||||||
|
) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "DocxExportServiceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DocxQueueFullError extends Error {}
|
||||||
|
class DocxServiceClosedError extends Error {}
|
||||||
|
|
||||||
|
interface QueueEntry {
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
signal: AbortSignal;
|
||||||
|
abort: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DocxConcurrencyGate {
|
||||||
|
private active = 0;
|
||||||
|
private closed = false;
|
||||||
|
private readonly queue: QueueEntry[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly limit: number,
|
||||||
|
private readonly maxQueue: number
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run<T>(
|
||||||
|
task: () => Promise<T>,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<T> {
|
||||||
|
await this.acquire(signal);
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
this.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
for (const entry of this.queue.splice(0)) {
|
||||||
|
entry.signal.removeEventListener("abort", entry.abort);
|
||||||
|
entry.reject(new DocxServiceClosedError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private acquire(signal: AbortSignal) {
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.reject(new DocxServiceClosedError());
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
return Promise.reject(signal.reason);
|
||||||
|
}
|
||||||
|
if (this.active < this.limit) {
|
||||||
|
this.active += 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (this.queue.length >= this.maxQueue) {
|
||||||
|
return Promise.reject(new DocxQueueFullError());
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const entry: QueueEntry = {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
signal,
|
||||||
|
abort: () => {
|
||||||
|
const index = this.queue.indexOf(entry);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.queue.splice(index, 1);
|
||||||
|
}
|
||||||
|
reject(signal.reason);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", entry.abort, { once: true });
|
||||||
|
this.queue.push(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private release() {
|
||||||
|
this.active -= 1;
|
||||||
|
while (this.queue.length) {
|
||||||
|
const entry = this.queue.shift()!;
|
||||||
|
entry.signal.removeEventListener("abort", entry.abort);
|
||||||
|
if (entry.signal.aborted) {
|
||||||
|
entry.reject(entry.signal.reason);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.active += 1;
|
||||||
|
entry.resolve();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIntegerEnvironment(
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
name: string,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number
|
||||||
|
) {
|
||||||
|
const raw = environment[name];
|
||||||
|
if (raw === undefined || raw.trim() === "") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (!/^\d+$/u.test(raw)) {
|
||||||
|
throw new Error(`${name} 必须是整数`);
|
||||||
|
}
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||||
|
throw new Error(`${name} 不能小于 ${minimum}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDocxExportRuntimeLimits(
|
||||||
|
environment: NodeJS.ProcessEnv = process.env
|
||||||
|
): DocxExportRuntimeLimits {
|
||||||
|
return {
|
||||||
|
concurrency: readIntegerEnvironment(
|
||||||
|
environment,
|
||||||
|
"DOCX_CONCURRENCY",
|
||||||
|
DEFAULT_DOCX_CONCURRENCY,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
maxQueue: readIntegerEnvironment(
|
||||||
|
environment,
|
||||||
|
"DOCX_MAX_QUEUE",
|
||||||
|
DEFAULT_DOCX_MAX_QUEUE,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
timeoutMs: readIntegerEnvironment(
|
||||||
|
environment,
|
||||||
|
"DOCX_TIMEOUT_MS",
|
||||||
|
DEFAULT_DOCX_TIMEOUT_MS,
|
||||||
|
1_000
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLimits(
|
||||||
|
values: Partial<DocxExportRuntimeLimits> = {}
|
||||||
|
): DocxExportRuntimeLimits {
|
||||||
|
const limits = {
|
||||||
|
concurrency: values.concurrency ?? DEFAULT_DOCX_CONCURRENCY,
|
||||||
|
maxQueue: values.maxQueue ?? DEFAULT_DOCX_MAX_QUEUE,
|
||||||
|
timeoutMs: values.timeoutMs ?? DEFAULT_DOCX_TIMEOUT_MS
|
||||||
|
};
|
||||||
|
if (!Number.isInteger(limits.concurrency) || limits.concurrency < 1) {
|
||||||
|
throw new Error("DOCX 并发数必须是正整数");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(limits.maxQueue) || limits.maxQueue < 0) {
|
||||||
|
throw new Error("DOCX 排队上限必须是非负整数");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(limits.timeoutMs) ||
|
||||||
|
limits.timeoutMs < 10 ||
|
||||||
|
limits.timeoutMs > 10 * 60_000
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX 总超时必须在 10ms 到 10 分钟之间");
|
||||||
|
}
|
||||||
|
return limits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capabilityError(capability: Exclude<
|
||||||
|
DocxCapability,
|
||||||
|
{ status: "available" }
|
||||||
|
>) {
|
||||||
|
const mapping = {
|
||||||
|
"not-found": "DOCX_RUNTIME_NOT_FOUND",
|
||||||
|
"version-mismatch": "DOCX_RUNTIME_VERSION_MISMATCH",
|
||||||
|
"not-executable": "DOCX_RUNTIME_NOT_EXECUTABLE",
|
||||||
|
"probe-timeout": "DOCX_RUNTIME_PROBE_TIMEOUT"
|
||||||
|
} as const;
|
||||||
|
return new DocxExportServiceError(
|
||||||
|
capability.status === "probe-timeout" ? 504 : 503,
|
||||||
|
mapping[capability.status],
|
||||||
|
capability.message,
|
||||||
|
capability.status === "probe-timeout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversionError(error: PandocDocxConversionError) {
|
||||||
|
const statusCode =
|
||||||
|
error.code === "DOCX_RENDER_TIMEOUT"
|
||||||
|
? 504
|
||||||
|
: error.code === "DOCX_RUNTIME_NOT_FOUND"
|
||||||
|
? 503
|
||||||
|
: 500;
|
||||||
|
return new DocxExportServiceError(
|
||||||
|
statusCode,
|
||||||
|
error.code,
|
||||||
|
error.message,
|
||||||
|
error.code === "DOCX_RENDER_TIMEOUT",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxExportService {
|
||||||
|
private readonly limits: DocxExportRuntimeLimits;
|
||||||
|
private readonly gate: DocxConcurrencyGate;
|
||||||
|
private readonly controllers = new Set<AbortController>();
|
||||||
|
private readonly jobs = new Set<Promise<unknown>>();
|
||||||
|
private closePromise: Promise<void> | undefined;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: DocxExportServiceOptions) {
|
||||||
|
this.limits = resolveLimits(options.limits);
|
||||||
|
this.gate = new DocxConcurrencyGate(
|
||||||
|
this.limits.concurrency,
|
||||||
|
this.limits.maxQueue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCapability() {
|
||||||
|
return (await this.options.runtime.probe()).capability;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(
|
||||||
|
request: unknown,
|
||||||
|
options: GenerateDocxOptions
|
||||||
|
): Promise<DocxExportResult> {
|
||||||
|
if (this.closed) {
|
||||||
|
return Promise.reject(
|
||||||
|
new DocxExportServiceError(
|
||||||
|
503,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出服务已关闭",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const job = this.runGeneration(request, options);
|
||||||
|
this.jobs.add(job);
|
||||||
|
void job.finally(() => this.jobs.delete(job)).catch(() => undefined);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closePromise ??= this.closeInternal();
|
||||||
|
return this.closePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeInternal() {
|
||||||
|
this.closed = true;
|
||||||
|
this.gate.close();
|
||||||
|
for (const controller of this.controllers) {
|
||||||
|
controller.abort(new DocxServiceClosedError());
|
||||||
|
}
|
||||||
|
await Promise.allSettled([...this.jobs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runGeneration(
|
||||||
|
request: unknown,
|
||||||
|
options: GenerateDocxOptions
|
||||||
|
) {
|
||||||
|
const totalStarted = performance.now();
|
||||||
|
const queuedAt = performance.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.controllers.add(controller);
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
controller.abort(new Error("DOCX 总超时"));
|
||||||
|
}, this.limits.timeoutMs);
|
||||||
|
timeout.unref();
|
||||||
|
const abortFromCaller = () =>
|
||||||
|
controller.abort(options.signal?.reason);
|
||||||
|
options.signal?.addEventListener("abort", abortFromCaller, {
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
abortFromCaller();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.gate.run(async () => {
|
||||||
|
const queueMs = performance.now() - queuedAt;
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
|
const probeStarted = performance.now();
|
||||||
|
const resolution = await this.options.runtime.probe();
|
||||||
|
const probeMs = performance.now() - probeStarted;
|
||||||
|
if (resolution.capability.status !== "available") {
|
||||||
|
throw capabilityError(resolution.capability);
|
||||||
|
}
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
|
const prepareStarted = performance.now();
|
||||||
|
const prepared =
|
||||||
|
await this.options.application.prepareDocxExport(
|
||||||
|
request,
|
||||||
|
options.imageContext ?? {}
|
||||||
|
);
|
||||||
|
const prepareMs = performance.now() - prepareStarted;
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
|
const mediaStarted = performance.now();
|
||||||
|
const media = await prepareDocxMedia(
|
||||||
|
prepared,
|
||||||
|
options.mediaAdapter,
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
const mediaMs = performance.now() - mediaStarted;
|
||||||
|
controller.signal.throwIfAborted();
|
||||||
|
|
||||||
|
const conversion = await this.options.converter.convert(
|
||||||
|
this.createConversionInput(prepared, media),
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
docx: conversion.docx,
|
||||||
|
fileName: createDocxFileName(prepared.request.fileName),
|
||||||
|
diagnostics: {
|
||||||
|
warnings: media.warnings,
|
||||||
|
echartsErrors: media.echartsErrors,
|
||||||
|
mermaidErrors: media.mermaidErrors
|
||||||
|
},
|
||||||
|
timings: {
|
||||||
|
queueMs,
|
||||||
|
probeMs,
|
||||||
|
prepareMs,
|
||||||
|
mediaMs,
|
||||||
|
referenceMs: conversion.timings.referenceMs,
|
||||||
|
pandocMs: conversion.timings.pandocMs,
|
||||||
|
validationMs: conversion.timings.validationMs,
|
||||||
|
totalMs: performance.now() - totalStarted
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, controller.signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof ApplicationRequestError ||
|
||||||
|
error instanceof DocxExportServiceError
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error instanceof PandocDocxConversionError) {
|
||||||
|
throw conversionError(error);
|
||||||
|
}
|
||||||
|
if (error instanceof DocxQueueFullError) {
|
||||||
|
throw new DocxExportServiceError(
|
||||||
|
429,
|
||||||
|
"DOCX_QUEUE_FULL",
|
||||||
|
"DOCX 生成队列已满",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof DocxServiceClosedError) {
|
||||||
|
throw new DocxExportServiceError(
|
||||||
|
503,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出服务已关闭",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
throw new DocxExportServiceError(
|
||||||
|
504,
|
||||||
|
"DOCX_RENDER_TIMEOUT",
|
||||||
|
timedOut ? "DOCX 导出超过总超时" : "DOCX 导出已取消",
|
||||||
|
timedOut,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new DocxExportServiceError(
|
||||||
|
500,
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 导出失败",
|
||||||
|
false,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
options.signal?.removeEventListener(
|
||||||
|
"abort",
|
||||||
|
abortFromCaller
|
||||||
|
);
|
||||||
|
this.controllers.delete(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createConversionInput(
|
||||||
|
prepared: PreparedDocxExport,
|
||||||
|
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
||||||
|
): PandocDocxConversionInput {
|
||||||
|
return {
|
||||||
|
markdown: prepared.request.markdown,
|
||||||
|
fileName: prepared.request.fileName,
|
||||||
|
language: prepared.request.language,
|
||||||
|
exportConfig: prepared.request.exportConfig,
|
||||||
|
theme: prepared.theme.manifest,
|
||||||
|
metadata: prepared.document.metadata,
|
||||||
|
media
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ export interface DocxMediaCaptureOutput {
|
|||||||
|
|
||||||
export interface DocxMediaCaptureAdapter {
|
export interface DocxMediaCaptureAdapter {
|
||||||
capture(
|
capture(
|
||||||
request: DocxMediaCaptureRequest
|
request: DocxMediaCaptureRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
): Promise<DocxMediaCaptureOutput>;
|
): Promise<DocxMediaCaptureOutput>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,10 +162,13 @@ function validateCaptures(
|
|||||||
|
|
||||||
export async function prepareDocxMedia(
|
export async function prepareDocxMedia(
|
||||||
prepared: PreparedDocxExport,
|
prepared: PreparedDocxExport,
|
||||||
adapter: DocxMediaCaptureAdapter
|
adapter: DocxMediaCaptureAdapter,
|
||||||
|
signal?: AbortSignal
|
||||||
): Promise<PreparedDocxMedia> {
|
): Promise<PreparedDocxMedia> {
|
||||||
|
signal?.throwIfAborted();
|
||||||
const dimensions = getDocxMediaRenderDimensions(prepared);
|
const dimensions = getDocxMediaRenderDimensions(prepared);
|
||||||
const output = await adapter.capture({
|
const output = await adapter.capture(
|
||||||
|
{
|
||||||
payload: createPagedDocumentPayload({
|
payload: createPagedDocumentPayload({
|
||||||
document: prepared.document,
|
document: prepared.document,
|
||||||
fileName: prepared.request.fileName,
|
fileName: prepared.request.fileName,
|
||||||
@@ -172,7 +176,10 @@ export async function prepareDocxMedia(
|
|||||||
exportConfig: prepared.request.exportConfig
|
exportConfig: prepared.request.exportConfig
|
||||||
}),
|
}),
|
||||||
dimensions
|
dimensions
|
||||||
});
|
},
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
|
const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
|
||||||
if (!parsedPlan.success) {
|
if (!parsedPlan.success) {
|
||||||
throw new Error("DOCX 媒体捕获计划无效");
|
throw new Error("DOCX 媒体捕获计划无效");
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export {
|
|||||||
type DocxMediaCaptureRequest,
|
type DocxMediaCaptureRequest,
|
||||||
type DocxMediaRenderDimensions
|
type DocxMediaRenderDimensions
|
||||||
} from "./docx-media-service.js";
|
} from "./docx-media-service.js";
|
||||||
|
export {
|
||||||
|
DocxExportService,
|
||||||
|
DocxExportServiceError,
|
||||||
|
readDocxExportRuntimeLimits,
|
||||||
|
type DocxCapabilityProvider,
|
||||||
|
type DocxConversionPort,
|
||||||
|
type DocxExportRuntimeLimits,
|
||||||
|
type DocxExportServiceOptions,
|
||||||
|
type GenerateDocxOptions
|
||||||
|
} from "./docx-export-service.js";
|
||||||
export {
|
export {
|
||||||
createThemeRegistry,
|
createThemeRegistry,
|
||||||
type ThemeRecord,
|
type ThemeRecord,
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
|
type DocxCapability,
|
||||||
|
type PreparedDocxMedia,
|
||||||
|
type ThemeManifest
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import type {
|
||||||
|
PandocDocxConversionInput,
|
||||||
|
PandocDocxConversionResult,
|
||||||
|
PandocRuntimeResolution
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
|
import {
|
||||||
|
DocxExportService,
|
||||||
|
readDocxExportRuntimeLimits,
|
||||||
|
type DocxMediaCaptureAdapter,
|
||||||
|
type PreparedDocxExport
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
function theme(): ThemeManifest {
|
||||||
|
return {
|
||||||
|
manifestVersion: 1,
|
||||||
|
id: "test-theme",
|
||||||
|
name: "测试主题",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "测试",
|
||||||
|
author: "测试",
|
||||||
|
license: "内部许可",
|
||||||
|
entry: "theme.css",
|
||||||
|
domPreset: "generic",
|
||||||
|
defaultFontSize: "16px",
|
||||||
|
supportedFeatures: [],
|
||||||
|
category: "general",
|
||||||
|
compatibleProfiles: [],
|
||||||
|
docxStyle: { preset: "technical" },
|
||||||
|
bundled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared: PreparedDocxExport = {
|
||||||
|
request: {
|
||||||
|
markdown: "# 测试",
|
||||||
|
fileName: "报告?.md",
|
||||||
|
language: "zh-CN",
|
||||||
|
resources: [],
|
||||||
|
exportConfig: {
|
||||||
|
...defaultExportConfig,
|
||||||
|
themeId: "test-theme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
rendererVersion: 1,
|
||||||
|
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||||
|
bodyHtml: "<h1>测试</h1>",
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "测试人",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
features: [],
|
||||||
|
warnings: []
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
manifest: theme(),
|
||||||
|
source: "bundled",
|
||||||
|
css: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableCapability: DocxCapability = {
|
||||||
|
format: "docx",
|
||||||
|
status: "available",
|
||||||
|
expectedVersion: "3.9.0.2",
|
||||||
|
detectedVersion: "3.9.0.2"
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolution(
|
||||||
|
capability: DocxCapability = availableCapability
|
||||||
|
): PandocRuntimeResolution {
|
||||||
|
return capability.status === "available"
|
||||||
|
? { capability, executablePath: "pandoc" }
|
||||||
|
: { capability };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyAdapter: DocxMediaCaptureAdapter = {
|
||||||
|
async capture() {
|
||||||
|
return {
|
||||||
|
plan: {
|
||||||
|
targets: [],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
},
|
||||||
|
captures: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function conversionResult(): PandocDocxConversionResult {
|
||||||
|
return {
|
||||||
|
docx: new Uint8Array([1, 2, 3]),
|
||||||
|
templateFingerprint: "a".repeat(64),
|
||||||
|
templateCacheKey: "b".repeat(64),
|
||||||
|
validation: {
|
||||||
|
partCount: 10,
|
||||||
|
xmlPartCount: 9,
|
||||||
|
relationshipCount: 3,
|
||||||
|
headerCount: 0,
|
||||||
|
footerCount: 1
|
||||||
|
},
|
||||||
|
timings: {
|
||||||
|
referenceMs: 3,
|
||||||
|
pandocMs: 4,
|
||||||
|
validationMs: 5
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(options: {
|
||||||
|
convert?: (
|
||||||
|
input: PandocDocxConversionInput,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) => Promise<PandocDocxConversionResult>;
|
||||||
|
capability?: DocxCapability;
|
||||||
|
limits?: {
|
||||||
|
concurrency: number;
|
||||||
|
maxQueue: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return new DocxExportService({
|
||||||
|
application: {
|
||||||
|
prepareDocxExport: vi.fn().mockResolvedValue(prepared)
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
probe: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(resolution(options.capability))
|
||||||
|
},
|
||||||
|
converter: {
|
||||||
|
convert:
|
||||||
|
options.convert ??
|
||||||
|
vi.fn().mockResolvedValue(conversionResult())
|
||||||
|
},
|
||||||
|
limits: options.limits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DOCX 共享导出服务", () => {
|
||||||
|
it("串联准备、媒体和转换并返回完整耗时", async () => {
|
||||||
|
const service = createService({});
|
||||||
|
const result = await service.generate(
|
||||||
|
{ markdown: "# 测试" },
|
||||||
|
{ mediaAdapter: emptyAdapter }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.docx).toEqual(new Uint8Array([1, 2, 3]));
|
||||||
|
expect(result.fileName).toBe("报告_.docx");
|
||||||
|
expect(result.diagnostics).toEqual({
|
||||||
|
warnings: [],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: []
|
||||||
|
});
|
||||||
|
expect(result.timings).toMatchObject({
|
||||||
|
referenceMs: 3,
|
||||||
|
pandocMs: 4,
|
||||||
|
validationMs: 5
|
||||||
|
});
|
||||||
|
for (const value of Object.values(result.timings)) {
|
||||||
|
expect(value).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
await service.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("限制并发和排队长度", async () => {
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
const firstBlocked = new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
});
|
||||||
|
let active = 0;
|
||||||
|
let maximumActive = 0;
|
||||||
|
let calls = 0;
|
||||||
|
const service = createService({
|
||||||
|
limits: {
|
||||||
|
concurrency: 1,
|
||||||
|
maxQueue: 1,
|
||||||
|
timeoutMs: 5_000
|
||||||
|
},
|
||||||
|
convert: async () => {
|
||||||
|
calls += 1;
|
||||||
|
active += 1;
|
||||||
|
maximumActive = Math.max(maximumActive, active);
|
||||||
|
if (calls === 1) {
|
||||||
|
await firstBlocked;
|
||||||
|
}
|
||||||
|
active -= 1;
|
||||||
|
return conversionResult();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = service.generate({}, { mediaAdapter: emptyAdapter });
|
||||||
|
await vi.waitFor(() => expect(calls).toBe(1));
|
||||||
|
const second = service.generate({}, { mediaAdapter: emptyAdapter });
|
||||||
|
const third = service.generate({}, { mediaAdapter: emptyAdapter });
|
||||||
|
await expect(third).rejects.toMatchObject({
|
||||||
|
code: "DOCX_QUEUE_FULL",
|
||||||
|
retryable: true
|
||||||
|
});
|
||||||
|
releaseFirst();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(maximumActive).toBe(1);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
await service.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("总超时会传播取消信号并释放任务", async () => {
|
||||||
|
const abortAwareAdapter: DocxMediaCaptureAdapter = {
|
||||||
|
capture(_request, signal) {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => reject(signal.reason),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const service = createService({
|
||||||
|
limits: {
|
||||||
|
concurrency: 1,
|
||||||
|
maxQueue: 0,
|
||||||
|
timeoutMs: 30
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.generate({}, { mediaAdapter: abortAwareAdapter })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "DOCX_RENDER_TIMEOUT",
|
||||||
|
retryable: true
|
||||||
|
});
|
||||||
|
await service.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("映射 capability 错误并解析运行限制", async () => {
|
||||||
|
const unavailable: DocxCapability = {
|
||||||
|
format: "docx",
|
||||||
|
status: "version-mismatch",
|
||||||
|
expectedVersion: "3.9.0.2",
|
||||||
|
detectedVersion: "3.9.0.1",
|
||||||
|
message: "版本不匹配"
|
||||||
|
};
|
||||||
|
const service = createService({ capability: unavailable });
|
||||||
|
await expect(
|
||||||
|
service.generate({}, { mediaAdapter: emptyAdapter })
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 503,
|
||||||
|
code: "DOCX_RUNTIME_VERSION_MISMATCH"
|
||||||
|
});
|
||||||
|
await service.close();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readDocxExportRuntimeLimits({
|
||||||
|
DOCX_CONCURRENCY: "2",
|
||||||
|
DOCX_MAX_QUEUE: "6",
|
||||||
|
DOCX_TIMEOUT_MS: "120000"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
concurrency: 2,
|
||||||
|
maxQueue: 6,
|
||||||
|
timeoutMs: 120_000
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
readDocxExportRuntimeLimits({ DOCX_CONCURRENCY: "0" })
|
||||||
|
).toThrow("不能小于 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export const MAXIMUM_DOCX_MEDIA_EDGE_PIXELS = 4_096;
|
|||||||
export const MAXIMUM_DOCX_MEDIA_PIXELS = 16_000_000;
|
export const MAXIMUM_DOCX_MEDIA_PIXELS = 16_000_000;
|
||||||
export const MAXIMUM_DOCX_MEDIA_BYTES = 8 * 1024 * 1024;
|
export const MAXIMUM_DOCX_MEDIA_BYTES = 8 * 1024 * 1024;
|
||||||
export const MAXIMUM_DOCX_TOTAL_MEDIA_BYTES = 30 * 1024 * 1024;
|
export const MAXIMUM_DOCX_TOTAL_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||||
|
export const MAXIMUM_DOCX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
export const documentExportFormatSchema = z.enum(["pdf", "docx"]);
|
export const documentExportFormatSchema = z.enum(["pdf", "docx"]);
|
||||||
export type DocumentExportFormat = z.infer<
|
export type DocumentExportFormat = z.infer<
|
||||||
@@ -182,6 +183,7 @@ export type DocxExportErrorResponse = z.infer<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export interface DocxGenerationTimings {
|
export interface DocxGenerationTimings {
|
||||||
|
queueMs: number;
|
||||||
probeMs: number;
|
probeMs: number;
|
||||||
prepareMs: number;
|
prepareMs: number;
|
||||||
mediaMs: number;
|
mediaMs: number;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
|
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
|
||||||
版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明
|
版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明
|
||||||
解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、
|
解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、
|
||||||
页眉、页脚和页码均已映射的动态模板。
|
页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter
|
||||||
|
将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown
|
||||||
|
正文、标题、列表、表格、代码和公式的可编辑文档结构。
|
||||||
|
|
||||||
## 设计边界
|
## 设计边界
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@
|
|||||||
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML;
|
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML;
|
||||||
- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖;
|
- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖;
|
||||||
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
|
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
|
||||||
- Pandoc 进程定位、超时和临时目录属于后续转换服务阶段。
|
- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理;
|
||||||
|
- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸;
|
||||||
|
- 并发排队、跨端用例编排和错误协议映射由
|
||||||
|
`@md-to-pdf/application` 的 `DocxExportService` 统一负责。
|
||||||
|
|
||||||
## 安全限制
|
## 安全限制
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@
|
|||||||
- 解压后总大小不超过 64 MiB;
|
- 解压后总大小不超过 64 MiB;
|
||||||
- 拒绝加密、ZIP64、未知压缩方法、重复路径和路径穿越;
|
- 拒绝加密、ZIP64、未知压缩方法、重复路径和路径穿越;
|
||||||
- 必须包含文档、样式、字体、编号、设置、关系和内容类型等关键部件。
|
- 必须包含文档、样式、字体、编号、设置、关系和内容类型等关键部件。
|
||||||
|
- 最终 DOCX 不超过 64 MiB、512 个部件和 128 MiB 解压内容。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
@@ -29,9 +35,10 @@ npm run test -w @md-to-pdf/docx-engine
|
|||||||
npm run typecheck -w @md-to-pdf/docx-engine
|
npm run typecheck -w @md-to-pdf/docx-engine
|
||||||
npm run build -w @md-to-pdf/docx-engine
|
npm run build -w @md-to-pdf/docx-engine
|
||||||
npm run verify:docx-reference
|
npm run verify:docx-reference
|
||||||
|
npm run verify:docx-conversion
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
||||||
`MD_TO_PDF_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
|
`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
|
||||||
分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理,
|
分别验证公文 A4 与自定义横向模板,并在系统临时目录中完成转换和清理,
|
||||||
不会保留用户文档或验收产物。
|
不会保留用户文档或验收产物。
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
local map_path = os.getenv("MD_TO_PDF_DOCX_MEDIA_MAP")
|
||||||
|
|
||||||
|
if map_path == nil or map_path == "" then
|
||||||
|
error("DOCX media map path is missing")
|
||||||
|
end
|
||||||
|
|
||||||
|
local map_file, open_error = io.open(map_path, "rb")
|
||||||
|
if map_file == nil then
|
||||||
|
error("DOCX media map cannot be opened: " .. tostring(open_error))
|
||||||
|
end
|
||||||
|
|
||||||
|
local map_content = map_file:read("*a")
|
||||||
|
map_file:close()
|
||||||
|
|
||||||
|
local media_map = pandoc.json.decode(map_content, false)
|
||||||
|
local counters = {
|
||||||
|
image = 0,
|
||||||
|
mermaid = 0,
|
||||||
|
echarts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local function contains_class(classes, expected)
|
||||||
|
for _, class_name in ipairs(classes) do
|
||||||
|
if class_name == expected then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function next_media(kind)
|
||||||
|
counters[kind] = counters[kind] + 1
|
||||||
|
local media = media_map[kind][counters[kind]]
|
||||||
|
if media == nil then
|
||||||
|
error(
|
||||||
|
"DOCX media map does not contain "
|
||||||
|
.. kind
|
||||||
|
.. " #"
|
||||||
|
.. tostring(counters[kind])
|
||||||
|
)
|
||||||
|
end
|
||||||
|
return media
|
||||||
|
end
|
||||||
|
|
||||||
|
local function media_image(media)
|
||||||
|
return pandoc.Image(
|
||||||
|
pandoc.Inlines { pandoc.Str(media.alt_text) },
|
||||||
|
media.path,
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
width = media.width,
|
||||||
|
height = media.height
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function replace_image(image)
|
||||||
|
local media = next_media("image")
|
||||||
|
image.src = media.path
|
||||||
|
image.caption = pandoc.Inlines { pandoc.Str(media.alt_text) }
|
||||||
|
image.attributes.width = media.width
|
||||||
|
image.attributes.height = media.height
|
||||||
|
return image
|
||||||
|
end
|
||||||
|
|
||||||
|
local function replace_code_block(block)
|
||||||
|
local kind
|
||||||
|
if contains_class(block.classes, "mermaid") then
|
||||||
|
kind = "mermaid"
|
||||||
|
elseif contains_class(block.classes, "echarts") then
|
||||||
|
kind = "echarts"
|
||||||
|
else
|
||||||
|
return block
|
||||||
|
end
|
||||||
|
|
||||||
|
local media = next_media(kind)
|
||||||
|
local image = media_image(media)
|
||||||
|
if media.caption ~= nil and media.caption ~= "" then
|
||||||
|
return pandoc.Figure(
|
||||||
|
{ pandoc.Plain { image } },
|
||||||
|
pandoc.Caption {
|
||||||
|
pandoc.Plain { pandoc.Str(media.caption) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
return pandoc.Para { image }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function validate_counts()
|
||||||
|
for _, kind in ipairs({ "image", "mermaid", "echarts" }) do
|
||||||
|
if counters[kind] ~= #media_map[kind] then
|
||||||
|
error(
|
||||||
|
"DOCX media map contains unused "
|
||||||
|
.. kind
|
||||||
|
.. " resources"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
Pandoc = function(document)
|
||||||
|
local transformed = document:walk {
|
||||||
|
Image = replace_image,
|
||||||
|
CodeBlock = replace_code_block
|
||||||
|
}
|
||||||
|
validate_counts()
|
||||||
|
return transformed
|
||||||
|
end
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Pandoc 媒体转换验收
|
||||||
|
author: Markdown PDF 导出器研发组
|
||||||
|
---
|
||||||
|
|
||||||
|
# 可编辑正文
|
||||||
|
|
||||||
|
这段中文正文在 DOCX 中必须继续作为标准文本编辑。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[开始] --> B[结束]
|
||||||
|
```
|
||||||
|
|
||||||
|
```echarts
|
||||||
|
version: 1
|
||||||
|
option:
|
||||||
|
xAxis:
|
||||||
|
type: category
|
||||||
|
data: [一月, 二月]
|
||||||
|
yAxis:
|
||||||
|
type: value
|
||||||
|
series:
|
||||||
|
- type: bar
|
||||||
|
data: [10, 20]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 项目 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文本 | 可编辑 |
|
||||||
|
| 媒体 | PNG |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const editable = true;
|
||||||
|
```
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"assets",
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||||
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs"
|
"verify:pandoc": "node scripts/verify-pandoc-reference.mjs",
|
||||||
|
"verify:conversion": "node scripts/verify-pandoc-conversion.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@md-to-pdf/core": "0.1.0",
|
"@md-to-pdf/core": "0.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
|
themeManifestSchema
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
PandocDocxConverter,
|
||||||
|
PandocRuntime
|
||||||
|
} from "@md-to-pdf/docx-engine";
|
||||||
|
import { unzipSync } from "fflate";
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageDirectory = path.resolve(directory, "..");
|
||||||
|
const repositoryDirectory = path.resolve(packageDirectory, "../..");
|
||||||
|
const markdown = fs.readFileSync(
|
||||||
|
path.join(packageDirectory, "fixtures", "pandoc-conversion.md"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const theme = themeManifestSchema.parse(
|
||||||
|
JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
repositoryDirectory,
|
||||||
|
"themes",
|
||||||
|
"gov-red-standard",
|
||||||
|
"theme.json"
|
||||||
|
),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const png = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resource(kind, ordinal, dimensions, caption) {
|
||||||
|
return {
|
||||||
|
id: `docx-media-${ordinal}`,
|
||||||
|
kind,
|
||||||
|
ordinal,
|
||||||
|
kindOrdinal: 1,
|
||||||
|
altText:
|
||||||
|
kind === "image"
|
||||||
|
? "普通图片"
|
||||||
|
: kind === "mermaid"
|
||||||
|
? "Mermaid 流程图"
|
||||||
|
: "ECharts 柱状图",
|
||||||
|
...(caption ? { caption } : {}),
|
||||||
|
displayWidthPx: dimensions.width,
|
||||||
|
displayHeightPx: dimensions.height,
|
||||||
|
captureX: 0,
|
||||||
|
captureY: 0,
|
||||||
|
captureWidthPx: dimensions.width,
|
||||||
|
captureHeightPx: dimensions.height,
|
||||||
|
rasterScale: 1,
|
||||||
|
fileName: `media-${ordinal}.png`,
|
||||||
|
contentType: "image/png",
|
||||||
|
content: png,
|
||||||
|
pixelWidth: 1,
|
||||||
|
pixelHeight: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = new PandocRuntime(
|
||||||
|
process.env.DOCX_PANDOC_PATH?.trim()
|
||||||
|
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
const converter = new PandocDocxConverter({ runtime });
|
||||||
|
const conversionInput = {
|
||||||
|
markdown,
|
||||||
|
fileName: "Pandoc 媒体转换验收.md",
|
||||||
|
language: "zh-CN",
|
||||||
|
exportConfig: {
|
||||||
|
...defaultExportConfig,
|
||||||
|
themeId: theme.id
|
||||||
|
},
|
||||||
|
theme,
|
||||||
|
metadata: {
|
||||||
|
title: "Pandoc 媒体转换验收",
|
||||||
|
author: "Markdown PDF 导出器研发组",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
resources: [
|
||||||
|
resource("image", 1, { width: 384, height: 192 }),
|
||||||
|
resource(
|
||||||
|
"mermaid",
|
||||||
|
2,
|
||||||
|
{ width: 480, height: 240 },
|
||||||
|
"Mermaid 流程图"
|
||||||
|
),
|
||||||
|
resource(
|
||||||
|
"echarts",
|
||||||
|
3,
|
||||||
|
{ width: 576, height: 288 },
|
||||||
|
"ECharts 柱状图"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: [],
|
||||||
|
warnings: [],
|
||||||
|
totalBytes: png.byteLength * 3
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = await converter.convert(conversionInput);
|
||||||
|
|
||||||
|
const entries = unzipSync(result.docx);
|
||||||
|
const documentXml = new TextDecoder().decode(
|
||||||
|
entries["word/document.xml"]
|
||||||
|
);
|
||||||
|
const mediaParts = Object.keys(entries).filter((name) =>
|
||||||
|
/^word\/media\/.+\.png$/u.test(name)
|
||||||
|
);
|
||||||
|
const physicalExtents = Array.from(
|
||||||
|
documentXml.matchAll(
|
||||||
|
/<wp:extent\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/gu
|
||||||
|
),
|
||||||
|
(match) => ({
|
||||||
|
width: Number(match[1]),
|
||||||
|
height: Number(match[2])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const checks = {
|
||||||
|
editableText: documentXml.includes("标准文本编辑"),
|
||||||
|
editableTable: documentXml.includes("<w:tbl"),
|
||||||
|
editableCode: documentXml.includes("editable"),
|
||||||
|
imageAlt: documentXml.includes("普通图片"),
|
||||||
|
mermaidAlt: documentXml.includes("Mermaid 流程图"),
|
||||||
|
echartsAlt: documentXml.includes("ECharts 柱状图"),
|
||||||
|
threeImageExtents:
|
||||||
|
physicalExtents.length === 3,
|
||||||
|
physicalDimensions: physicalExtents.every(
|
||||||
|
(extent, index) =>
|
||||||
|
Math.abs(
|
||||||
|
extent.width - [3657600, 4572000, 5486400][index]
|
||||||
|
) <= 360 &&
|
||||||
|
Math.abs(
|
||||||
|
extent.height - [1828800, 2286000, 2743200][index]
|
||||||
|
) <= 360
|
||||||
|
),
|
||||||
|
embeddedPng: mediaParts.length >= 1
|
||||||
|
};
|
||||||
|
for (const [name, passed] of Object.entries(checks)) {
|
||||||
|
assert(
|
||||||
|
passed,
|
||||||
|
`真实 DOCX 媒体转换检查失败:${name},实际尺寸 ${JSON.stringify(physicalExtents)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mismatchedMediaRejected = false;
|
||||||
|
try {
|
||||||
|
await converter.convert({
|
||||||
|
...conversionInput,
|
||||||
|
media: {
|
||||||
|
...conversionInput.media,
|
||||||
|
resources: conversionInput.media.resources.slice(0, 2),
|
||||||
|
totalBytes: png.byteLength * 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
mismatchedMediaRejected =
|
||||||
|
error?.code === "DOCX_GENERATION_FAILED";
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
mismatchedMediaRejected,
|
||||||
|
"Lua Filter 未拒绝缺失的 ECharts PNG 映射"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
bytes: result.docx.byteLength,
|
||||||
|
templateFingerprint: result.templateFingerprint,
|
||||||
|
templateCacheKey: result.templateCacheKey,
|
||||||
|
validation: result.validation,
|
||||||
|
mediaParts,
|
||||||
|
physicalExtents,
|
||||||
|
mismatchedMediaRejected,
|
||||||
|
checks,
|
||||||
|
timings: result.timings
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
createDynamicReferenceDocx,
|
createDynamicReferenceDocx,
|
||||||
validateDynamicReferenceDocx
|
PandocRuntime,
|
||||||
|
validateGeneratedDocx
|
||||||
} from "@md-to-pdf/docx-engine";
|
} from "@md-to-pdf/docx-engine";
|
||||||
import { unzipSync } from "fflate";
|
import { unzipSync } from "fflate";
|
||||||
|
|
||||||
@@ -28,8 +29,19 @@ const themePath = path.join(
|
|||||||
"gov-red-standard",
|
"gov-red-standard",
|
||||||
"theme.json"
|
"theme.json"
|
||||||
);
|
);
|
||||||
const pandocExecutable =
|
const runtime = new PandocRuntime(
|
||||||
process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc";
|
process.env.DOCX_PANDOC_PATH?.trim()
|
||||||
|
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
const runtimeResolution = await runtime.probe();
|
||||||
|
assert(
|
||||||
|
runtimeResolution.capability.status === "available",
|
||||||
|
runtimeResolution.capability.status === "available"
|
||||||
|
? ""
|
||||||
|
: runtimeResolution.capability.message
|
||||||
|
);
|
||||||
|
const pandocExecutable = await runtime.getExecutablePath();
|
||||||
const maximumBuffer = 128 * 1024 * 1024;
|
const maximumBuffer = 128 * 1024 * 1024;
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
@@ -64,7 +76,7 @@ function countMatches(value, pattern) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inspectResult(content, expected) {
|
function inspectResult(content, expected) {
|
||||||
const validation = validateDynamicReferenceDocx(content);
|
const validation = validateGeneratedDocx(content);
|
||||||
const entries = unzipSync(content);
|
const entries = unzipSync(content);
|
||||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||||
@@ -174,22 +186,13 @@ function createVariant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionResult = runPandoc(["--version"]);
|
const detectedVersion = runtimeResolution.capability.detectedVersion;
|
||||||
const detectedVersion =
|
|
||||||
versionResult.stdout
|
|
||||||
.split(/\r?\n/u)[0]
|
|
||||||
?.replace(/^pandoc\s+/u, "")
|
|
||||||
.trim() ?? "";
|
|
||||||
assert(
|
assert(
|
||||||
detectedVersion === DOCX_PANDOC_VERSION,
|
detectedVersion === DOCX_PANDOC_VERSION,
|
||||||
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}`
|
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const baselineResult = runPandoc(
|
const baseline = await runtime.getDefaultReferenceDocx();
|
||||||
["--print-default-data-file", "reference.docx"],
|
|
||||||
{ binary: true }
|
|
||||||
);
|
|
||||||
const baseline = new Uint8Array(baselineResult.stdout);
|
|
||||||
const theme = themeManifestSchema.parse(
|
const theme = themeManifestSchema.parse(
|
||||||
JSON.parse(fs.readFileSync(themePath, "utf8"))
|
JSON.parse(fs.readFileSync(themePath, "utf8"))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export * from "./header-footer-transform.js";
|
export * from "./header-footer-transform.js";
|
||||||
export * from "./ooxml.js";
|
export * from "./ooxml.js";
|
||||||
|
export * from "./pandoc-process.js";
|
||||||
|
export * from "./pandoc-media.js";
|
||||||
|
export * from "./pandoc-converter.js";
|
||||||
|
export * from "./pandoc-runtime-manifest.js";
|
||||||
|
export * from "./pandoc-runtime.js";
|
||||||
export * from "./reference-builder.js";
|
export * from "./reference-builder.js";
|
||||||
export * from "./reference-options.js";
|
export * from "./reference-options.js";
|
||||||
export * from "./reference-package.js";
|
export * from "./reference-package.js";
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import {
|
||||||
|
MAXIMUM_DOCX_OUTPUT_BYTES,
|
||||||
|
type DocxExportErrorCode,
|
||||||
|
type ExportConfig,
|
||||||
|
type MarkdownDocumentMetadata,
|
||||||
|
type PreparedDocxMedia,
|
||||||
|
type ThemeManifest
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
createDynamicReferenceCacheKey,
|
||||||
|
createDynamicReferenceDocx,
|
||||||
|
type DynamicReferenceDocxResult
|
||||||
|
} from "./reference-builder.js";
|
||||||
|
import type { DynamicReferenceDocxOptions } from "./reference-options.js";
|
||||||
|
import { preparePandocMedia } from "./pandoc-media.js";
|
||||||
|
import {
|
||||||
|
runPandocProcess,
|
||||||
|
type PandocProcessRunner
|
||||||
|
} from "./pandoc-process.js";
|
||||||
|
import { readReferenceDocxPackage } from "./reference-package.js";
|
||||||
|
import type { PandocRuntime } from "./pandoc-runtime.js";
|
||||||
|
import {
|
||||||
|
validateGeneratedDocx,
|
||||||
|
type DynamicReferenceValidation
|
||||||
|
} from "./validator.js";
|
||||||
|
|
||||||
|
const DEFAULT_CONVERSION_TIMEOUT_MS = 60_000;
|
||||||
|
const MAXIMUM_PANDOC_STDOUT_BYTES = 1024 * 1024;
|
||||||
|
const MAXIMUM_PANDOC_STDERR_BYTES = 64 * 1024;
|
||||||
|
const DEFAULT_REFERENCE_CACHE_SIZE = 32;
|
||||||
|
const temporaryDirectoryPrefix = "md-to-pdf-docx-";
|
||||||
|
const mediaMapEnvironmentName = "MD_TO_PDF_DOCX_MEDIA_MAP";
|
||||||
|
const luaFilterUrl = new URL(
|
||||||
|
"../assets/docx-media-filter.lua",
|
||||||
|
import.meta.url
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface PandocRuntimeProvider {
|
||||||
|
getExecutablePath(): Promise<string>;
|
||||||
|
getDefaultReferenceDocx(): Promise<Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocDocxConversionInput {
|
||||||
|
markdown: string;
|
||||||
|
fileName: string;
|
||||||
|
language: string;
|
||||||
|
exportConfig: ExportConfig;
|
||||||
|
theme: ThemeManifest;
|
||||||
|
metadata: MarkdownDocumentMetadata;
|
||||||
|
media: PreparedDocxMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocDocxConversionTimings {
|
||||||
|
referenceMs: number;
|
||||||
|
pandocMs: number;
|
||||||
|
validationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocDocxConversionResult {
|
||||||
|
docx: Uint8Array;
|
||||||
|
templateFingerprint: string;
|
||||||
|
templateCacheKey: string;
|
||||||
|
validation: DynamicReferenceValidation;
|
||||||
|
timings: PandocDocxConversionTimings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocDocxConverterOptions {
|
||||||
|
runtime: PandocRuntimeProvider | PandocRuntime;
|
||||||
|
runner?: PandocProcessRunner;
|
||||||
|
environment?: NodeJS.ProcessEnv;
|
||||||
|
temporaryRoot?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
maximumOutputBytes?: number;
|
||||||
|
referenceCacheSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PandocDocxConversionError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: Extract<
|
||||||
|
DocxExportErrorCode,
|
||||||
|
| "DOCX_RUNTIME_NOT_FOUND"
|
||||||
|
| "DOCX_RENDER_TIMEOUT"
|
||||||
|
| "DOCX_GENERATION_FAILED"
|
||||||
|
| "DOCX_OUTPUT_INVALID"
|
||||||
|
>,
|
||||||
|
message: string,
|
||||||
|
options?: ErrorOptions
|
||||||
|
) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = "PandocDocxConversionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let luaFilterContent: Promise<Uint8Array> | undefined;
|
||||||
|
|
||||||
|
function loadLuaFilter() {
|
||||||
|
luaFilterContent ??= readFile(luaFilterUrl);
|
||||||
|
return luaFilterContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOutputLimit(value: number | undefined) {
|
||||||
|
const limit = value ?? MAXIMUM_DOCX_OUTPUT_BYTES;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(limit) ||
|
||||||
|
limit < 1024 ||
|
||||||
|
limit > MAXIMUM_DOCX_OUTPUT_BYTES
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX 输出大小上限无效");
|
||||||
|
}
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTimeout(value: number | undefined) {
|
||||||
|
const timeout = value ?? DEFAULT_CONVERSION_TIMEOUT_MS;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(timeout) ||
|
||||||
|
timeout < 1_000 ||
|
||||||
|
timeout > 10 * 60_000
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX 转换超时配置无效");
|
||||||
|
}
|
||||||
|
return timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceOptions(
|
||||||
|
input: PandocDocxConversionInput
|
||||||
|
): DynamicReferenceDocxOptions {
|
||||||
|
return {
|
||||||
|
exportConfig: input.exportConfig,
|
||||||
|
theme: input.theme,
|
||||||
|
fileName: input.fileName,
|
||||||
|
metadata: {
|
||||||
|
title: input.metadata.title,
|
||||||
|
author: input.metadata.author
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pandocArguments(paths: {
|
||||||
|
markdown: string;
|
||||||
|
output: string;
|
||||||
|
reference: string;
|
||||||
|
luaFilter: string;
|
||||||
|
resourceDirectory: string;
|
||||||
|
dataDirectory: string;
|
||||||
|
language: string;
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
paths.markdown,
|
||||||
|
"--from",
|
||||||
|
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars",
|
||||||
|
"--to",
|
||||||
|
"docx",
|
||||||
|
"--standalone",
|
||||||
|
"--reference-doc",
|
||||||
|
paths.reference,
|
||||||
|
"--lua-filter",
|
||||||
|
paths.luaFilter,
|
||||||
|
"--resource-path",
|
||||||
|
paths.resourceDirectory,
|
||||||
|
"--data-dir",
|
||||||
|
paths.dataDirectory,
|
||||||
|
"--metadata",
|
||||||
|
`lang=${paths.language}`,
|
||||||
|
"--output",
|
||||||
|
paths.output
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PandocDocxConverter {
|
||||||
|
private readonly runner: PandocProcessRunner;
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
private readonly maximumOutputBytes: number;
|
||||||
|
private readonly referenceCacheSize: number;
|
||||||
|
private readonly referenceCache = new Map<
|
||||||
|
string,
|
||||||
|
DynamicReferenceDocxResult
|
||||||
|
>();
|
||||||
|
|
||||||
|
constructor(private readonly options: PandocDocxConverterOptions) {
|
||||||
|
this.runner = options.runner ?? runPandocProcess;
|
||||||
|
this.timeoutMs = ensureTimeout(options.timeoutMs);
|
||||||
|
this.maximumOutputBytes = ensureOutputLimit(
|
||||||
|
options.maximumOutputBytes
|
||||||
|
);
|
||||||
|
this.referenceCacheSize =
|
||||||
|
options.referenceCacheSize ?? DEFAULT_REFERENCE_CACHE_SIZE;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(this.referenceCacheSize) ||
|
||||||
|
this.referenceCacheSize < 1 ||
|
||||||
|
this.referenceCacheSize > 128
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX 动态模板缓存容量无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async convert(
|
||||||
|
input: PandocDocxConversionInput,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PandocDocxConversionResult> {
|
||||||
|
let preparedMedia;
|
||||||
|
try {
|
||||||
|
preparedMedia = preparePandocMedia(input.media);
|
||||||
|
} catch (error) {
|
||||||
|
throw new PandocDocxConversionError(
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 媒体映射无效",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryDirectory = await mkdtemp(
|
||||||
|
path.join(
|
||||||
|
this.options.temporaryRoot ?? os.tmpdir(),
|
||||||
|
temporaryDirectoryPrefix
|
||||||
|
)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const referenceStarted = performance.now();
|
||||||
|
const [executablePath, baseline, filterContent] =
|
||||||
|
await Promise.all([
|
||||||
|
this.options.runtime.getExecutablePath(),
|
||||||
|
this.options.runtime.getDefaultReferenceDocx(),
|
||||||
|
loadLuaFilter()
|
||||||
|
]);
|
||||||
|
const reference = this.getOrCreateReference(baseline, input);
|
||||||
|
const referenceMs = performance.now() - referenceStarted;
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
markdown: path.join(temporaryDirectory, "document.md"),
|
||||||
|
output: path.join(temporaryDirectory, "document.docx"),
|
||||||
|
reference: path.join(temporaryDirectory, "reference.docx"),
|
||||||
|
luaFilter: path.join(
|
||||||
|
temporaryDirectory,
|
||||||
|
"docx-media-filter.lua"
|
||||||
|
),
|
||||||
|
mediaMap: path.join(temporaryDirectory, "media-map.json"),
|
||||||
|
mediaDirectory: path.join(temporaryDirectory, "media"),
|
||||||
|
dataDirectory: path.join(temporaryDirectory, "pandoc-data")
|
||||||
|
};
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(paths.mediaDirectory),
|
||||||
|
mkdir(paths.dataDirectory)
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(paths.markdown, input.markdown, "utf8"),
|
||||||
|
writeFile(paths.reference, reference.content),
|
||||||
|
writeFile(paths.luaFilter, filterContent),
|
||||||
|
writeFile(
|
||||||
|
paths.mediaMap,
|
||||||
|
JSON.stringify(preparedMedia.map),
|
||||||
|
"utf8"
|
||||||
|
),
|
||||||
|
...preparedMedia.files.map((file) =>
|
||||||
|
writeFile(
|
||||||
|
path.join(temporaryDirectory, file.relativePath),
|
||||||
|
file.content
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pandocStarted = performance.now();
|
||||||
|
const processResult = await this.runner(
|
||||||
|
executablePath,
|
||||||
|
pandocArguments({
|
||||||
|
markdown: paths.markdown,
|
||||||
|
output: paths.output,
|
||||||
|
reference: paths.reference,
|
||||||
|
luaFilter: paths.luaFilter,
|
||||||
|
resourceDirectory: temporaryDirectory,
|
||||||
|
dataDirectory: paths.dataDirectory,
|
||||||
|
language: input.language
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cwd: temporaryDirectory,
|
||||||
|
env: {
|
||||||
|
...(this.options.environment ?? process.env),
|
||||||
|
[mediaMapEnvironmentName]: paths.mediaMap
|
||||||
|
},
|
||||||
|
timeoutMs: this.timeoutMs,
|
||||||
|
maxStdoutBytes: MAXIMUM_PANDOC_STDOUT_BYTES,
|
||||||
|
maxStderrBytes: MAXIMUM_PANDOC_STDERR_BYTES,
|
||||||
|
...(signal ? { signal } : {})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const pandocMs = performance.now() - pandocStarted;
|
||||||
|
if (
|
||||||
|
processResult.outcome === "timeout" ||
|
||||||
|
processResult.outcome === "aborted"
|
||||||
|
) {
|
||||||
|
throw new PandocDocxConversionError(
|
||||||
|
"DOCX_RENDER_TIMEOUT",
|
||||||
|
"DOCX 转换超时或已取消"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
processResult.outcome !== "completed" ||
|
||||||
|
processResult.exitCode !== 0
|
||||||
|
) {
|
||||||
|
throw new PandocDocxConversionError(
|
||||||
|
processResult.outcome === "not-found"
|
||||||
|
? "DOCX_RUNTIME_NOT_FOUND"
|
||||||
|
: "DOCX_GENERATION_FAILED",
|
||||||
|
"Pandoc 未能生成 DOCX"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationStarted = performance.now();
|
||||||
|
const outputStat = await stat(paths.output);
|
||||||
|
if (
|
||||||
|
!outputStat.isFile() ||
|
||||||
|
outputStat.size < 1 ||
|
||||||
|
outputStat.size > this.maximumOutputBytes
|
||||||
|
) {
|
||||||
|
throw new PandocDocxConversionError(
|
||||||
|
"DOCX_OUTPUT_INVALID",
|
||||||
|
"DOCX 输出大小无效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const docx = await readFile(paths.output);
|
||||||
|
let validation: DynamicReferenceValidation;
|
||||||
|
try {
|
||||||
|
validation = validateGeneratedDocx(docx);
|
||||||
|
} catch (error) {
|
||||||
|
throw new PandocDocxConversionError(
|
||||||
|
"DOCX_OUTPUT_INVALID",
|
||||||
|
"DOCX 输出结构无效",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const validationMs = performance.now() - validationStarted;
|
||||||
|
return {
|
||||||
|
docx,
|
||||||
|
templateFingerprint: reference.templateFingerprint,
|
||||||
|
templateCacheKey: reference.cacheKey,
|
||||||
|
validation,
|
||||||
|
timings: {
|
||||||
|
referenceMs,
|
||||||
|
pandocMs,
|
||||||
|
validationMs
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PandocDocxConversionError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new PandocDocxConversionError(
|
||||||
|
"DOCX_GENERATION_FAILED",
|
||||||
|
"DOCX 转换失败",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(temporaryDirectory, {
|
||||||
|
recursive: true,
|
||||||
|
force: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReferenceCache() {
|
||||||
|
this.referenceCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrCreateReference(
|
||||||
|
baseline: Uint8Array,
|
||||||
|
input: PandocDocxConversionInput
|
||||||
|
) {
|
||||||
|
const options = referenceOptions(input);
|
||||||
|
const baselineFingerprint =
|
||||||
|
readReferenceDocxPackage(baseline).fingerprint;
|
||||||
|
const cacheKey = createDynamicReferenceCacheKey(
|
||||||
|
baselineFingerprint,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
const cached = this.referenceCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
this.referenceCache.delete(cacheKey);
|
||||||
|
this.referenceCache.set(cacheKey, cached);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const reference = createDynamicReferenceDocx(
|
||||||
|
baseline,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
this.referenceCache.set(cacheKey, reference);
|
||||||
|
while (this.referenceCache.size > this.referenceCacheSize) {
|
||||||
|
const oldest = this.referenceCache.keys().next().value;
|
||||||
|
if (oldest === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.referenceCache.delete(oldest);
|
||||||
|
}
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
DOCX_MEDIA_CSS_DPI,
|
||||||
|
MAXIMUM_DOCX_MEDIA_BYTES,
|
||||||
|
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
|
||||||
|
MAXIMUM_DOCX_MEDIA_PIXELS,
|
||||||
|
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||||
|
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||||
|
type DocxMediaKind,
|
||||||
|
type PreparedDocxMedia
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface PandocMediaMapItem {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
alt_text: string;
|
||||||
|
caption?: string;
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocMediaMap {
|
||||||
|
image: PandocMediaMapItem[];
|
||||||
|
mermaid: PandocMediaMapItem[];
|
||||||
|
echarts: PandocMediaMapItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocMediaFile {
|
||||||
|
relativePath: string;
|
||||||
|
content: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedPandocMedia {
|
||||||
|
map: PandocMediaMap;
|
||||||
|
files: PandocMediaFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelsToMillimeters(value: number) {
|
||||||
|
return (value * 25.4) / DOCX_MEDIA_CSS_DPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function physicalLength(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0 || value > 10_000) {
|
||||||
|
throw new Error("DOCX 媒体显示尺寸无效");
|
||||||
|
}
|
||||||
|
return `${pixelsToMillimeters(value).toFixed(3)}mm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPng(content: Uint8Array) {
|
||||||
|
const signature = [
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
content.byteLength < 24 ||
|
||||||
|
signature.some((value, index) => content[index] !== value) ||
|
||||||
|
String.fromCharCode(...content.slice(12, 16)) !== "IHDR"
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX 媒体不是有效 PNG");
|
||||||
|
}
|
||||||
|
const view = new DataView(
|
||||||
|
content.buffer,
|
||||||
|
content.byteOffset,
|
||||||
|
content.byteLength
|
||||||
|
);
|
||||||
|
const width = view.getUint32(16);
|
||||||
|
const height = view.getUint32(20);
|
||||||
|
if (
|
||||||
|
width < 1 ||
|
||||||
|
height < 1 ||
|
||||||
|
width > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
|
||||||
|
height > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
|
||||||
|
width * height > MAXIMUM_DOCX_MEDIA_PIXELS
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX PNG 媒体像素尺寸超过限制");
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePandocMedia(
|
||||||
|
media: PreparedDocxMedia
|
||||||
|
): PreparedPandocMedia {
|
||||||
|
if (media.echartsErrors.length || media.mermaidErrors.length) {
|
||||||
|
throw new Error("DOCX 图表渲染存在错误,已中止转换");
|
||||||
|
}
|
||||||
|
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
|
||||||
|
throw new Error("DOCX 媒体数量超过限制");
|
||||||
|
}
|
||||||
|
|
||||||
|
const map: PandocMediaMap = {
|
||||||
|
image: [],
|
||||||
|
mermaid: [],
|
||||||
|
echarts: []
|
||||||
|
};
|
||||||
|
const files: PandocMediaFile[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
let totalBytes = 0;
|
||||||
|
const kindCounts: Record<DocxMediaKind, number> = {
|
||||||
|
image: 0,
|
||||||
|
mermaid: 0,
|
||||||
|
echarts: 0
|
||||||
|
};
|
||||||
|
for (const [index, resource] of media.resources.entries()) {
|
||||||
|
if (ids.has(resource.id)) {
|
||||||
|
throw new Error(`DOCX 媒体 ID 重复:${resource.id}`);
|
||||||
|
}
|
||||||
|
ids.add(resource.id);
|
||||||
|
kindCounts[resource.kind] += 1;
|
||||||
|
if (
|
||||||
|
resource.ordinal !== index + 1 ||
|
||||||
|
resource.kindOrdinal !== kindCounts[resource.kind]
|
||||||
|
) {
|
||||||
|
throw new Error("DOCX 媒体顺序与捕获计划不一致");
|
||||||
|
}
|
||||||
|
if (resource.content.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) {
|
||||||
|
throw new Error(`DOCX 媒体 ${resource.id} 超过单文件大小限制`);
|
||||||
|
}
|
||||||
|
totalBytes += resource.content.byteLength;
|
||||||
|
if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) {
|
||||||
|
throw new Error("DOCX PNG 媒体总大小超过限制");
|
||||||
|
}
|
||||||
|
const dimensions = assertPng(resource.content);
|
||||||
|
if (
|
||||||
|
dimensions.width !== resource.pixelWidth ||
|
||||||
|
dimensions.height !== resource.pixelHeight
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`DOCX 媒体 ${resource.id} 的 PNG 像素声明不一致`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const relativePath = `media/media-${String(
|
||||||
|
resource.ordinal
|
||||||
|
).padStart(3, "0")}.png`;
|
||||||
|
map[resource.kind].push({
|
||||||
|
id: resource.id,
|
||||||
|
path: relativePath,
|
||||||
|
alt_text: resource.altText || `${resource.kind} 图片`,
|
||||||
|
...(resource.caption
|
||||||
|
? { caption: resource.caption }
|
||||||
|
: {}),
|
||||||
|
width: physicalLength(resource.displayWidthPx),
|
||||||
|
height: physicalLength(resource.displayHeightPx)
|
||||||
|
});
|
||||||
|
files.push({
|
||||||
|
relativePath,
|
||||||
|
content: resource.content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (media.totalBytes !== totalBytes) {
|
||||||
|
throw new Error("DOCX 媒体总大小声明不一致");
|
||||||
|
}
|
||||||
|
return { map, files };
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
export type PandocProcessOutcome =
|
||||||
|
| "completed"
|
||||||
|
| "not-found"
|
||||||
|
| "not-executable"
|
||||||
|
| "timeout"
|
||||||
|
| "aborted"
|
||||||
|
| "output-limit";
|
||||||
|
|
||||||
|
export interface PandocProcessResult {
|
||||||
|
outcome: PandocProcessOutcome;
|
||||||
|
exitCode: number | null;
|
||||||
|
stdout: Uint8Array;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocProcessOptions {
|
||||||
|
cwd?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
timeoutMs: number;
|
||||||
|
maxStdoutBytes: number;
|
||||||
|
maxStderrBytes: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PandocProcessRunner = (
|
||||||
|
executablePath: string,
|
||||||
|
arguments_: readonly string[],
|
||||||
|
options: PandocProcessOptions
|
||||||
|
) => Promise<PandocProcessResult>;
|
||||||
|
|
||||||
|
function decodeStderr(chunks: Buffer[]) {
|
||||||
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runPandocProcess: PandocProcessRunner = (
|
||||||
|
executablePath,
|
||||||
|
arguments_,
|
||||||
|
options
|
||||||
|
) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const child = spawn(executablePath, [...arguments_], {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: options.env,
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
const stdout: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
|
let stdoutBytes = 0;
|
||||||
|
let stderrBytes = 0;
|
||||||
|
let settled = false;
|
||||||
|
let forcedOutcome: PandocProcessOutcome | undefined;
|
||||||
|
|
||||||
|
const settle = (
|
||||||
|
outcome: PandocProcessOutcome,
|
||||||
|
exitCode: number | null
|
||||||
|
) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
options.signal?.removeEventListener("abort", abort);
|
||||||
|
resolve({
|
||||||
|
outcome,
|
||||||
|
exitCode,
|
||||||
|
stdout: Buffer.concat(stdout),
|
||||||
|
stderr: decodeStderr(stderr)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const stop = (outcome: PandocProcessOutcome) => {
|
||||||
|
if (settled || forcedOutcome) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forcedOutcome = outcome;
|
||||||
|
child.kill();
|
||||||
|
};
|
||||||
|
const abort = () => stop("aborted");
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => stop("timeout"),
|
||||||
|
options.timeoutMs
|
||||||
|
);
|
||||||
|
timeout.unref();
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
abort();
|
||||||
|
} else {
|
||||||
|
options.signal?.addEventListener("abort", abort, {
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
stdoutBytes += chunk.byteLength;
|
||||||
|
if (stdoutBytes > options.maxStdoutBytes) {
|
||||||
|
stop("output-limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stdout.push(chunk);
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderrBytes += chunk.byteLength;
|
||||||
|
if (stderrBytes > options.maxStderrBytes) {
|
||||||
|
stop("output-limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stderr.push(chunk);
|
||||||
|
});
|
||||||
|
child.on("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
const outcome =
|
||||||
|
error.code === "ENOENT"
|
||||||
|
? "not-found"
|
||||||
|
: error.code === "EACCES" || error.code === "EPERM"
|
||||||
|
? "not-executable"
|
||||||
|
: "not-executable";
|
||||||
|
settle(outcome, null);
|
||||||
|
});
|
||||||
|
child.on("close", (exitCode) => {
|
||||||
|
settle(forcedOutcome ?? "completed", exitCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core";
|
||||||
|
|
||||||
|
export interface PandocRuntimeArtifact {
|
||||||
|
platform: "win32" | "linux";
|
||||||
|
architecture: "x64";
|
||||||
|
archiveType: "zip" | "tar.gz";
|
||||||
|
downloadUrl: string;
|
||||||
|
sha256: string;
|
||||||
|
executableRelativePath: string;
|
||||||
|
licenseFiles: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pandocRuntimeManifest = {
|
||||||
|
version: DOCX_PANDOC_VERSION,
|
||||||
|
license: "GPL-2.0-or-later",
|
||||||
|
projectUrl: "https://pandoc.org/",
|
||||||
|
sourceArchiveUrl:
|
||||||
|
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-source.tar.gz",
|
||||||
|
artifacts: [
|
||||||
|
{
|
||||||
|
platform: "win32",
|
||||||
|
architecture: "x64",
|
||||||
|
archiveType: "zip",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip",
|
||||||
|
sha256:
|
||||||
|
"C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A",
|
||||||
|
executableRelativePath: "pandoc.exe",
|
||||||
|
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "linux",
|
||||||
|
architecture: "x64",
|
||||||
|
archiveType: "tar.gz",
|
||||||
|
downloadUrl:
|
||||||
|
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz",
|
||||||
|
sha256:
|
||||||
|
"A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508",
|
||||||
|
executableRelativePath: "bin/pandoc",
|
||||||
|
licenseFiles: ["COPYING.md", "COPYRIGHT"]
|
||||||
|
}
|
||||||
|
] satisfies readonly PandocRuntimeArtifact[]
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function findPandocRuntimeArtifact(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
architecture: string
|
||||||
|
) {
|
||||||
|
return pandocRuntimeManifest.artifacts.find(
|
||||||
|
(artifact) =>
|
||||||
|
artifact.platform === platform &&
|
||||||
|
artifact.architecture === architecture
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
DOCX_PANDOC_VERSION,
|
||||||
|
type DocxCapability,
|
||||||
|
type DocxRuntimeStatus
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
MAXIMUM_REFERENCE_DOCX_BYTES,
|
||||||
|
readReferenceDocxPackage
|
||||||
|
} from "./reference-package.js";
|
||||||
|
import {
|
||||||
|
runPandocProcess,
|
||||||
|
type PandocProcessOptions,
|
||||||
|
type PandocProcessResult,
|
||||||
|
type PandocProcessRunner
|
||||||
|
} from "./pandoc-process.js";
|
||||||
|
|
||||||
|
const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
|
||||||
|
const DEFAULT_REFERENCE_TIMEOUT_MS = 5_000;
|
||||||
|
const MAXIMUM_PROBE_OUTPUT_BYTES = 16 * 1024;
|
||||||
|
|
||||||
|
export interface PandocRuntimeResolution {
|
||||||
|
capability: DocxCapability;
|
||||||
|
executablePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PandocRuntimeOptions {
|
||||||
|
configuredPath?: string;
|
||||||
|
desktopResourcesPath?: string;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
architecture?: string;
|
||||||
|
environment?: NodeJS.ProcessEnv;
|
||||||
|
probeTimeoutMs?: number;
|
||||||
|
referenceTimeoutMs?: number;
|
||||||
|
runner?: PandocProcessRunner;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeCandidate {
|
||||||
|
executablePath: string;
|
||||||
|
exclusive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PandocRuntimeUnavailableError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: Exclude<DocxRuntimeStatus, "available">,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PandocRuntimeUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailableCapability(
|
||||||
|
status: Exclude<DocxRuntimeStatus, "available">,
|
||||||
|
message: string,
|
||||||
|
detectedVersion?: string
|
||||||
|
): DocxCapability {
|
||||||
|
return {
|
||||||
|
format: "docx",
|
||||||
|
status,
|
||||||
|
expectedVersion: DOCX_PANDOC_VERSION,
|
||||||
|
...(detectedVersion ? { detectedVersion } : {}),
|
||||||
|
message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableCapability(): DocxCapability {
|
||||||
|
return {
|
||||||
|
format: "docx",
|
||||||
|
status: "available",
|
||||||
|
expectedVersion: DOCX_PANDOC_VERSION,
|
||||||
|
detectedVersion: DOCX_PANDOC_VERSION
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetectedVersion(result: PandocProcessResult) {
|
||||||
|
const firstLine = new TextDecoder()
|
||||||
|
.decode(result.stdout)
|
||||||
|
.split(/\r?\n/u)[0]
|
||||||
|
?.trim();
|
||||||
|
return firstLine?.replace(/^pandoc\s+/u, "").trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProbeOptions(
|
||||||
|
timeoutMs: number,
|
||||||
|
environment: NodeJS.ProcessEnv
|
||||||
|
): PandocProcessOptions {
|
||||||
|
return {
|
||||||
|
timeoutMs,
|
||||||
|
maxStdoutBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
|
||||||
|
maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
|
||||||
|
env: environment
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeStatus(result: PandocProcessResult) {
|
||||||
|
if (result.outcome === "not-found") {
|
||||||
|
return "not-found" as const;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.outcome === "timeout" ||
|
||||||
|
result.outcome === "aborted"
|
||||||
|
) {
|
||||||
|
return "probe-timeout" as const;
|
||||||
|
}
|
||||||
|
return "not-executable" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCandidates(options: PandocRuntimeOptions) {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const architecture = options.architecture ?? process.arch;
|
||||||
|
const environment = options.environment ?? process.env;
|
||||||
|
const configuredPath =
|
||||||
|
options.configuredPath ??
|
||||||
|
environment.DOCX_PANDOC_PATH?.trim() ??
|
||||||
|
"";
|
||||||
|
if (configuredPath) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
executablePath: configuredPath,
|
||||||
|
exclusive: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: RuntimeCandidate[] = [];
|
||||||
|
if (
|
||||||
|
platform === "win32" &&
|
||||||
|
architecture === "x64" &&
|
||||||
|
options.desktopResourcesPath
|
||||||
|
) {
|
||||||
|
candidates.push({
|
||||||
|
executablePath: path.join(
|
||||||
|
options.desktopResourcesPath,
|
||||||
|
"pandoc",
|
||||||
|
DOCX_PANDOC_VERSION,
|
||||||
|
"windows-x86_64",
|
||||||
|
"pandoc.exe"
|
||||||
|
),
|
||||||
|
exclusive: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (platform === "linux" && architecture === "x64") {
|
||||||
|
candidates.push({
|
||||||
|
executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`,
|
||||||
|
exclusive: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
candidates.push({
|
||||||
|
executablePath: platform === "win32" ? "pandoc.exe" : "pandoc",
|
||||||
|
exclusive: false
|
||||||
|
});
|
||||||
|
return candidates.filter(
|
||||||
|
(candidate, index, all) =>
|
||||||
|
all.findIndex(
|
||||||
|
(item) => item.executablePath === candidate.executablePath
|
||||||
|
) === index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeCandidate(
|
||||||
|
candidate: RuntimeCandidate,
|
||||||
|
runner: PandocProcessRunner,
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<PandocRuntimeResolution> {
|
||||||
|
const result = await runner(
|
||||||
|
candidate.executablePath,
|
||||||
|
["--version"],
|
||||||
|
createProbeOptions(timeoutMs, environment)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
result.outcome !== "completed" ||
|
||||||
|
result.exitCode !== 0
|
||||||
|
) {
|
||||||
|
const status =
|
||||||
|
result.outcome === "completed"
|
||||||
|
? "not-executable"
|
||||||
|
: outcomeStatus(result);
|
||||||
|
return {
|
||||||
|
capability: unavailableCapability(
|
||||||
|
status,
|
||||||
|
status === "probe-timeout"
|
||||||
|
? "Pandoc 版本探测超时"
|
||||||
|
: status === "not-found"
|
||||||
|
? "未找到 Pandoc 运行时"
|
||||||
|
: "Pandoc 运行时无法执行"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const detectedVersion = parseDetectedVersion(result);
|
||||||
|
if (detectedVersion !== DOCX_PANDOC_VERSION) {
|
||||||
|
return {
|
||||||
|
capability: unavailableCapability(
|
||||||
|
"version-mismatch",
|
||||||
|
`Pandoc 版本不匹配,需要 ${DOCX_PANDOC_VERSION}`,
|
||||||
|
detectedVersion || "未知"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capability: availableCapability(),
|
||||||
|
executablePath: candidate.executablePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probePandocRuntime(
|
||||||
|
options: PandocRuntimeOptions = {}
|
||||||
|
): Promise<PandocRuntimeResolution> {
|
||||||
|
const environment = options.environment ?? process.env;
|
||||||
|
const runner = options.runner ?? runPandocProcess;
|
||||||
|
let lastMissing: PandocRuntimeResolution | undefined;
|
||||||
|
for (const candidate of createCandidates(options)) {
|
||||||
|
const resolution = await probeCandidate(
|
||||||
|
candidate,
|
||||||
|
runner,
|
||||||
|
environment,
|
||||||
|
options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
if (resolution.capability.status === "available") {
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resolution.capability.status !== "not-found" ||
|
||||||
|
candidate.exclusive
|
||||||
|
) {
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
lastMissing = resolution;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
lastMissing ?? {
|
||||||
|
capability: unavailableCapability(
|
||||||
|
"not-found",
|
||||||
|
"未找到 Pandoc 运行时"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PandocRuntime {
|
||||||
|
private resolution:
|
||||||
|
| Promise<PandocRuntimeResolution>
|
||||||
|
| undefined = undefined;
|
||||||
|
private defaultReference:
|
||||||
|
| Promise<Uint8Array>
|
||||||
|
| undefined = undefined;
|
||||||
|
|
||||||
|
constructor(private readonly options: PandocRuntimeOptions = {}) {}
|
||||||
|
|
||||||
|
probe() {
|
||||||
|
this.resolution ??= probePandocRuntime(this.options);
|
||||||
|
return this.resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExecutablePath() {
|
||||||
|
const resolution = await this.probe();
|
||||||
|
if (resolution.capability.status !== "available") {
|
||||||
|
throw new PandocRuntimeUnavailableError(
|
||||||
|
resolution.capability.status,
|
||||||
|
resolution.capability.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!resolution.executablePath) {
|
||||||
|
throw new PandocRuntimeUnavailableError(
|
||||||
|
"not-found",
|
||||||
|
"Pandoc capability 可用但缺少可执行路径"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolution.executablePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefaultReferenceDocx() {
|
||||||
|
this.defaultReference ??= this.loadDefaultReferenceDocx();
|
||||||
|
try {
|
||||||
|
return (await this.defaultReference).slice();
|
||||||
|
} catch (error) {
|
||||||
|
this.defaultReference = undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate() {
|
||||||
|
this.resolution = undefined;
|
||||||
|
this.defaultReference = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDefaultReferenceDocx() {
|
||||||
|
const executablePath = await this.getExecutablePath();
|
||||||
|
const runner = this.options.runner ?? runPandocProcess;
|
||||||
|
const result = await runner(
|
||||||
|
executablePath,
|
||||||
|
["--print-default-data-file", "reference.docx"],
|
||||||
|
{
|
||||||
|
timeoutMs:
|
||||||
|
this.options.referenceTimeoutMs ??
|
||||||
|
DEFAULT_REFERENCE_TIMEOUT_MS,
|
||||||
|
maxStdoutBytes: MAXIMUM_REFERENCE_DOCX_BYTES,
|
||||||
|
maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
|
||||||
|
env: this.options.environment ?? process.env
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
result.outcome !== "completed" ||
|
||||||
|
result.exitCode !== 0
|
||||||
|
) {
|
||||||
|
const status =
|
||||||
|
result.outcome === "timeout" ||
|
||||||
|
result.outcome === "aborted"
|
||||||
|
? "probe-timeout"
|
||||||
|
: result.outcome === "not-found"
|
||||||
|
? "not-found"
|
||||||
|
: "not-executable";
|
||||||
|
throw new PandocRuntimeUnavailableError(
|
||||||
|
status,
|
||||||
|
"无法读取 Pandoc 默认 reference.docx"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
readReferenceDocxPackage(result.stdout);
|
||||||
|
return result.stdout;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,21 @@ export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024;
|
|||||||
export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256;
|
export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256;
|
||||||
export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES =
|
export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES =
|
||||||
64 * 1024 * 1024;
|
64 * 1024 * 1024;
|
||||||
|
export const MAXIMUM_GENERATED_DOCX_ENTRY_COUNT = 512;
|
||||||
|
export const MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES =
|
||||||
|
128 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface DocxPackageLimits {
|
||||||
|
maximumBytes: number;
|
||||||
|
maximumEntryCount: number;
|
||||||
|
maximumUncompressedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const referenceDocxPackageLimits: DocxPackageLimits = {
|
||||||
|
maximumBytes: MAXIMUM_REFERENCE_DOCX_BYTES,
|
||||||
|
maximumEntryCount: MAXIMUM_REFERENCE_ENTRY_COUNT,
|
||||||
|
maximumUncompressedBytes: MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
|
||||||
|
};
|
||||||
|
|
||||||
export const requiredReferenceDocxParts = [
|
export const requiredReferenceDocxParts = [
|
||||||
"[Content_Types].xml",
|
"[Content_Types].xml",
|
||||||
@@ -28,6 +43,23 @@ interface ZipDirectoryEntry {
|
|||||||
uncompressedSize: number;
|
uncompressedSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fingerprintEntries(
|
||||||
|
entries: ReadonlyMap<string, Uint8Array>
|
||||||
|
) {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
for (const [name, content] of [...entries].sort(
|
||||||
|
([left], [right]) => left.localeCompare(right)
|
||||||
|
)) {
|
||||||
|
const length = Buffer.alloc(8);
|
||||||
|
length.writeBigUInt64BE(BigInt(content.byteLength));
|
||||||
|
hash.update(name, "utf8");
|
||||||
|
hash.update(Uint8Array.of(0));
|
||||||
|
hash.update(length);
|
||||||
|
hash.update(content);
|
||||||
|
}
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
function findEndOfCentralDirectory(content: Uint8Array) {
|
function findEndOfCentralDirectory(content: Uint8Array) {
|
||||||
const view = new DataView(
|
const view = new DataView(
|
||||||
content.buffer,
|
content.buffer,
|
||||||
@@ -59,10 +91,13 @@ function validateEntryName(name: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inspectZipDirectory(content: Uint8Array) {
|
function inspectZipDirectory(
|
||||||
|
content: Uint8Array,
|
||||||
|
limits: DocxPackageLimits
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
content.byteLength < 22 ||
|
content.byteLength < 22 ||
|
||||||
content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES
|
content.byteLength > limits.maximumBytes
|
||||||
) {
|
) {
|
||||||
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
|
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
|
||||||
}
|
}
|
||||||
@@ -81,7 +116,7 @@ function inspectZipDirectory(content: Uint8Array) {
|
|||||||
if (
|
if (
|
||||||
diskNumber !== 0 ||
|
diskNumber !== 0 ||
|
||||||
directoryDisk !== 0 ||
|
directoryDisk !== 0 ||
|
||||||
entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT ||
|
entryCount > limits.maximumEntryCount ||
|
||||||
entryCount === 0 ||
|
entryCount === 0 ||
|
||||||
directoryOffset + directorySize > endOffset
|
directoryOffset + directorySize > endOffset
|
||||||
) {
|
) {
|
||||||
@@ -129,7 +164,7 @@ function inspectZipDirectory(content: Uint8Array) {
|
|||||||
totalUncompressedBytes += uncompressedSize;
|
totalUncompressedBytes += uncompressedSize;
|
||||||
if (
|
if (
|
||||||
totalUncompressedBytes >
|
totalUncompressedBytes >
|
||||||
MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
|
limits.maximumUncompressedBytes
|
||||||
) {
|
) {
|
||||||
throw new Error("reference.docx 解压后总大小超过限制");
|
throw new Error("reference.docx 解压后总大小超过限制");
|
||||||
}
|
}
|
||||||
@@ -142,10 +177,11 @@ function inspectZipDirectory(content: Uint8Array) {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readReferenceDocxPackage(
|
export function readDocxPackage(
|
||||||
content: Uint8Array
|
content: Uint8Array,
|
||||||
|
limits: DocxPackageLimits
|
||||||
): ReferenceDocxPackage {
|
): ReferenceDocxPackage {
|
||||||
const directory = inspectZipDirectory(content);
|
const directory = inspectZipDirectory(content, limits);
|
||||||
const unzipped = unzipSync(content);
|
const unzipped = unzipSync(content);
|
||||||
const entries = new Map<string, Uint8Array>();
|
const entries = new Map<string, Uint8Array>();
|
||||||
for (const descriptor of directory) {
|
for (const descriptor of directory) {
|
||||||
@@ -169,13 +205,17 @@ export function readReferenceDocxPackage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fingerprint: createHash("sha256")
|
fingerprint: fingerprintEntries(entries),
|
||||||
.update(content)
|
|
||||||
.digest("hex"),
|
|
||||||
entries
|
entries
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readReferenceDocxPackage(
|
||||||
|
content: Uint8Array
|
||||||
|
): ReferenceDocxPackage {
|
||||||
|
return readDocxPackage(content, referenceDocxPackageLimits);
|
||||||
|
}
|
||||||
|
|
||||||
export function writeReferenceDocxPackage(
|
export function writeReferenceDocxPackage(
|
||||||
entries: ReadonlyMap<string, Uint8Array>
|
entries: ReadonlyMap<string, Uint8Array>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
CONTENT_TYPES_NAMESPACE,
|
CONTENT_TYPES_NAMESPACE,
|
||||||
OFFICE_RELATIONSHIP_NAMESPACE,
|
OFFICE_RELATIONSHIP_NAMESPACE,
|
||||||
@@ -8,7 +9,12 @@ import {
|
|||||||
parseXmlPart,
|
parseXmlPart,
|
||||||
type XmlElement
|
type XmlElement
|
||||||
} from "./ooxml.js";
|
} from "./ooxml.js";
|
||||||
import { readReferenceDocxPackage } from "./reference-package.js";
|
import {
|
||||||
|
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
|
||||||
|
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
|
||||||
|
readDocxPackage,
|
||||||
|
readReferenceDocxPackage
|
||||||
|
} from "./reference-package.js";
|
||||||
|
|
||||||
const HEADER_RELATIONSHIP_TYPE =
|
const HEADER_RELATIONSHIP_TYPE =
|
||||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||||
@@ -263,10 +269,18 @@ function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDynamicReferenceDocx(
|
function validateDocx(
|
||||||
content: Uint8Array
|
content: Uint8Array,
|
||||||
|
generatedOutput: boolean
|
||||||
): DynamicReferenceValidation {
|
): DynamicReferenceValidation {
|
||||||
const reference = readReferenceDocxPackage(content);
|
const reference = generatedOutput
|
||||||
|
? readDocxPackage(content, {
|
||||||
|
maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES,
|
||||||
|
maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
|
||||||
|
maximumUncompressedBytes:
|
||||||
|
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES
|
||||||
|
})
|
||||||
|
: readReferenceDocxPackage(content);
|
||||||
let xmlPartCount = 0;
|
let xmlPartCount = 0;
|
||||||
let relationshipCount = 0;
|
let relationshipCount = 0;
|
||||||
const relationshipParts = new Map<
|
const relationshipParts = new Map<
|
||||||
@@ -345,3 +359,15 @@ export function validateDynamicReferenceDocx(
|
|||||||
footerCount
|
footerCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateDynamicReferenceDocx(
|
||||||
|
content: Uint8Array
|
||||||
|
): DynamicReferenceValidation {
|
||||||
|
return validateDocx(content, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateGeneratedDocx(
|
||||||
|
content: Uint8Array
|
||||||
|
): DynamicReferenceValidation {
|
||||||
|
return validateDocx(content, true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
copyFile,
|
||||||
|
mkdtemp,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
defaultExportConfig,
|
||||||
|
type PreparedDocxMedia,
|
||||||
|
type ThemeManifest
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
PandocDocxConversionError,
|
||||||
|
PandocDocxConverter,
|
||||||
|
type PandocProcessRunner,
|
||||||
|
type PandocRuntimeProvider
|
||||||
|
} from "../src/index.js";
|
||||||
|
import { createTestBaselineReference } from "./reference-test-fixture.js";
|
||||||
|
|
||||||
|
function theme(): ThemeManifest {
|
||||||
|
return {
|
||||||
|
manifestVersion: 1,
|
||||||
|
id: "test-theme",
|
||||||
|
name: "测试主题",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "测试",
|
||||||
|
author: "测试",
|
||||||
|
license: "内部许可",
|
||||||
|
entry: "theme.css",
|
||||||
|
domPreset: "generic",
|
||||||
|
defaultFontSize: "16px",
|
||||||
|
supportedFeatures: [],
|
||||||
|
category: "general",
|
||||||
|
compatibleProfiles: [],
|
||||||
|
docxStyle: { preset: "technical" },
|
||||||
|
bundled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMedia: PreparedDocxMedia = {
|
||||||
|
resources: [],
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: [],
|
||||||
|
warnings: [],
|
||||||
|
totalBytes: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
function input() {
|
||||||
|
return {
|
||||||
|
markdown: "# 测试",
|
||||||
|
fileName: "测试.md",
|
||||||
|
language: "zh-CN",
|
||||||
|
exportConfig: defaultExportConfig,
|
||||||
|
theme: theme(),
|
||||||
|
metadata: {
|
||||||
|
title: "测试",
|
||||||
|
author: "测试人",
|
||||||
|
subject: "",
|
||||||
|
keywords: [],
|
||||||
|
language: "zh-CN"
|
||||||
|
},
|
||||||
|
media: emptyMedia
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function argumentAfter(arguments_: readonly string[], name: string) {
|
||||||
|
const index = arguments_.indexOf(name);
|
||||||
|
if (index < 0 || !arguments_[index + 1]) {
|
||||||
|
throw new Error(`缺少参数 ${name}`);
|
||||||
|
}
|
||||||
|
return arguments_[index + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pandoc DOCX 转换器", () => {
|
||||||
|
let temporaryRoot: string;
|
||||||
|
const baseline = createTestBaselineReference();
|
||||||
|
const runtime: PandocRuntimeProvider = {
|
||||||
|
async getExecutablePath() {
|
||||||
|
return "pandoc";
|
||||||
|
},
|
||||||
|
async getDefaultReferenceDocx() {
|
||||||
|
return baseline;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
temporaryRoot = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "docx-converter-test-")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(temporaryRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("使用固定参数转换、校验并清理请求级临时目录", async () => {
|
||||||
|
let requestDirectory = "";
|
||||||
|
const runner = vi.fn<PandocProcessRunner>(
|
||||||
|
async (_executable, arguments_, options) => {
|
||||||
|
requestDirectory = options.cwd!;
|
||||||
|
expect(arguments_).toContain("--lua-filter");
|
||||||
|
expect(arguments_).toContain("--data-dir");
|
||||||
|
expect(arguments_).toContain("--resource-path");
|
||||||
|
expect(
|
||||||
|
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||||
|
).toContain("media-map.json");
|
||||||
|
await copyFile(
|
||||||
|
argumentAfter(arguments_, "--reference-doc"),
|
||||||
|
argumentAfter(arguments_, "--output")
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: new Uint8Array(),
|
||||||
|
stderr: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const converter = new PandocDocxConverter({
|
||||||
|
runtime,
|
||||||
|
runner,
|
||||||
|
temporaryRoot
|
||||||
|
});
|
||||||
|
const result = await converter.convert(input());
|
||||||
|
|
||||||
|
expect(result.docx.byteLength).toBeGreaterThan(0);
|
||||||
|
expect(result.validation.partCount).toBeGreaterThan(8);
|
||||||
|
expect(runner).toHaveBeenCalledTimes(1);
|
||||||
|
expect(existsSync(requestDirectory)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("超时和无效输出均返回稳定错误并清理目录", async () => {
|
||||||
|
let timeoutDirectory = "";
|
||||||
|
const timeoutRunner = vi.fn<PandocProcessRunner>(
|
||||||
|
async (_executable, _arguments, options) => {
|
||||||
|
timeoutDirectory = options.cwd!;
|
||||||
|
return {
|
||||||
|
outcome: "timeout",
|
||||||
|
exitCode: null,
|
||||||
|
stdout: new Uint8Array(),
|
||||||
|
stderr: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
new PandocDocxConverter({
|
||||||
|
runtime,
|
||||||
|
runner: timeoutRunner,
|
||||||
|
temporaryRoot
|
||||||
|
}).convert(input())
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "DOCX_RENDER_TIMEOUT"
|
||||||
|
} satisfies Partial<PandocDocxConversionError>);
|
||||||
|
expect(existsSync(timeoutDirectory)).toBe(false);
|
||||||
|
|
||||||
|
const invalidRunner = vi.fn<PandocProcessRunner>(
|
||||||
|
async (_executable, arguments_) => {
|
||||||
|
await writeFile(
|
||||||
|
argumentAfter(arguments_, "--output"),
|
||||||
|
"not a docx"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: new Uint8Array(),
|
||||||
|
stderr: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
new PandocDocxConverter({
|
||||||
|
runtime,
|
||||||
|
runner: invalidRunner,
|
||||||
|
temporaryRoot
|
||||||
|
}).convert(input())
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "DOCX_OUTPUT_INVALID"
|
||||||
|
} satisfies Partial<PandocDocxConversionError>);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
DocxMediaKind,
|
||||||
|
DocxPngMediaResource,
|
||||||
|
PreparedDocxMedia
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import { preparePandocMedia } from "../src/index.js";
|
||||||
|
|
||||||
|
const png = Uint8Array.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resource(
|
||||||
|
kind: DocxMediaKind,
|
||||||
|
ordinal: number,
|
||||||
|
kindOrdinal: number
|
||||||
|
): DocxPngMediaResource {
|
||||||
|
return {
|
||||||
|
id: `docx-media-${ordinal}`,
|
||||||
|
kind,
|
||||||
|
ordinal,
|
||||||
|
kindOrdinal,
|
||||||
|
altText: `${kind} ${kindOrdinal}`,
|
||||||
|
caption: `${kind} 图注`,
|
||||||
|
displayWidthPx: 384,
|
||||||
|
displayHeightPx: 192,
|
||||||
|
captureX: 0,
|
||||||
|
captureY: 0,
|
||||||
|
captureWidthPx: 384,
|
||||||
|
captureHeightPx: 192,
|
||||||
|
rasterScale: 1,
|
||||||
|
fileName: `media-${ordinal}.png`,
|
||||||
|
contentType: "image/png",
|
||||||
|
content: png,
|
||||||
|
pixelWidth: 1,
|
||||||
|
pixelHeight: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepared(
|
||||||
|
resources: DocxPngMediaResource[]
|
||||||
|
): PreparedDocxMedia {
|
||||||
|
return {
|
||||||
|
resources,
|
||||||
|
echartsErrors: [],
|
||||||
|
mermaidErrors: [],
|
||||||
|
warnings: [],
|
||||||
|
totalBytes: resources.reduce(
|
||||||
|
(total, item) => total + item.content.byteLength,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pandoc DOCX 媒体映射", () => {
|
||||||
|
it("按媒体类型和捕获顺序生成受控路径与物理尺寸", () => {
|
||||||
|
const result = preparePandocMedia(
|
||||||
|
prepared([
|
||||||
|
resource("image", 1, 1),
|
||||||
|
resource("mermaid", 2, 1),
|
||||||
|
resource("echarts", 3, 1)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.files.map((item) => item.relativePath)).toEqual([
|
||||||
|
"media/media-001.png",
|
||||||
|
"media/media-002.png",
|
||||||
|
"media/media-003.png"
|
||||||
|
]);
|
||||||
|
expect(result.map.image[0]).toMatchObject({
|
||||||
|
path: "media/media-001.png",
|
||||||
|
width: "101.600mm",
|
||||||
|
height: "50.800mm"
|
||||||
|
});
|
||||||
|
expect(result.map.mermaid).toHaveLength(1);
|
||||||
|
expect(result.map.echarts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝顺序错位、无效 PNG 和图表错误", () => {
|
||||||
|
expect(() =>
|
||||||
|
preparePandocMedia(
|
||||||
|
prepared([resource("image", 2, 1)])
|
||||||
|
)
|
||||||
|
).toThrow("顺序");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
preparePandocMedia(
|
||||||
|
prepared([
|
||||||
|
{
|
||||||
|
...resource("image", 1, 1),
|
||||||
|
content: new Uint8Array([1, 2, 3])
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
).toThrow("有效 PNG");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
preparePandocMedia({
|
||||||
|
...prepared([]),
|
||||||
|
mermaidErrors: ["图表错误"]
|
||||||
|
})
|
||||||
|
).toThrow("已中止转换");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { zipSync } from "fflate";
|
||||||
|
import {
|
||||||
|
DOCX_PANDOC_VERSION,
|
||||||
|
type DocxRuntimeStatus
|
||||||
|
} from "@md-to-pdf/core";
|
||||||
|
import {
|
||||||
|
PandocRuntime,
|
||||||
|
probePandocRuntime,
|
||||||
|
type PandocProcessResult,
|
||||||
|
type PandocProcessRunner
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
function processResult(
|
||||||
|
overrides: Partial<PandocProcessResult> = {}
|
||||||
|
): PandocProcessResult {
|
||||||
|
return {
|
||||||
|
outcome: "completed",
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: encoder.encode(`pandoc ${DOCX_PANDOC_VERSION}\n`),
|
||||||
|
stderr: "",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runnerWith(result: PandocProcessResult) {
|
||||||
|
return vi.fn<PandocProcessRunner>().mockResolvedValue(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReferenceDocx() {
|
||||||
|
const xml = (value: string) => encoder.encode(value);
|
||||||
|
return zipSync({
|
||||||
|
"[Content_Types].xml": xml("<Types/>"),
|
||||||
|
"_rels/.rels": xml("<Relationships/>"),
|
||||||
|
"word/document.xml": xml("<document/>"),
|
||||||
|
"word/fontTable.xml": xml("<fonts/>"),
|
||||||
|
"word/numbering.xml": xml("<numbering/>"),
|
||||||
|
"word/settings.xml": xml("<settings/>"),
|
||||||
|
"word/styles.xml": xml("<styles/>"),
|
||||||
|
"word/_rels/document.xml.rels": xml("<Relationships/>"),
|
||||||
|
"word/theme/theme1.xml": xml("<theme/>")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pandoc 运行时探测", () => {
|
||||||
|
it.each([
|
||||||
|
["not-found", "not-found"],
|
||||||
|
["not-executable", "not-executable"],
|
||||||
|
["timeout", "probe-timeout"],
|
||||||
|
["aborted", "probe-timeout"],
|
||||||
|
["output-limit", "not-executable"]
|
||||||
|
] satisfies Array<[PandocProcessResult["outcome"], DocxRuntimeStatus]>)(
|
||||||
|
"将 %s 映射为 %s",
|
||||||
|
async (outcome, expectedStatus) => {
|
||||||
|
const resolution = await probePandocRuntime({
|
||||||
|
configuredPath: "C:\\trusted\\pandoc.exe",
|
||||||
|
runner: runnerWith(
|
||||||
|
processResult({ outcome, exitCode: null, stdout: new Uint8Array() })
|
||||||
|
)
|
||||||
|
});
|
||||||
|
expect(resolution.capability.status).toBe(expectedStatus);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("拒绝不同补丁版本", async () => {
|
||||||
|
const resolution = await probePandocRuntime({
|
||||||
|
configuredPath: "C:\\trusted\\pandoc.exe",
|
||||||
|
runner: runnerWith(
|
||||||
|
processResult({
|
||||||
|
stdout: encoder.encode("pandoc 3.9.0.1\n")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
});
|
||||||
|
expect(resolution.capability).toMatchObject({
|
||||||
|
status: "version-mismatch",
|
||||||
|
detectedVersion: "3.9.0.1"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显式路径失败时不静默回退 PATH", async () => {
|
||||||
|
const runner = runnerWith(
|
||||||
|
processResult({
|
||||||
|
outcome: "not-found",
|
||||||
|
exitCode: null,
|
||||||
|
stdout: new Uint8Array()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const resolution = await probePandocRuntime({
|
||||||
|
configuredPath: "C:\\missing\\pandoc.exe",
|
||||||
|
runner
|
||||||
|
});
|
||||||
|
expect(resolution.capability.status).toBe("not-found");
|
||||||
|
expect(runner).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("内置路径不存在时回退到 PATH", async () => {
|
||||||
|
const runner = vi
|
||||||
|
.fn<PandocProcessRunner>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
processResult({
|
||||||
|
outcome: "not-found",
|
||||||
|
exitCode: null,
|
||||||
|
stdout: new Uint8Array()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(processResult());
|
||||||
|
const resolution = await probePandocRuntime({
|
||||||
|
platform: "win32",
|
||||||
|
architecture: "x64",
|
||||||
|
desktopResourcesPath: "C:\\app\\resources",
|
||||||
|
environment: {},
|
||||||
|
runner
|
||||||
|
});
|
||||||
|
expect(resolution).toMatchObject({
|
||||||
|
capability: { status: "available" },
|
||||||
|
executablePath: "pandoc.exe"
|
||||||
|
});
|
||||||
|
expect(runner.mock.calls[0]?.[0]).toContain(
|
||||||
|
`pandoc\\${DOCX_PANDOC_VERSION}\\windows-x86_64\\pandoc.exe`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缓存探测和默认 reference.docx,并向调用方返回副本", async () => {
|
||||||
|
const reference = createReferenceDocx();
|
||||||
|
const runner = vi
|
||||||
|
.fn<PandocProcessRunner>()
|
||||||
|
.mockResolvedValueOnce(processResult())
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
processResult({ stdout: reference })
|
||||||
|
);
|
||||||
|
const runtime = new PandocRuntime({
|
||||||
|
configuredPath: "C:\\trusted\\pandoc.exe",
|
||||||
|
runner
|
||||||
|
});
|
||||||
|
const first = await runtime.getDefaultReferenceDocx();
|
||||||
|
first[0] = 0;
|
||||||
|
const second = await runtime.getDefaultReferenceDocx();
|
||||||
|
|
||||||
|
expect(second[0]).not.toBe(0);
|
||||||
|
expect(runner).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runner.mock.calls[1]?.[1]).toEqual([
|
||||||
|
"--print-default-data-file",
|
||||||
|
"reference.docx"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { unzipSync, zipSync } from "fflate";
|
import { unzipSync, zipSync } from "fflate";
|
||||||
import {
|
import {
|
||||||
defaultExportConfig,
|
defaultExportConfig,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
} from "@md-to-pdf/core";
|
} from "@md-to-pdf/core";
|
||||||
import {
|
import {
|
||||||
createDynamicReferenceDocx,
|
createDynamicReferenceDocx,
|
||||||
|
validateGeneratedDocx,
|
||||||
validateDynamicReferenceDocx,
|
validateDynamicReferenceDocx,
|
||||||
writeReferenceDocxPackage
|
writeReferenceDocxPackage
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
@@ -272,4 +274,27 @@ describe("动态 reference.docx", () => {
|
|||||||
/footerReference 引用了无效关系/u
|
/footerReference 引用了无效关系/u
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => {
|
||||||
|
const result = createDynamicReferenceDocx(
|
||||||
|
createBaselineReference(),
|
||||||
|
createOptions(defaultExportConfig)
|
||||||
|
);
|
||||||
|
const entries = unzipSync(result.content);
|
||||||
|
const generated = zipSync(
|
||||||
|
{
|
||||||
|
...entries,
|
||||||
|
"word/media/large-test.bin": randomBytes(3 * 1024 * 1024)
|
||||||
|
},
|
||||||
|
{ level: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(generated.byteLength).toBeGreaterThan(2 * 1024 * 1024);
|
||||||
|
expect(validateGeneratedDocx(generated).partCount).toBe(
|
||||||
|
result.partCount + 1
|
||||||
|
);
|
||||||
|
expect(() => validateDynamicReferenceDocx(generated)).toThrow(
|
||||||
|
/大小超过限制/u
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
function createReferencePackage(
|
function createReferencePackage(
|
||||||
overrides: Record<string, Uint8Array> = {}
|
overrides: Record<string, Uint8Array> = {},
|
||||||
|
mtime = new Date("2020-01-01T00:00:00.000Z")
|
||||||
) {
|
) {
|
||||||
const entries = Object.fromEntries(
|
const entries = Object.fromEntries(
|
||||||
requiredReferenceDocxParts.map((name) => [
|
requiredReferenceDocxParts.map((name) => [
|
||||||
@@ -14,14 +15,25 @@ function createReferencePackage(
|
|||||||
new TextEncoder().encode(`<part name="${name}"/>`)
|
new TextEncoder().encode(`<part name="${name}"/>`)
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
return zipSync({ ...entries, ...overrides });
|
return zipSync(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries({ ...entries, ...overrides }).map(
|
||||||
|
([name, content]) => [name, [content, { mtime }]]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("reference.docx 包", () => {
|
describe("reference.docx 包", () => {
|
||||||
it("预检中央目录并返回稳定指纹", () => {
|
it("预检中央目录并返回稳定指纹", () => {
|
||||||
const content = createReferencePackage();
|
const content = createReferencePackage();
|
||||||
const first = readReferenceDocxPackage(content);
|
const first = readReferenceDocxPackage(content);
|
||||||
const second = readReferenceDocxPackage(content);
|
const second = readReferenceDocxPackage(
|
||||||
|
createReferencePackage(
|
||||||
|
{},
|
||||||
|
new Date("2026-07-30T00:00:00.000Z")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
expect(first.entries.size).toBe(requiredReferenceDocxParts.length);
|
expect(first.entries.size).toBe(requiredReferenceDocxParts.length);
|
||||||
expect(first.entries.has("word/styles.xml")).toBe(true);
|
expect(first.entries.has("word/styles.xml")).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { zipSync } from "fflate";
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const wordNamespace =
|
||||||
|
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||||
|
|
||||||
|
function xml(value: string) {
|
||||||
|
return encoder.encode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestBaselineReference() {
|
||||||
|
const styleIds = [
|
||||||
|
"Normal",
|
||||||
|
"BodyText",
|
||||||
|
"FirstParagraph",
|
||||||
|
"Compact",
|
||||||
|
"Title",
|
||||||
|
"TitleChar",
|
||||||
|
"Subtitle",
|
||||||
|
"SubtitleChar",
|
||||||
|
"Heading1",
|
||||||
|
"Heading1Char",
|
||||||
|
"Heading2",
|
||||||
|
"Heading2Char",
|
||||||
|
"Heading3",
|
||||||
|
"Heading3Char",
|
||||||
|
"Heading4",
|
||||||
|
"Heading4Char",
|
||||||
|
"Heading5",
|
||||||
|
"Heading5Char",
|
||||||
|
"Heading6",
|
||||||
|
"Heading6Char",
|
||||||
|
"BlockText",
|
||||||
|
"FootnoteText",
|
||||||
|
"DefaultParagraphFont",
|
||||||
|
"Table",
|
||||||
|
"Definition",
|
||||||
|
"Caption",
|
||||||
|
"TableCaption",
|
||||||
|
"ImageCaption",
|
||||||
|
"Figure",
|
||||||
|
"BodyTextChar",
|
||||||
|
"VerbatimChar",
|
||||||
|
"Hyperlink"
|
||||||
|
];
|
||||||
|
const styles = styleIds
|
||||||
|
.map((styleId) => {
|
||||||
|
const type =
|
||||||
|
styleId === "Table"
|
||||||
|
? "table"
|
||||||
|
: styleId.endsWith("Char") ||
|
||||||
|
styleId === "DefaultParagraphFont" ||
|
||||||
|
styleId === "VerbatimChar" ||
|
||||||
|
styleId === "Hyperlink"
|
||||||
|
? "character"
|
||||||
|
: "paragraph";
|
||||||
|
return `<w:style w:type="${type}" w:styleId="${styleId}"><w:name w:val="${styleId}"/></w:style>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return zipSync({
|
||||||
|
"[Content_Types].xml": xml(
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
|
||||||
|
),
|
||||||
|
"_rels/.rels": xml(
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
|
||||||
|
),
|
||||||
|
"word/document.xml": xml(
|
||||||
|
`<w:document xmlns:w="${wordNamespace}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p/><w:sectPr><w:footnotePr/></w:sectPr></w:body></w:document>`
|
||||||
|
),
|
||||||
|
"word/fontTable.xml": xml(
|
||||||
|
`<w:fonts xmlns:w="${wordNamespace}"><w:font w:name="Arial"/></w:fonts>`
|
||||||
|
),
|
||||||
|
"word/numbering.xml": xml(
|
||||||
|
`<w:numbering xmlns:w="${wordNamespace}"/>`
|
||||||
|
),
|
||||||
|
"word/settings.xml": xml(
|
||||||
|
`<w:settings xmlns:w="${wordNamespace}"/>`
|
||||||
|
),
|
||||||
|
"word/styles.xml": xml(
|
||||||
|
`<w:styles xmlns:w="${wordNamespace}"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>${styles}</w:styles>`
|
||||||
|
),
|
||||||
|
"word/_rels/document.xml.rels": xml(
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>'
|
||||||
|
),
|
||||||
|
"word/theme/theme1.xml": xml(
|
||||||
|
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/></a:minorFont></a:fontScheme></a:themeElements></a:theme>'
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user