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
@@ -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,