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
+5
View File
@@ -1,5 +1,10 @@
export * from "./header-footer-transform.js";
export * from "./ooxml.js";
export * from "./pandoc-process.js";
export * from "./pandoc-media.js";
export * from "./pandoc-converter.js";
export * from "./pandoc-runtime-manifest.js";
export * from "./pandoc-runtime.js";
export * from "./reference-builder.js";
export * from "./reference-options.js";
export * from "./reference-package.js";
@@ -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;
}
}
+151
View File
@@ -0,0 +1,151 @@
import {
DOCX_MEDIA_CSS_DPI,
MAXIMUM_DOCX_MEDIA_BYTES,
MAXIMUM_DOCX_MEDIA_EDGE_PIXELS,
MAXIMUM_DOCX_MEDIA_PIXELS,
MAXIMUM_DOCX_RESOURCE_COUNT,
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
type DocxMediaKind,
type PreparedDocxMedia
} from "@md-to-pdf/core";
export interface PandocMediaMapItem {
id: string;
path: string;
alt_text: string;
caption?: string;
width: string;
height: string;
}
export interface PandocMediaMap {
image: PandocMediaMapItem[];
mermaid: PandocMediaMapItem[];
echarts: PandocMediaMapItem[];
}
export interface PandocMediaFile {
relativePath: string;
content: Uint8Array;
}
export interface PreparedPandocMedia {
map: PandocMediaMap;
files: PandocMediaFile[];
}
function pixelsToMillimeters(value: number) {
return (value * 25.4) / DOCX_MEDIA_CSS_DPI;
}
function physicalLength(value: number) {
if (!Number.isFinite(value) || value <= 0 || value > 10_000) {
throw new Error("DOCX 媒体显示尺寸无效");
}
return `${pixelsToMillimeters(value).toFixed(3)}mm`;
}
function assertPng(content: Uint8Array) {
const signature = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
];
if (
content.byteLength < 24 ||
signature.some((value, index) => content[index] !== value) ||
String.fromCharCode(...content.slice(12, 16)) !== "IHDR"
) {
throw new Error("DOCX 媒体不是有效 PNG");
}
const view = new DataView(
content.buffer,
content.byteOffset,
content.byteLength
);
const width = view.getUint32(16);
const height = view.getUint32(20);
if (
width < 1 ||
height < 1 ||
width > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
height > MAXIMUM_DOCX_MEDIA_EDGE_PIXELS ||
width * height > MAXIMUM_DOCX_MEDIA_PIXELS
) {
throw new Error("DOCX PNG 媒体像素尺寸超过限制");
}
return { width, height };
}
export function preparePandocMedia(
media: PreparedDocxMedia
): PreparedPandocMedia {
if (media.echartsErrors.length || media.mermaidErrors.length) {
throw new Error("DOCX 图表渲染存在错误,已中止转换");
}
if (media.resources.length > MAXIMUM_DOCX_RESOURCE_COUNT) {
throw new Error("DOCX 媒体数量超过限制");
}
const map: PandocMediaMap = {
image: [],
mermaid: [],
echarts: []
};
const files: PandocMediaFile[] = [];
const ids = new Set<string>();
let totalBytes = 0;
const kindCounts: Record<DocxMediaKind, number> = {
image: 0,
mermaid: 0,
echarts: 0
};
for (const [index, resource] of media.resources.entries()) {
if (ids.has(resource.id)) {
throw new Error(`DOCX 媒体 ID 重复:${resource.id}`);
}
ids.add(resource.id);
kindCounts[resource.kind] += 1;
if (
resource.ordinal !== index + 1 ||
resource.kindOrdinal !== kindCounts[resource.kind]
) {
throw new Error("DOCX 媒体顺序与捕获计划不一致");
}
if (resource.content.byteLength > MAXIMUM_DOCX_MEDIA_BYTES) {
throw new Error(`DOCX 媒体 ${resource.id} 超过单文件大小限制`);
}
totalBytes += resource.content.byteLength;
if (totalBytes > MAXIMUM_DOCX_TOTAL_MEDIA_BYTES) {
throw new Error("DOCX PNG 媒体总大小超过限制");
}
const dimensions = assertPng(resource.content);
if (
dimensions.width !== resource.pixelWidth ||
dimensions.height !== resource.pixelHeight
) {
throw new Error(
`DOCX 媒体 ${resource.id} 的 PNG 像素声明不一致`
);
}
const relativePath = `media/media-${String(
resource.ordinal
).padStart(3, "0")}.png`;
map[resource.kind].push({
id: resource.id,
path: relativePath,
alt_text: resource.altText || `${resource.kind} 图片`,
...(resource.caption
? { caption: resource.caption }
: {}),
width: physicalLength(resource.displayWidthPx),
height: physicalLength(resource.displayHeightPx)
});
files.push({
relativePath,
content: resource.content
});
}
if (media.totalBytes !== totalBytes) {
throw new Error("DOCX 媒体总大小声明不一致");
}
return { map, files };
}
+124
View File
@@ -0,0 +1,124 @@
import { spawn } from "node:child_process";
export type PandocProcessOutcome =
| "completed"
| "not-found"
| "not-executable"
| "timeout"
| "aborted"
| "output-limit";
export interface PandocProcessResult {
outcome: PandocProcessOutcome;
exitCode: number | null;
stdout: Uint8Array;
stderr: string;
}
export interface PandocProcessOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs: number;
maxStdoutBytes: number;
maxStderrBytes: number;
signal?: AbortSignal;
}
export type PandocProcessRunner = (
executablePath: string,
arguments_: readonly string[],
options: PandocProcessOptions
) => Promise<PandocProcessResult>;
function decodeStderr(chunks: Buffer[]) {
return Buffer.concat(chunks).toString("utf8");
}
export const runPandocProcess: PandocProcessRunner = (
executablePath,
arguments_,
options
) =>
new Promise((resolve) => {
const child = spawn(executablePath, [...arguments_], {
cwd: options.cwd,
env: options.env,
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"]
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let settled = false;
let forcedOutcome: PandocProcessOutcome | undefined;
const settle = (
outcome: PandocProcessOutcome,
exitCode: number | null
) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
options.signal?.removeEventListener("abort", abort);
resolve({
outcome,
exitCode,
stdout: Buffer.concat(stdout),
stderr: decodeStderr(stderr)
});
};
const stop = (outcome: PandocProcessOutcome) => {
if (settled || forcedOutcome) {
return;
}
forcedOutcome = outcome;
child.kill();
};
const abort = () => stop("aborted");
const timeout = setTimeout(
() => stop("timeout"),
options.timeoutMs
);
timeout.unref();
if (options.signal?.aborted) {
abort();
} else {
options.signal?.addEventListener("abort", abort, {
once: true
});
}
child.stdout.on("data", (chunk: Buffer) => {
stdoutBytes += chunk.byteLength;
if (stdoutBytes > options.maxStdoutBytes) {
stop("output-limit");
return;
}
stdout.push(chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBytes += chunk.byteLength;
if (stderrBytes > options.maxStderrBytes) {
stop("output-limit");
return;
}
stderr.push(chunk);
});
child.on("error", (error: NodeJS.ErrnoException) => {
const outcome =
error.code === "ENOENT"
? "not-found"
: error.code === "EACCES" || error.code === "EPERM"
? "not-executable"
: "not-executable";
settle(outcome, null);
});
child.on("close", (exitCode) => {
settle(forcedOutcome ?? "completed", exitCode);
});
});
@@ -0,0 +1,54 @@
import { DOCX_PANDOC_VERSION } from "@md-to-pdf/core";
export interface PandocRuntimeArtifact {
platform: "win32" | "linux";
architecture: "x64";
archiveType: "zip" | "tar.gz";
downloadUrl: string;
sha256: string;
executableRelativePath: string;
licenseFiles: readonly string[];
}
export const pandocRuntimeManifest = {
version: DOCX_PANDOC_VERSION,
license: "GPL-2.0-or-later",
projectUrl: "https://pandoc.org/",
sourceArchiveUrl:
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-source.tar.gz",
artifacts: [
{
platform: "win32",
architecture: "x64",
archiveType: "zip",
downloadUrl:
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-windows-x86_64.zip",
sha256:
"C97542F2800F446E788D9F74237856D995421AD1BB3CC8324286840C5F272D3A",
executableRelativePath: "pandoc.exe",
licenseFiles: ["COPYING.rtf", "COPYRIGHT.txt"]
},
{
platform: "linux",
architecture: "x64",
archiveType: "tar.gz",
downloadUrl:
"https://github.com/jgm/pandoc/releases/download/3.9.0.2/pandoc-3.9.0.2-linux-amd64.tar.gz",
sha256:
"A69ABFABABDA8A56969A254B09F9553A7BE89DDEC00D4E0FE9FD585D71A67508",
executableRelativePath: "bin/pandoc",
licenseFiles: ["COPYING.md", "COPYRIGHT"]
}
] satisfies readonly PandocRuntimeArtifact[]
} as const;
export function findPandocRuntimeArtifact(
platform: NodeJS.Platform,
architecture: string
) {
return pandocRuntimeManifest.artifacts.find(
(artifact) =>
artifact.platform === platform &&
artifact.architecture === architecture
);
}
+322
View File
@@ -0,0 +1,322 @@
import path from "node:path";
import {
DOCX_PANDOC_VERSION,
type DocxCapability,
type DocxRuntimeStatus
} from "@md-to-pdf/core";
import {
MAXIMUM_REFERENCE_DOCX_BYTES,
readReferenceDocxPackage
} from "./reference-package.js";
import {
runPandocProcess,
type PandocProcessOptions,
type PandocProcessResult,
type PandocProcessRunner
} from "./pandoc-process.js";
const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
const DEFAULT_REFERENCE_TIMEOUT_MS = 5_000;
const MAXIMUM_PROBE_OUTPUT_BYTES = 16 * 1024;
export interface PandocRuntimeResolution {
capability: DocxCapability;
executablePath?: string;
}
export interface PandocRuntimeOptions {
configuredPath?: string;
desktopResourcesPath?: string;
platform?: NodeJS.Platform;
architecture?: string;
environment?: NodeJS.ProcessEnv;
probeTimeoutMs?: number;
referenceTimeoutMs?: number;
runner?: PandocProcessRunner;
}
interface RuntimeCandidate {
executablePath: string;
exclusive: boolean;
}
export class PandocRuntimeUnavailableError extends Error {
constructor(
readonly status: Exclude<DocxRuntimeStatus, "available">,
message: string
) {
super(message);
this.name = "PandocRuntimeUnavailableError";
}
}
function unavailableCapability(
status: Exclude<DocxRuntimeStatus, "available">,
message: string,
detectedVersion?: string
): DocxCapability {
return {
format: "docx",
status,
expectedVersion: DOCX_PANDOC_VERSION,
...(detectedVersion ? { detectedVersion } : {}),
message
};
}
function availableCapability(): DocxCapability {
return {
format: "docx",
status: "available",
expectedVersion: DOCX_PANDOC_VERSION,
detectedVersion: DOCX_PANDOC_VERSION
};
}
function parseDetectedVersion(result: PandocProcessResult) {
const firstLine = new TextDecoder()
.decode(result.stdout)
.split(/\r?\n/u)[0]
?.trim();
return firstLine?.replace(/^pandoc\s+/u, "").trim() ?? "";
}
function createProbeOptions(
timeoutMs: number,
environment: NodeJS.ProcessEnv
): PandocProcessOptions {
return {
timeoutMs,
maxStdoutBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
env: environment
};
}
function outcomeStatus(result: PandocProcessResult) {
if (result.outcome === "not-found") {
return "not-found" as const;
}
if (
result.outcome === "timeout" ||
result.outcome === "aborted"
) {
return "probe-timeout" as const;
}
return "not-executable" as const;
}
function createCandidates(options: PandocRuntimeOptions) {
const platform = options.platform ?? process.platform;
const architecture = options.architecture ?? process.arch;
const environment = options.environment ?? process.env;
const configuredPath =
options.configuredPath ??
environment.DOCX_PANDOC_PATH?.trim() ??
"";
if (configuredPath) {
return [
{
executablePath: configuredPath,
exclusive: true
}
];
}
const candidates: RuntimeCandidate[] = [];
if (
platform === "win32" &&
architecture === "x64" &&
options.desktopResourcesPath
) {
candidates.push({
executablePath: path.join(
options.desktopResourcesPath,
"pandoc",
DOCX_PANDOC_VERSION,
"windows-x86_64",
"pandoc.exe"
),
exclusive: false
});
}
if (platform === "linux" && architecture === "x64") {
candidates.push({
executablePath: `/opt/pandoc/${DOCX_PANDOC_VERSION}/bin/pandoc`,
exclusive: false
});
}
candidates.push({
executablePath: platform === "win32" ? "pandoc.exe" : "pandoc",
exclusive: false
});
return candidates.filter(
(candidate, index, all) =>
all.findIndex(
(item) => item.executablePath === candidate.executablePath
) === index
);
}
async function probeCandidate(
candidate: RuntimeCandidate,
runner: PandocProcessRunner,
environment: NodeJS.ProcessEnv,
timeoutMs: number
): Promise<PandocRuntimeResolution> {
const result = await runner(
candidate.executablePath,
["--version"],
createProbeOptions(timeoutMs, environment)
);
if (
result.outcome !== "completed" ||
result.exitCode !== 0
) {
const status =
result.outcome === "completed"
? "not-executable"
: outcomeStatus(result);
return {
capability: unavailableCapability(
status,
status === "probe-timeout"
? "Pandoc 版本探测超时"
: status === "not-found"
? "未找到 Pandoc 运行时"
: "Pandoc 运行时无法执行"
)
};
}
const detectedVersion = parseDetectedVersion(result);
if (detectedVersion !== DOCX_PANDOC_VERSION) {
return {
capability: unavailableCapability(
"version-mismatch",
`Pandoc 版本不匹配,需要 ${DOCX_PANDOC_VERSION}`,
detectedVersion || "未知"
)
};
}
return {
capability: availableCapability(),
executablePath: candidate.executablePath
};
}
export async function probePandocRuntime(
options: PandocRuntimeOptions = {}
): Promise<PandocRuntimeResolution> {
const environment = options.environment ?? process.env;
const runner = options.runner ?? runPandocProcess;
let lastMissing: PandocRuntimeResolution | undefined;
for (const candidate of createCandidates(options)) {
const resolution = await probeCandidate(
candidate,
runner,
environment,
options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
);
if (resolution.capability.status === "available") {
return resolution;
}
if (
resolution.capability.status !== "not-found" ||
candidate.exclusive
) {
return resolution;
}
lastMissing = resolution;
}
return (
lastMissing ?? {
capability: unavailableCapability(
"not-found",
"未找到 Pandoc 运行时"
)
}
);
}
export class PandocRuntime {
private resolution:
| Promise<PandocRuntimeResolution>
| undefined = undefined;
private defaultReference:
| Promise<Uint8Array>
| undefined = undefined;
constructor(private readonly options: PandocRuntimeOptions = {}) {}
probe() {
this.resolution ??= probePandocRuntime(this.options);
return this.resolution;
}
async getExecutablePath() {
const resolution = await this.probe();
if (resolution.capability.status !== "available") {
throw new PandocRuntimeUnavailableError(
resolution.capability.status,
resolution.capability.message
);
}
if (!resolution.executablePath) {
throw new PandocRuntimeUnavailableError(
"not-found",
"Pandoc capability 可用但缺少可执行路径"
);
}
return resolution.executablePath;
}
async getDefaultReferenceDocx() {
this.defaultReference ??= this.loadDefaultReferenceDocx();
try {
return (await this.defaultReference).slice();
} catch (error) {
this.defaultReference = undefined;
throw error;
}
}
invalidate() {
this.resolution = undefined;
this.defaultReference = undefined;
}
private async loadDefaultReferenceDocx() {
const executablePath = await this.getExecutablePath();
const runner = this.options.runner ?? runPandocProcess;
const result = await runner(
executablePath,
["--print-default-data-file", "reference.docx"],
{
timeoutMs:
this.options.referenceTimeoutMs ??
DEFAULT_REFERENCE_TIMEOUT_MS,
maxStdoutBytes: MAXIMUM_REFERENCE_DOCX_BYTES,
maxStderrBytes: MAXIMUM_PROBE_OUTPUT_BYTES,
env: this.options.environment ?? process.env
}
);
if (
result.outcome !== "completed" ||
result.exitCode !== 0
) {
const status =
result.outcome === "timeout" ||
result.outcome === "aborted"
? "probe-timeout"
: result.outcome === "not-found"
? "not-found"
: "not-executable";
throw new PandocRuntimeUnavailableError(
status,
"无法读取 Pandoc 默认 reference.docx"
);
}
readReferenceDocxPackage(result.stdout);
return result.stdout;
}
}
+50 -10
View File
@@ -5,6 +5,21 @@ export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024;
export const MAXIMUM_REFERENCE_ENTRY_COUNT = 256;
export const MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES =
64 * 1024 * 1024;
export const MAXIMUM_GENERATED_DOCX_ENTRY_COUNT = 512;
export const MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES =
128 * 1024 * 1024;
export interface DocxPackageLimits {
maximumBytes: number;
maximumEntryCount: number;
maximumUncompressedBytes: number;
}
export const referenceDocxPackageLimits: DocxPackageLimits = {
maximumBytes: MAXIMUM_REFERENCE_DOCX_BYTES,
maximumEntryCount: MAXIMUM_REFERENCE_ENTRY_COUNT,
maximumUncompressedBytes: MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
};
export const requiredReferenceDocxParts = [
"[Content_Types].xml",
@@ -28,6 +43,23 @@ interface ZipDirectoryEntry {
uncompressedSize: number;
}
function fingerprintEntries(
entries: ReadonlyMap<string, Uint8Array>
) {
const hash = createHash("sha256");
for (const [name, content] of [...entries].sort(
([left], [right]) => left.localeCompare(right)
)) {
const length = Buffer.alloc(8);
length.writeBigUInt64BE(BigInt(content.byteLength));
hash.update(name, "utf8");
hash.update(Uint8Array.of(0));
hash.update(length);
hash.update(content);
}
return hash.digest("hex");
}
function findEndOfCentralDirectory(content: Uint8Array) {
const view = new DataView(
content.buffer,
@@ -59,10 +91,13 @@ function validateEntryName(name: string) {
}
}
function inspectZipDirectory(content: Uint8Array) {
function inspectZipDirectory(
content: Uint8Array,
limits: DocxPackageLimits
) {
if (
content.byteLength < 22 ||
content.byteLength > MAXIMUM_REFERENCE_DOCX_BYTES
content.byteLength > limits.maximumBytes
) {
throw new Error("reference.docx 压缩包大小超过限制或内容不完整");
}
@@ -81,7 +116,7 @@ function inspectZipDirectory(content: Uint8Array) {
if (
diskNumber !== 0 ||
directoryDisk !== 0 ||
entryCount > MAXIMUM_REFERENCE_ENTRY_COUNT ||
entryCount > limits.maximumEntryCount ||
entryCount === 0 ||
directoryOffset + directorySize > endOffset
) {
@@ -129,7 +164,7 @@ function inspectZipDirectory(content: Uint8Array) {
totalUncompressedBytes += uncompressedSize;
if (
totalUncompressedBytes >
MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
limits.maximumUncompressedBytes
) {
throw new Error("reference.docx 解压后总大小超过限制");
}
@@ -142,10 +177,11 @@ function inspectZipDirectory(content: Uint8Array) {
return entries;
}
export function readReferenceDocxPackage(
content: Uint8Array
export function readDocxPackage(
content: Uint8Array,
limits: DocxPackageLimits
): ReferenceDocxPackage {
const directory = inspectZipDirectory(content);
const directory = inspectZipDirectory(content, limits);
const unzipped = unzipSync(content);
const entries = new Map<string, Uint8Array>();
for (const descriptor of directory) {
@@ -169,13 +205,17 @@ export function readReferenceDocxPackage(
}
}
return {
fingerprint: createHash("sha256")
.update(content)
.digest("hex"),
fingerprint: fingerprintEntries(entries),
entries
};
}
export function readReferenceDocxPackage(
content: Uint8Array
): ReferenceDocxPackage {
return readDocxPackage(content, referenceDocxPackageLimits);
}
export function writeReferenceDocxPackage(
entries: ReadonlyMap<string, Uint8Array>
) {
+30 -4
View File
@@ -1,4 +1,5 @@
import path from "node:path";
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
import {
CONTENT_TYPES_NAMESPACE,
OFFICE_RELATIONSHIP_NAMESPACE,
@@ -8,7 +9,12 @@ import {
parseXmlPart,
type XmlElement
} from "./ooxml.js";
import { readReferenceDocxPackage } from "./reference-package.js";
import {
MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES,
readDocxPackage,
readReferenceDocxPackage
} from "./reference-package.js";
const HEADER_RELATIONSHIP_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
@@ -263,10 +269,18 @@ function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
}
}
export function validateDynamicReferenceDocx(
content: Uint8Array
function validateDocx(
content: Uint8Array,
generatedOutput: boolean
): DynamicReferenceValidation {
const reference = readReferenceDocxPackage(content);
const reference = generatedOutput
? readDocxPackage(content, {
maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES,
maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
maximumUncompressedBytes:
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES
})
: readReferenceDocxPackage(content);
let xmlPartCount = 0;
let relationshipCount = 0;
const relationshipParts = new Map<
@@ -345,3 +359,15 @@ export function validateDynamicReferenceDocx(
footerCount
};
}
export function validateDynamicReferenceDocx(
content: Uint8Array
): DynamicReferenceValidation {
return validateDocx(content, false);
}
export function validateGeneratedDocx(
content: Uint8Array
): DynamicReferenceValidation {
return validateDocx(content, true);
}