feat: 实现 Pandoc DOCX 转换服务
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user