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
+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);
});
});