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
+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");
});
});