feat: 实现 Pandoc DOCX 转换服务

This commit is contained in:
SkyJourney
2026-07-30 15:05:50 +08:00
parent fa159d916f
commit bf43433d38
32 changed files with 2908 additions and 61 deletions
@@ -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
View File
@@ -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 验证;
- 阶段 78:依次实现 Pandoc 转换服务以及 Web/Desktop 导出交互
- 阶段 7:已实现 Pandoc 运行时、Lua 媒体映射和共享转换服务;
- 阶段 8:实现 Web/Desktop DOCX 导出交互;
- 阶段 910:完成自动化和 Word/WPS 互操作验收,再构建正式发布产物。
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
+1
View File
@@ -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
View File
@@ -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"
},
+12 -3
View File
@@ -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 冲突时以内置主题为准。
+1
View File
@@ -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
};
}
}
+18 -11
View File
@@ -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 媒体捕获计划无效");
+10
View File
@@ -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");
});
});
+2
View File
@@ -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;
+10 -3
View File
@@ -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 中必须继续作为标准文本编辑。
![普通图片](ignored-original-image.png)
```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;
```
+3 -1
View File
@@ -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"))
);
+5
View File
@@ -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;
}
}
+151
View File
@@ -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 };
}
+124
View File
@@ -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
);
}
+322
View File
@@ -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;
}
}
+50 -10
View File
@@ -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>
) {
+30 -4
View File
@@ -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>'
)
});
}