feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
# @md-to-pdf/application
|
||||
|
||||
Web Server 与 Electron Desktop 共用的应用服务层,组合
|
||||
`@md-to-pdf/core` 和 `@md-to-pdf/renderer`,统一处理 Markdown 渲染、
|
||||
主题发现、主题资源和图片资源。Fastify 与 Electron IPC 只负责传输和
|
||||
平台能力适配。
|
||||
`@md-to-pdf/core`、`@md-to-pdf/renderer` 和
|
||||
`@md-to-pdf/docx-engine`,统一处理 Markdown 渲染、主题发现、主题资源、
|
||||
图片资源和 DOCX 导出编排。Fastify 与 Electron IPC 只负责传输和平台
|
||||
能力适配。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
application-service.ts 渲染、主题和资源用例入口
|
||||
docx-export-service.ts DOCX 并发、超时、错误和完整导出编排
|
||||
docx-media-service.ts DOCX 媒体尺寸计算、PNG 校验与清单组装
|
||||
image-resources.ts 本地、Base64 与受限远程图片处理
|
||||
theme-registry.ts 内置及自定义主题扫描、校验与缓存
|
||||
@@ -17,6 +19,7 @@ src/
|
||||
tests/
|
||||
application-service.test.ts
|
||||
bundled-themes.test.ts
|
||||
docx-export-service.test.ts
|
||||
docx-media-service.test.ts
|
||||
image-resources.test.ts
|
||||
```
|
||||
@@ -53,6 +56,12 @@ Desktop 才会以 Markdown 所在目录为边界解析相对资源。
|
||||
大小、总大小和媒体 ID。最终按文档顺序输出稳定的 `media-001.png`
|
||||
清单;它不依赖 Playwright 或 Electron,平台代码只负责 Chromium 捕获。
|
||||
|
||||
`DocxExportService` 串联 capability 探测、请求准备、平台媒体捕获、
|
||||
Pandoc 转换和最终 OOXML 校验。默认并发为 1、队列为 4、总超时为 90 秒;
|
||||
可以通过 `DOCX_CONCURRENCY`、`DOCX_MAX_QUEUE` 和 `DOCX_TIMEOUT_MS`
|
||||
配置。排队、探测、准备、媒体、模板、Pandoc、校验和总耗时使用共享协议
|
||||
返回;关闭服务时会取消活动任务、拒绝排队任务并等待清理完成。
|
||||
|
||||
内置主题来自仓库 `themes/`,当前名称为 Typora Github、
|
||||
Typora Pixyll、Typora whitey 和 Typora Clean。额外主题从平台传入的
|
||||
本地主题根目录扫描;与内置主题 ID 冲突时以内置主题为准。
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/docx-engine": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import {
|
||||
createDocxFileName,
|
||||
type DocxCapability,
|
||||
type DocxExportErrorCode,
|
||||
type DocxExportResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PandocDocxConversionError,
|
||||
type PandocDocxConversionInput,
|
||||
type PandocDocxConversionResult,
|
||||
type PandocRuntimeResolution
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import {
|
||||
ApplicationRequestError,
|
||||
type ApplicationService,
|
||||
type PreparedDocxExport
|
||||
} from "./application-service.js";
|
||||
import {
|
||||
prepareDocxMedia,
|
||||
type DocxMediaCaptureAdapter
|
||||
} from "./docx-media-service.js";
|
||||
import type { ImageResolutionContext } from "./image-resources.js";
|
||||
|
||||
const DEFAULT_DOCX_CONCURRENCY = 1;
|
||||
const DEFAULT_DOCX_MAX_QUEUE = 4;
|
||||
const DEFAULT_DOCX_TIMEOUT_MS = 90_000;
|
||||
|
||||
export interface DocxExportRuntimeLimits {
|
||||
concurrency: number;
|
||||
maxQueue: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface DocxCapabilityProvider {
|
||||
probe(): Promise<PandocRuntimeResolution>;
|
||||
}
|
||||
|
||||
export interface DocxConversionPort {
|
||||
convert(
|
||||
input: PandocDocxConversionInput,
|
||||
signal?: AbortSignal
|
||||
): Promise<PandocDocxConversionResult>;
|
||||
}
|
||||
|
||||
export interface DocxExportServiceOptions {
|
||||
application: Pick<ApplicationService, "prepareDocxExport">;
|
||||
runtime: DocxCapabilityProvider;
|
||||
converter: DocxConversionPort;
|
||||
limits?: Partial<DocxExportRuntimeLimits>;
|
||||
}
|
||||
|
||||
export interface GenerateDocxOptions {
|
||||
mediaAdapter: DocxMediaCaptureAdapter;
|
||||
imageContext?: ImageResolutionContext;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class DocxExportServiceError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: 429 | 500 | 503 | 504,
|
||||
readonly code: DocxExportErrorCode,
|
||||
message: string,
|
||||
readonly retryable: boolean,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "DocxExportServiceError";
|
||||
}
|
||||
}
|
||||
|
||||
class DocxQueueFullError extends Error {}
|
||||
class DocxServiceClosedError extends Error {}
|
||||
|
||||
interface QueueEntry {
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
signal: AbortSignal;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
class DocxConcurrencyGate {
|
||||
private active = 0;
|
||||
private closed = false;
|
||||
private readonly queue: QueueEntry[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly limit: number,
|
||||
private readonly maxQueue: number
|
||||
) {}
|
||||
|
||||
async run<T>(
|
||||
task: () => Promise<T>,
|
||||
signal: AbortSignal
|
||||
): Promise<T> {
|
||||
await this.acquire(signal);
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
for (const entry of this.queue.splice(0)) {
|
||||
entry.signal.removeEventListener("abort", entry.abort);
|
||||
entry.reject(new DocxServiceClosedError());
|
||||
}
|
||||
}
|
||||
|
||||
private acquire(signal: AbortSignal) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new DocxServiceClosedError());
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(signal.reason);
|
||||
}
|
||||
if (this.active < this.limit) {
|
||||
this.active += 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this.queue.length >= this.maxQueue) {
|
||||
return Promise.reject(new DocxQueueFullError());
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const entry: QueueEntry = {
|
||||
resolve,
|
||||
reject,
|
||||
signal,
|
||||
abort: () => {
|
||||
const index = this.queue.indexOf(entry);
|
||||
if (index >= 0) {
|
||||
this.queue.splice(index, 1);
|
||||
}
|
||||
reject(signal.reason);
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", entry.abort, { once: true });
|
||||
this.queue.push(entry);
|
||||
});
|
||||
}
|
||||
|
||||
private release() {
|
||||
this.active -= 1;
|
||||
while (this.queue.length) {
|
||||
const entry = this.queue.shift()!;
|
||||
entry.signal.removeEventListener("abort", entry.abort);
|
||||
if (entry.signal.aborted) {
|
||||
entry.reject(entry.signal.reason);
|
||||
continue;
|
||||
}
|
||||
this.active += 1;
|
||||
entry.resolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readIntegerEnvironment(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number
|
||||
) {
|
||||
const raw = environment[name];
|
||||
if (raw === undefined || raw.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
if (!/^\d+$/u.test(raw)) {
|
||||
throw new Error(`${name} 必须是整数`);
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||
throw new Error(`${name} 不能小于 ${minimum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readDocxExportRuntimeLimits(
|
||||
environment: NodeJS.ProcessEnv = process.env
|
||||
): DocxExportRuntimeLimits {
|
||||
return {
|
||||
concurrency: readIntegerEnvironment(
|
||||
environment,
|
||||
"DOCX_CONCURRENCY",
|
||||
DEFAULT_DOCX_CONCURRENCY,
|
||||
1
|
||||
),
|
||||
maxQueue: readIntegerEnvironment(
|
||||
environment,
|
||||
"DOCX_MAX_QUEUE",
|
||||
DEFAULT_DOCX_MAX_QUEUE,
|
||||
0
|
||||
),
|
||||
timeoutMs: readIntegerEnvironment(
|
||||
environment,
|
||||
"DOCX_TIMEOUT_MS",
|
||||
DEFAULT_DOCX_TIMEOUT_MS,
|
||||
1_000
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLimits(
|
||||
values: Partial<DocxExportRuntimeLimits> = {}
|
||||
): DocxExportRuntimeLimits {
|
||||
const limits = {
|
||||
concurrency: values.concurrency ?? DEFAULT_DOCX_CONCURRENCY,
|
||||
maxQueue: values.maxQueue ?? DEFAULT_DOCX_MAX_QUEUE,
|
||||
timeoutMs: values.timeoutMs ?? DEFAULT_DOCX_TIMEOUT_MS
|
||||
};
|
||||
if (!Number.isInteger(limits.concurrency) || limits.concurrency < 1) {
|
||||
throw new Error("DOCX 并发数必须是正整数");
|
||||
}
|
||||
if (!Number.isInteger(limits.maxQueue) || limits.maxQueue < 0) {
|
||||
throw new Error("DOCX 排队上限必须是非负整数");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(limits.timeoutMs) ||
|
||||
limits.timeoutMs < 10 ||
|
||||
limits.timeoutMs > 10 * 60_000
|
||||
) {
|
||||
throw new Error("DOCX 总超时必须在 10ms 到 10 分钟之间");
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
function capabilityError(capability: Exclude<
|
||||
DocxCapability,
|
||||
{ status: "available" }
|
||||
>) {
|
||||
const mapping = {
|
||||
"not-found": "DOCX_RUNTIME_NOT_FOUND",
|
||||
"version-mismatch": "DOCX_RUNTIME_VERSION_MISMATCH",
|
||||
"not-executable": "DOCX_RUNTIME_NOT_EXECUTABLE",
|
||||
"probe-timeout": "DOCX_RUNTIME_PROBE_TIMEOUT"
|
||||
} as const;
|
||||
return new DocxExportServiceError(
|
||||
capability.status === "probe-timeout" ? 504 : 503,
|
||||
mapping[capability.status],
|
||||
capability.message,
|
||||
capability.status === "probe-timeout"
|
||||
);
|
||||
}
|
||||
|
||||
function conversionError(error: PandocDocxConversionError) {
|
||||
const statusCode =
|
||||
error.code === "DOCX_RENDER_TIMEOUT"
|
||||
? 504
|
||||
: error.code === "DOCX_RUNTIME_NOT_FOUND"
|
||||
? 503
|
||||
: 500;
|
||||
return new DocxExportServiceError(
|
||||
statusCode,
|
||||
error.code,
|
||||
error.message,
|
||||
error.code === "DOCX_RENDER_TIMEOUT",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
export class DocxExportService {
|
||||
private readonly limits: DocxExportRuntimeLimits;
|
||||
private readonly gate: DocxConcurrencyGate;
|
||||
private readonly controllers = new Set<AbortController>();
|
||||
private readonly jobs = new Set<Promise<unknown>>();
|
||||
private closePromise: Promise<void> | undefined;
|
||||
private closed = false;
|
||||
|
||||
constructor(private readonly options: DocxExportServiceOptions) {
|
||||
this.limits = resolveLimits(options.limits);
|
||||
this.gate = new DocxConcurrencyGate(
|
||||
this.limits.concurrency,
|
||||
this.limits.maxQueue
|
||||
);
|
||||
}
|
||||
|
||||
async getCapability() {
|
||||
return (await this.options.runtime.probe()).capability;
|
||||
}
|
||||
|
||||
generate(
|
||||
request: unknown,
|
||||
options: GenerateDocxOptions
|
||||
): Promise<DocxExportResult> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(
|
||||
new DocxExportServiceError(
|
||||
503,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出服务已关闭",
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
const job = this.runGeneration(request, options);
|
||||
this.jobs.add(job);
|
||||
void job.finally(() => this.jobs.delete(job)).catch(() => undefined);
|
||||
return job;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closePromise ??= this.closeInternal();
|
||||
return this.closePromise;
|
||||
}
|
||||
|
||||
private async closeInternal() {
|
||||
this.closed = true;
|
||||
this.gate.close();
|
||||
for (const controller of this.controllers) {
|
||||
controller.abort(new DocxServiceClosedError());
|
||||
}
|
||||
await Promise.allSettled([...this.jobs]);
|
||||
}
|
||||
|
||||
private async runGeneration(
|
||||
request: unknown,
|
||||
options: GenerateDocxOptions
|
||||
) {
|
||||
const totalStarted = performance.now();
|
||||
const queuedAt = performance.now();
|
||||
const controller = new AbortController();
|
||||
this.controllers.add(controller);
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort(new Error("DOCX 总超时"));
|
||||
}, this.limits.timeoutMs);
|
||||
timeout.unref();
|
||||
const abortFromCaller = () =>
|
||||
controller.abort(options.signal?.reason);
|
||||
options.signal?.addEventListener("abort", abortFromCaller, {
|
||||
once: true
|
||||
});
|
||||
if (options.signal?.aborted) {
|
||||
abortFromCaller();
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.gate.run(async () => {
|
||||
const queueMs = performance.now() - queuedAt;
|
||||
controller.signal.throwIfAborted();
|
||||
|
||||
const probeStarted = performance.now();
|
||||
const resolution = await this.options.runtime.probe();
|
||||
const probeMs = performance.now() - probeStarted;
|
||||
if (resolution.capability.status !== "available") {
|
||||
throw capabilityError(resolution.capability);
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
|
||||
const prepareStarted = performance.now();
|
||||
const prepared =
|
||||
await this.options.application.prepareDocxExport(
|
||||
request,
|
||||
options.imageContext ?? {}
|
||||
);
|
||||
const prepareMs = performance.now() - prepareStarted;
|
||||
controller.signal.throwIfAborted();
|
||||
|
||||
const mediaStarted = performance.now();
|
||||
const media = await prepareDocxMedia(
|
||||
prepared,
|
||||
options.mediaAdapter,
|
||||
controller.signal
|
||||
);
|
||||
const mediaMs = performance.now() - mediaStarted;
|
||||
controller.signal.throwIfAborted();
|
||||
|
||||
const conversion = await this.options.converter.convert(
|
||||
this.createConversionInput(prepared, media),
|
||||
controller.signal
|
||||
);
|
||||
return {
|
||||
docx: conversion.docx,
|
||||
fileName: createDocxFileName(prepared.request.fileName),
|
||||
diagnostics: {
|
||||
warnings: media.warnings,
|
||||
echartsErrors: media.echartsErrors,
|
||||
mermaidErrors: media.mermaidErrors
|
||||
},
|
||||
timings: {
|
||||
queueMs,
|
||||
probeMs,
|
||||
prepareMs,
|
||||
mediaMs,
|
||||
referenceMs: conversion.timings.referenceMs,
|
||||
pandocMs: conversion.timings.pandocMs,
|
||||
validationMs: conversion.timings.validationMs,
|
||||
totalMs: performance.now() - totalStarted
|
||||
}
|
||||
};
|
||||
}, controller.signal);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ApplicationRequestError ||
|
||||
error instanceof DocxExportServiceError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof PandocDocxConversionError) {
|
||||
throw conversionError(error);
|
||||
}
|
||||
if (error instanceof DocxQueueFullError) {
|
||||
throw new DocxExportServiceError(
|
||||
429,
|
||||
"DOCX_QUEUE_FULL",
|
||||
"DOCX 生成队列已满",
|
||||
true
|
||||
);
|
||||
}
|
||||
if (error instanceof DocxServiceClosedError) {
|
||||
throw new DocxExportServiceError(
|
||||
503,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出服务已关闭",
|
||||
false
|
||||
);
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
throw new DocxExportServiceError(
|
||||
504,
|
||||
"DOCX_RENDER_TIMEOUT",
|
||||
timedOut ? "DOCX 导出超过总超时" : "DOCX 导出已取消",
|
||||
timedOut,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw new DocxExportServiceError(
|
||||
500,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出失败",
|
||||
false,
|
||||
{ cause: error }
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
options.signal?.removeEventListener(
|
||||
"abort",
|
||||
abortFromCaller
|
||||
);
|
||||
this.controllers.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
private createConversionInput(
|
||||
prepared: PreparedDocxExport,
|
||||
media: Awaited<ReturnType<typeof prepareDocxMedia>>
|
||||
): PandocDocxConversionInput {
|
||||
return {
|
||||
markdown: prepared.request.markdown,
|
||||
fileName: prepared.request.fileName,
|
||||
language: prepared.request.language,
|
||||
exportConfig: prepared.request.exportConfig,
|
||||
theme: prepared.theme.manifest,
|
||||
metadata: prepared.document.metadata,
|
||||
media
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ export interface DocxMediaCaptureOutput {
|
||||
|
||||
export interface DocxMediaCaptureAdapter {
|
||||
capture(
|
||||
request: DocxMediaCaptureRequest
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<DocxMediaCaptureOutput>;
|
||||
}
|
||||
|
||||
@@ -161,18 +162,24 @@ function validateCaptures(
|
||||
|
||||
export async function prepareDocxMedia(
|
||||
prepared: PreparedDocxExport,
|
||||
adapter: DocxMediaCaptureAdapter
|
||||
adapter: DocxMediaCaptureAdapter,
|
||||
signal?: AbortSignal
|
||||
): Promise<PreparedDocxMedia> {
|
||||
signal?.throwIfAborted();
|
||||
const dimensions = getDocxMediaRenderDimensions(prepared);
|
||||
const output = await adapter.capture({
|
||||
payload: createPagedDocumentPayload({
|
||||
document: prepared.document,
|
||||
fileName: prepared.request.fileName,
|
||||
themeCss: prepared.theme.css,
|
||||
exportConfig: prepared.request.exportConfig
|
||||
}),
|
||||
dimensions
|
||||
});
|
||||
const output = await adapter.capture(
|
||||
{
|
||||
payload: createPagedDocumentPayload({
|
||||
document: prepared.document,
|
||||
fileName: prepared.request.fileName,
|
||||
themeCss: prepared.theme.css,
|
||||
exportConfig: prepared.request.exportConfig
|
||||
}),
|
||||
dimensions
|
||||
},
|
||||
signal
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const parsedPlan = docxMediaCapturePlanSchema.safeParse(output.plan);
|
||||
if (!parsedPlan.success) {
|
||||
throw new Error("DOCX 媒体捕获计划无效");
|
||||
|
||||
@@ -30,6 +30,16 @@ export {
|
||||
type DocxMediaCaptureRequest,
|
||||
type DocxMediaRenderDimensions
|
||||
} from "./docx-media-service.js";
|
||||
export {
|
||||
DocxExportService,
|
||||
DocxExportServiceError,
|
||||
readDocxExportRuntimeLimits,
|
||||
type DocxCapabilityProvider,
|
||||
type DocxConversionPort,
|
||||
type DocxExportRuntimeLimits,
|
||||
type DocxExportServiceOptions,
|
||||
type GenerateDocxOptions
|
||||
} from "./docx-export-service.js";
|
||||
export {
|
||||
createThemeRegistry,
|
||||
type ThemeRecord,
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defaultExportConfig,
|
||||
type DocxCapability,
|
||||
type PreparedDocxMedia,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
import type {
|
||||
PandocDocxConversionInput,
|
||||
PandocDocxConversionResult,
|
||||
PandocRuntimeResolution
|
||||
} from "@md-to-pdf/docx-engine";
|
||||
import {
|
||||
DocxExportService,
|
||||
readDocxExportRuntimeLimits,
|
||||
type DocxMediaCaptureAdapter,
|
||||
type PreparedDocxExport
|
||||
} from "../src/index.js";
|
||||
|
||||
function theme(): ThemeManifest {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "test-theme",
|
||||
name: "测试主题",
|
||||
version: "1.0.0",
|
||||
description: "测试",
|
||||
author: "测试",
|
||||
license: "内部许可",
|
||||
entry: "theme.css",
|
||||
domPreset: "generic",
|
||||
defaultFontSize: "16px",
|
||||
supportedFeatures: [],
|
||||
category: "general",
|
||||
compatibleProfiles: [],
|
||||
docxStyle: { preset: "technical" },
|
||||
bundled: true
|
||||
};
|
||||
}
|
||||
|
||||
const prepared: PreparedDocxExport = {
|
||||
request: {
|
||||
markdown: "# 测试",
|
||||
fileName: "报告?.md",
|
||||
language: "zh-CN",
|
||||
resources: [],
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
themeId: "test-theme"
|
||||
}
|
||||
},
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||
bodyHtml: "<h1>测试</h1>",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "测试人",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
theme: {
|
||||
manifest: theme(),
|
||||
source: "bundled",
|
||||
css: ""
|
||||
}
|
||||
};
|
||||
|
||||
const availableCapability: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "available",
|
||||
expectedVersion: "3.9.0.2",
|
||||
detectedVersion: "3.9.0.2"
|
||||
};
|
||||
|
||||
function resolution(
|
||||
capability: DocxCapability = availableCapability
|
||||
): PandocRuntimeResolution {
|
||||
return capability.status === "available"
|
||||
? { capability, executablePath: "pandoc" }
|
||||
: { capability };
|
||||
}
|
||||
|
||||
const emptyAdapter: DocxMediaCaptureAdapter = {
|
||||
async capture() {
|
||||
return {
|
||||
plan: {
|
||||
targets: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
},
|
||||
captures: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function conversionResult(): PandocDocxConversionResult {
|
||||
return {
|
||||
docx: new Uint8Array([1, 2, 3]),
|
||||
templateFingerprint: "a".repeat(64),
|
||||
templateCacheKey: "b".repeat(64),
|
||||
validation: {
|
||||
partCount: 10,
|
||||
xmlPartCount: 9,
|
||||
relationshipCount: 3,
|
||||
headerCount: 0,
|
||||
footerCount: 1
|
||||
},
|
||||
timings: {
|
||||
referenceMs: 3,
|
||||
pandocMs: 4,
|
||||
validationMs: 5
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createService(options: {
|
||||
convert?: (
|
||||
input: PandocDocxConversionInput,
|
||||
signal?: AbortSignal
|
||||
) => Promise<PandocDocxConversionResult>;
|
||||
capability?: DocxCapability;
|
||||
limits?: {
|
||||
concurrency: number;
|
||||
maxQueue: number;
|
||||
timeoutMs: number;
|
||||
};
|
||||
}) {
|
||||
return new DocxExportService({
|
||||
application: {
|
||||
prepareDocxExport: vi.fn().mockResolvedValue(prepared)
|
||||
},
|
||||
runtime: {
|
||||
probe: vi
|
||||
.fn()
|
||||
.mockResolvedValue(resolution(options.capability))
|
||||
},
|
||||
converter: {
|
||||
convert:
|
||||
options.convert ??
|
||||
vi.fn().mockResolvedValue(conversionResult())
|
||||
},
|
||||
limits: options.limits
|
||||
});
|
||||
}
|
||||
|
||||
describe("DOCX 共享导出服务", () => {
|
||||
it("串联准备、媒体和转换并返回完整耗时", async () => {
|
||||
const service = createService({});
|
||||
const result = await service.generate(
|
||||
{ markdown: "# 测试" },
|
||||
{ mediaAdapter: emptyAdapter }
|
||||
);
|
||||
|
||||
expect(result.docx).toEqual(new Uint8Array([1, 2, 3]));
|
||||
expect(result.fileName).toBe("报告_.docx");
|
||||
expect(result.diagnostics).toEqual({
|
||||
warnings: [],
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
});
|
||||
expect(result.timings).toMatchObject({
|
||||
referenceMs: 3,
|
||||
pandocMs: 4,
|
||||
validationMs: 5
|
||||
});
|
||||
for (const value of Object.values(result.timings)) {
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("限制并发和排队长度", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
const firstBlocked = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
let calls = 0;
|
||||
const service = createService({
|
||||
limits: {
|
||||
concurrency: 1,
|
||||
maxQueue: 1,
|
||||
timeoutMs: 5_000
|
||||
},
|
||||
convert: async () => {
|
||||
calls += 1;
|
||||
active += 1;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
if (calls === 1) {
|
||||
await firstBlocked;
|
||||
}
|
||||
active -= 1;
|
||||
return conversionResult();
|
||||
}
|
||||
});
|
||||
|
||||
const first = service.generate({}, { mediaAdapter: emptyAdapter });
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
const second = service.generate({}, { mediaAdapter: emptyAdapter });
|
||||
const third = service.generate({}, { mediaAdapter: emptyAdapter });
|
||||
await expect(third).rejects.toMatchObject({
|
||||
code: "DOCX_QUEUE_FULL",
|
||||
retryable: true
|
||||
});
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(maximumActive).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("总超时会传播取消信号并释放任务", async () => {
|
||||
const abortAwareAdapter: DocxMediaCaptureAdapter = {
|
||||
capture(_request, signal) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(signal.reason),
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
const service = createService({
|
||||
limits: {
|
||||
concurrency: 1,
|
||||
maxQueue: 0,
|
||||
timeoutMs: 30
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.generate({}, { mediaAdapter: abortAwareAdapter })
|
||||
).rejects.toMatchObject({
|
||||
code: "DOCX_RENDER_TIMEOUT",
|
||||
retryable: true
|
||||
});
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("映射 capability 错误并解析运行限制", async () => {
|
||||
const unavailable: DocxCapability = {
|
||||
format: "docx",
|
||||
status: "version-mismatch",
|
||||
expectedVersion: "3.9.0.2",
|
||||
detectedVersion: "3.9.0.1",
|
||||
message: "版本不匹配"
|
||||
};
|
||||
const service = createService({ capability: unavailable });
|
||||
await expect(
|
||||
service.generate({}, { mediaAdapter: emptyAdapter })
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 503,
|
||||
code: "DOCX_RUNTIME_VERSION_MISMATCH"
|
||||
});
|
||||
await service.close();
|
||||
|
||||
expect(
|
||||
readDocxExportRuntimeLimits({
|
||||
DOCX_CONCURRENCY: "2",
|
||||
DOCX_MAX_QUEUE: "6",
|
||||
DOCX_TIMEOUT_MS: "120000"
|
||||
})
|
||||
).toEqual({
|
||||
concurrency: 2,
|
||||
maxQueue: 6,
|
||||
timeoutMs: 120_000
|
||||
});
|
||||
expect(() =>
|
||||
readDocxExportRuntimeLimits({ DOCX_CONCURRENCY: "0" })
|
||||
).toThrow("不能小于 1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user