feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -23,12 +23,15 @@ function createRenderScript(request: DocxMediaCaptureRequest) {
|
||||
|
||||
export async function captureDocxMediaWithElectronWebContents(
|
||||
webContents: WebContents,
|
||||
request: DocxMediaCaptureRequest
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const plan = (await webContents.executeJavaScript(
|
||||
createRenderScript(request),
|
||||
true
|
||||
)) as DocxMediaCapturePlan;
|
||||
signal?.throwIfAborted();
|
||||
const ownsDebugger = !webContents.debugger.isAttached();
|
||||
if (ownsDebugger) {
|
||||
webContents.debugger.attach("1.3");
|
||||
@@ -37,6 +40,7 @@ export async function captureDocxMediaWithElectronWebContents(
|
||||
try {
|
||||
const captures = [];
|
||||
for (const target of plan.targets) {
|
||||
signal?.throwIfAborted();
|
||||
const result = (await webContents.debugger.sendCommand(
|
||||
"Page.captureScreenshot",
|
||||
{
|
||||
@@ -57,6 +61,7 @@ export async function captureDocxMediaWithElectronWebContents(
|
||||
png: Buffer.from(result.data, "base64")
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { plan, captures };
|
||||
} finally {
|
||||
if (ownsDebugger && webContents.debugger.isAttached()) {
|
||||
@@ -70,10 +75,11 @@ export class ElectronDocxMediaCaptureAdapter
|
||||
{
|
||||
constructor(private readonly webContents: WebContents) {}
|
||||
|
||||
capture(request: DocxMediaCaptureRequest) {
|
||||
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||
return captureDocxMediaWithElectronWebContents(
|
||||
this.webContents,
|
||||
request
|
||||
request,
|
||||
signal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,17 @@ async function renderCapturePlan(
|
||||
|
||||
export async function captureDocxMediaWithPlaywrightPage(
|
||||
page: Page,
|
||||
request: DocxMediaCaptureRequest
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const plan = await renderCapturePlan(page, request);
|
||||
signal?.throwIfAborted();
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const captures = [];
|
||||
for (const target of plan.targets) {
|
||||
signal?.throwIfAborted();
|
||||
const result = (await session.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
@@ -58,6 +62,7 @@ export async function captureDocxMediaWithPlaywrightPage(
|
||||
png: Buffer.from(result.data, "base64")
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { plan, captures };
|
||||
} finally {
|
||||
await session.detach();
|
||||
@@ -69,7 +74,11 @@ export class PlaywrightDocxMediaCaptureAdapter
|
||||
{
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
capture(request: DocxMediaCaptureRequest) {
|
||||
return captureDocxMediaWithPlaywrightPage(this.page, request);
|
||||
capture(request: DocxMediaCaptureRequest, signal?: AbortSignal) {
|
||||
return captureDocxMediaWithPlaywrightPage(
|
||||
this.page,
|
||||
request,
|
||||
signal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -106,6 +106,15 @@ Server 使用 Playwright Chromium、Desktop 使用 Electron Chromium,
|
||||
配置和元数据。本机真实 Pandoc 验收覆盖公文 A4 和自定义横向两组模板,
|
||||
输出仍由标准 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.1 项目骨架
|
||||
@@ -985,8 +994,10 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
|
||||
`apps/server` 与 `apps/desktop` 分别提供 HTTP 和 IPC 适配。
|
||||
- `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
|
||||
多窗口、同文件单例、文件变化检测和平台链接动作;`main.ts` 负责启动
|
||||
组装、协议和生命周期。
|
||||
@@ -1022,7 +1033,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
- 阶段 4:已实现 CodeMirror 6 与可扩展基础 Markdown 工具栏;
|
||||
- 阶段 5:已实现 DOCX 资源预处理和跨端 Chromium PNG 捕获适配器;
|
||||
- 阶段 6:已实现动态 reference.docx、主题样式映射和真实 Pandoc 验证;
|
||||
- 阶段 7~8:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互;
|
||||
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
|
||||
- 阶段 8:实现 Web/Desktop DOCX 导出交互;
|
||||
- 阶段 9~10:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
|
||||
|
||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||
|
||||
Generated
+1
@@ -9903,6 +9903,7 @@
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+4
-2
@@ -11,19 +11,21 @@
|
||||
"scripts": {
|
||||
"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",
|
||||
"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: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:application": "npm run dev -w @md-to-pdf/application",
|
||||
"dev:preview-engine": "npm run dev -w @md-to-pdf/preview-engine",
|
||||
"dev:server": "npm run dev -w @md-to-pdf/server",
|
||||
"dev:web": "npm run dev -w @md-to-pdf/web",
|
||||
"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",
|
||||
"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-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",
|
||||
"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
|
||||
|
||||
Web Server 与 Electron Desktop 共用的应用服务层,组合
|
||||
`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、
|
||||
主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和
|
||||
平台能力适配。
|
||||
`@md-to-pdf/core`、`@md-to-pdf/renderer` 和
|
||||
`@md-to-pdf/docx-engine`,统一处理 Markdown 渲染、主题发现、主题资源、
|
||||
图片资源和 DOCX 导出编排。Fastify 与 Electron IPC 只负责传输和平台
|
||||
能力适配。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
application-service.ts 渲染、主题和资源用例入口
|
||||
docx-export-service.ts DOCX 并发、超时、错误和完整导出编排
|
||||
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
||||
image-resources.ts 本地、Base64 与受限远程图片处理
|
||||
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
||||
@@ -17,6 +19,7 @@ src/
|
||||
tests/
|
||||
application-service.test.ts
|
||||
bundled-themes.test.ts
|
||||
docx-export-service.test.ts
|
||||
docx-media-service.test.ts
|
||||
image-resources.test.ts
|
||||
```
|
||||
@@ -53,6 +56,12 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
||||
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
||||
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
||||
|
||||
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、
|
||||
Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒;
|
||||
可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS`
|
||||
配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议
|
||||
返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。
|
||||
|
||||
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
||||
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"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 {
|
||||
capture(
|
||||
request: DocxMediaCaptureRequest
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<DocxMediaCaptureOutput>;
|
||||
}
|
||||
|
||||
@@ -161,18 +162,24 @@ function validateCaptures(
|
||||
|
||||
export async function prepareDocxMedia(
|
||||
prepared: PreparedDocxExport,
|
||||
adapter: DocxMediaCaptureAdapter
|
||||
adapter: DocxMediaCaptureAdapter,
|
||||
signal?: AbortSignal
|
||||
): Promise<PreparedDocxMedia> {
|
||||
signal?.throwIfAborted();
|
||||
const dimensions = getDocxMediaRenderDimensions(prepared);
|
||||
const output = await adapter.capture({
|
||||
payload: createPagedDocumentPayload({
|
||||
document: prepared.document,
|
||||
fileName: prepared.request.fileName,
|
||||
themeCss: prepared.theme.css,
|
||||
exportConfig: prepared.request.exportConfig
|
||||
}),
|
||||
dimensions
|
||||
});
|
||||
const output = await adapter.capture(
|
||||
{
|
||||
payload: createPagedDocumentPayload({
|
||||
document: prepared.document,
|
||||
fileName: prepared.request.fileName,
|
||||
themeCss: prepared.theme.css,
|
||||
exportConfig: prepared.request.exportConfig
|
||||
}),
|
||||
dimensions
|
||||
},
|
||||
signal
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
|
||||
if (!parsedPlan.success) {
|
||||
throw new Error("DOCX 媒体捕获计划无效");
|
||||
|
||||
@@ -30,6 +30,16 @@ export {
|
||||
type DocxMediaCaptureRequest,
|
||||
type DocxMediaRenderDimensions
|
||||
} 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 {
|
||||
createThemeRegistry,
|
||||
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_BYTES = 8 * 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 type DocumentExportFormat = z.infer<
|
||||
@@ -182,6 +183,7 @@ export type DocxExportErrorResponse = z.infer<
|
||||
>;
|
||||
|
||||
export interface DocxGenerationTimings {
|
||||
queueMs: number;
|
||||
probeMs: number;
|
||||
prepareMs: number;
|
||||
mediaMs: number;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
纯 Node.js 的 DOCX 模板和 Pandoc 编排核心。当前阶段负责读取固定 Pandoc
|
||||
版本的默认 `reference.docx`、验证 ZIP 安全边界,将主题 DOCX 样式声明
|
||||
解析为完整样式预设,并生成纸张、页边距、字体、段落、代码、表格、
|
||||
页眉、页脚和页码均已映射的动态模板。
|
||||
页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter
|
||||
将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown
|
||||
正文、标题、列表、表格、代码和公式的可编辑文档结构。
|
||||
|
||||
## 设计边界
|
||||
|
||||
@@ -12,7 +14,10 @@
|
||||
- OOXML 使用 `@xmldom/xmldom`,不拼接未经转义的用户 XML;
|
||||
- 不解析任意主题 CSS,主题通过 `docxStyle` 提供受限结构化覆盖;
|
||||
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
|
||||
- Pandoc 进程定位、超时和临时目录属于后续转换服务阶段。
|
||||
- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理;
|
||||
- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸;
|
||||
- 并发排队、跨端用例编排和错误协议映射由
|
||||
`@md-to-pdf/application` 的 `DocxExportService` 统一负责。
|
||||
|
||||
## 安全限制
|
||||
|
||||
@@ -21,6 +26,7 @@
|
||||
- 解压后总大小不超过 64 MiB;
|
||||
- 拒绝加密、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 build -w @md-to-pdf/docx-engine
|
||||
npm run verify:docx-reference
|
||||
npm run verify:docx-conversion
|
||||
```
|
||||
|
||||
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
|
||||
`MD_TO_PDF_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
|
||||
`DOCX_PANDOC_PATH` 指定可执行文件。脚本从 Pandoc 读取原始默认模板,
|
||||
分别验证公文 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": [
|
||||
"assets",
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
@@ -19,7 +20,8 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"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": {
|
||||
"@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";
|
||||
import {
|
||||
createDynamicReferenceDocx,
|
||||
validateDynamicReferenceDocx
|
||||
PandocRuntime,
|
||||
validateGeneratedDocx
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
@@ -28,8 +29,19 @@ const themePath = path.join(
|
||||
"gov-red-standard",
|
||||
"theme.json"
|
||||
);
|
||||
const pandocExecutable =
|
||||
process.env.MD_TO_PDF_PANDOC_PATH?.trim() || "pandoc";
|
||||
const runtime = new PandocRuntime(
|
||||
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 decoder = new TextDecoder();
|
||||
|
||||
@@ -64,7 +76,7 @@ function countMatches(value, pattern) {
|
||||
}
|
||||
|
||||
function inspectResult(content, expected) {
|
||||
const validation = validateDynamicReferenceDocx(content);
|
||||
const validation = validateGeneratedDocx(content);
|
||||
const entries = unzipSync(content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
@@ -174,22 +186,13 @@ function createVariant(
|
||||
};
|
||||
}
|
||||
|
||||
const versionResult = runPandoc(["--version"]);
|
||||
const detectedVersion =
|
||||
versionResult.stdout
|
||||
.split(/\r?\n/u)[0]
|
||||
?.replace(/^pandoc\s+/u, "")
|
||||
.trim() ?? "";
|
||||
const detectedVersion = runtimeResolution.capability.detectedVersion;
|
||||
assert(
|
||||
detectedVersion === DOCX_PANDOC_VERSION,
|
||||
`Pandoc 版本不匹配:期望 ${DOCX_PANDOC_VERSION},实际 ${detectedVersion || "未知"}`
|
||||
);
|
||||
|
||||
const baselineResult = runPandoc(
|
||||
["--print-default-data-file", "reference.docx"],
|
||||
{ binary: true }
|
||||
);
|
||||
const baseline = new Uint8Array(baselineResult.stdout);
|
||||
const baseline = await runtime.getDefaultReferenceDocx();
|
||||
const theme = themeManifestSchema.parse(
|
||||
JSON.parse(fs.readFileSync(themePath, "utf8"))
|
||||
);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export * from "./header-footer-transform.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-options.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_UNCOMPRESSED_BYTES =
|
||||
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 = [
|
||||
"[Content_Types].xml",
|
||||
@@ -28,6 +43,23 @@ interface ZipDirectoryEntry {
|
||||
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) {
|
||||
const view = new DataView(
|
||||
content.buffer,
|
||||
@@ -59,10 +91,13 @@ function validateEntryName(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function inspectZipDirectory(content: Uint8Array) {
|
||||
function inspectZipDirectory(
|
||||
content: Uint8Array,
|
||||
limits: DocxPackageLimits
|
||||
) {
|
||||
if (
|
||||
content.byteLength < 22 ||
|
||||
content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES
|
||||
content.byteLength > limits.maximumBytes
|
||||
) {
|
||||
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
|
||||
}
|
||||
@@ -81,7 +116,7 @@ function inspectZipDirectory(content: Uint8Array) {
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
directoryDisk !== 0 ||
|
||||
entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT ||
|
||||
entryCount > limits.maximumEntryCount ||
|
||||
entryCount === 0 ||
|
||||
directoryOffset + directorySize > endOffset
|
||||
) {
|
||||
@@ -129,7 +164,7 @@ function inspectZipDirectory(content: Uint8Array) {
|
||||
totalUncompressedBytes += uncompressedSize;
|
||||
if (
|
||||
totalUncompressedBytes >
|
||||
MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
|
||||
limits.maximumUncompressedBytes
|
||||
) {
|
||||
throw new Error("reference.docx 解压后总大小超过限制");
|
||||
}
|
||||
@@ -142,10 +177,11 @@ function inspectZipDirectory(content: Uint8Array) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function readReferenceDocxPackage(
|
||||
content: Uint8Array
|
||||
export function readDocxPackage(
|
||||
content: Uint8Array,
|
||||
limits: DocxPackageLimits
|
||||
): ReferenceDocxPackage {
|
||||
const directory = inspectZipDirectory(content);
|
||||
const directory = inspectZipDirectory(content, limits);
|
||||
const unzipped = unzipSync(content);
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
for (const descriptor of directory) {
|
||||
@@ -169,13 +205,17 @@ export function readReferenceDocxPackage(
|
||||
}
|
||||
}
|
||||
return {
|
||||
fingerprint: createHash("sha256")
|
||||
.update(content)
|
||||
.digest("hex"),
|
||||
fingerprint: fingerprintEntries(entries),
|
||||
entries
|
||||
};
|
||||
}
|
||||
|
||||
export function readReferenceDocxPackage(
|
||||
content: Uint8Array
|
||||
): ReferenceDocxPackage {
|
||||
return readDocxPackage(content, referenceDocxPackageLimits);
|
||||
}
|
||||
|
||||
export function writeReferenceDocxPackage(
|
||||
entries: ReadonlyMap<string, Uint8Array>
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
|
||||
import {
|
||||
CONTENT_TYPES_NAMESPACE,
|
||||
OFFICE_RELATIONSHIP_NAMESPACE,
|
||||
@@ -8,7 +9,12 @@ import {
|
||||
parseXmlPart,
|
||||
type XmlElement
|
||||
} 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 =
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||
@@ -263,10 +269,18 @@ function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDynamicReferenceDocx(
|
||||
content: Uint8Array
|
||||
function validateDocx(
|
||||
content: Uint8Array,
|
||||
generatedOutput: boolean
|
||||
): 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 relationshipCount = 0;
|
||||
const relationshipParts = new Map<
|
||||
@@ -345,3 +359,15 @@ export function validateDynamicReferenceDocx(
|
||||
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 { randomBytes } from "node:crypto";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
import {
|
||||
defaultExportConfig,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createDynamicReferenceDocx,
|
||||
validateGeneratedDocx,
|
||||
validateDynamicReferenceDocx,
|
||||
writeReferenceDocxPackage
|
||||
} from "../src/index.js";
|
||||
@@ -272,4 +274,27 @@ describe("动态 reference.docx", () => {
|
||||
/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";
|
||||
|
||||
function createReferencePackage(
|
||||
overrides: Record<string, Uint8Array> = {}
|
||||
overrides: Record<string, Uint8Array> = {},
|
||||
mtime = new Date("2020-01-01T00:00:00.000Z")
|
||||
) {
|
||||
const entries = Object.fromEntries(
|
||||
requiredReferenceDocxParts.map((name) => [
|
||||
@@ -14,14 +15,25 @@ function createReferencePackage(
|
||||
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 包", () => {
|
||||
it("预检中央目录并返回稳定指纹", () => {
|
||||
const content = createReferencePackage();
|
||||
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.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