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,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;
}
}