import { spawn } from "node:child_process"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { OfficeError } from "../core/errors.ts"; export const PDF_PINS = [ "pypdf==6.16.2", "Pillow==12.3.0", "pdfplumber==0.11.10", "reportlab==5.0.1", "pypdfium2==5.13.0", "cryptography==50.0.1", ] as const; const MODULE_PROBE = String.raw` import importlib.metadata, importlib.util, json, sys packages = {"pypdf":"pypdf","PIL":"Pillow","pdfplumber":"pdfplumber","reportlab":"reportlab","pypdfium2":"pypdfium2","cryptography":"cryptography"} out={"python":sys.version.split()[0],"executable":sys.executable,"packages":{}} for mod, dist in packages.items(): found=importlib.util.find_spec(mod) is not None ver=None if found: try: ver=importlib.metadata.version(dist) except importlib.metadata.PackageNotFoundError: pass out["packages"][dist]={"available":found,"version":ver} print(json.dumps(out)) `.trim(); export interface PythonCommand { command: string; prefix: string[]; source: "private" | "global"; } export interface PythonStatus { python: string; executable: string; packages: Record; source: "private" | "global"; } interface ProcessResult { code: number; stdout: string; stderr: string; } function runProcess(command: string, args: string[], options: { cwd: string; input?: string; signal?: AbortSignal; timeout?: number; }): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, windowsHide: true, shell: false, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" }, ...(options.signal ? { signal: options.signal } : {}), }); let stdout = ""; let stderr = ""; let settled = false; const timer = options.timeout ? setTimeout(() => child.kill(), options.timeout) : undefined; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { stdout += chunk; if (stdout.length > 32 * 1024 * 1024) child.kill(); }); child.stderr.on("data", (chunk: string) => { stderr += chunk; if (stderr.length > 4 * 1024 * 1024) child.kill(); }); child.once("error", (error) => { if (timer) clearTimeout(timer); if (!settled) { settled = true; reject(error); } }); child.once("close", (code) => { if (timer) clearTimeout(timer); if (!settled) { settled = true; resolve({ code: code ?? 1, stdout, stderr }); } }); child.stdin.end(options.input ?? ""); }); } function parseLastJson(text: string): T { const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); for (let index = lines.length - 1; index >= 0; index -= 1) { try { return JSON.parse(lines[index]!) as T; } catch { /* ignore */ } } throw new OfficeError("Python 未返回有效 JSON。", "INVALID_PYTHON_OUTPUT"); } export class PdfRuntime { readonly projectRoot: string; readonly cwd: string; readonly signal: AbortSignal | undefined; readonly privatePython: string; readonly workerPath: string; constructor(projectRoot: string, cwd: string, signal?: AbortSignal) { this.projectRoot = projectRoot; this.cwd = cwd; this.signal = signal; this.privatePython = path.join(projectRoot, ".runtime", "python", "Scripts", "python.exe"); this.workerPath = path.join(projectRoot, "scripts", "pdf", "worker.py"); } async resolvePython(preferPrivate = true): Promise { const candidates: PythonCommand[] = []; if (preferPrivate && existsSync(this.privatePython)) candidates.push({ command: this.privatePython, prefix: [], source: "private" }); candidates.push( { command: "py.exe", prefix: ["-3"], source: "global" }, { command: "python.exe", prefix: [], source: "global" }, { command: "python", prefix: [], source: "global" }, ); for (const candidate of candidates) { try { const result = await runProcess(candidate.command, [...candidate.prefix, "-c", "import sys; print(sys.version_info[0])"], { cwd: this.cwd, timeout: 15_000, ...(this.signal ? { signal: this.signal } : {}) }); if (result.code === 0 && result.stdout.trim() === "3") return candidate; } catch { /* try next */ } } throw new OfficeError("未找到可用的 Python 3。", "PYTHON_NOT_FOUND"); } async status(preferPrivate = true): Promise { const python = await this.resolvePython(preferPrivate); const result = await runProcess(python.command, [...python.prefix, "-c", MODULE_PROBE], { cwd: this.cwd, timeout: 30_000, ...(this.signal ? { signal: this.signal } : {}) }); if (result.code !== 0) throw new OfficeError("Python 依赖检测失败。", "PYTHON_STATUS_FAILED", { stderr: result.stderr.slice(-2000) }); return { ...parseLastJson>(result.stdout), source: python.source }; } async worker(operation: string, params: Record, secrets: Record = {}): Promise { if (!existsSync(this.workerPath)) throw new OfficeError("PDF worker 不存在。", "PDF_WORKER_MISSING"); if (!existsSync(this.privatePython)) throw new OfficeError("项目私有 PDF 环境尚未安装。请先运行 office_setup。", "PDF_RUNTIME_NOT_INSTALLED"); const request = JSON.stringify({ operation, params, secrets }); const result = await runProcess(this.privatePython, [this.workerPath], { cwd: this.cwd, input: request, timeout: 300_000, ...(this.signal ? { signal: this.signal } : {}), }); type Response = { ok: true; result: T } | { ok: false; error: { code: string; message: string } }; const response = parseLastJson(result.stdout); if (!response.ok) throw new OfficeError(response.error.message, response.error.code); if (result.code !== 0) throw new OfficeError("PDF worker 异常退出。", "PDF_WORKER_FAILED", { exitCode: result.code, stderr: result.stderr.slice(-2000) }); return response.result; } async setupPrivate(): Promise { if (process.platform !== "win32") throw new OfficeError("私有 PDF 环境当前只支持 Windows。", "UNSUPPORTED_PLATFORM"); const base = await this.resolvePython(false); const runtimeRoot = path.dirname(path.dirname(this.privatePython)); const runtimeRootExists = existsSync(runtimeRoot); const createdThisRun = !runtimeRootExists; if (!runtimeRootExists) { mkdirSync(path.dirname(runtimeRoot), { recursive: true }); const create = await runProcess(base.command, [...base.prefix, "-m", "venv", runtimeRoot], { cwd: this.cwd, timeout: 300_000, ...(this.signal ? { signal: this.signal } : {}) }); if (create.code !== 0) { rmSync(runtimeRoot, { recursive: true, force: true }); throw new OfficeError("创建私有 Python 环境失败。", "VENV_CREATE_FAILED", { stderr: create.stderr.slice(-3000) }); } } else if (!existsSync(this.privatePython)) { throw new OfficeError("项目私有 PDF 环境不完整。为避免删除现有文件,未自动修复;请在确认后手动移除 .runtime/python 再重新运行 office_setup。", "PDF_RUNTIME_REPAIR_REQUIRED", { runtimeRoot }); } const install = await runProcess(this.privatePython, ["-m", "pip", "install", "--disable-pip-version-check", "--no-input", ...PDF_PINS], { cwd: this.cwd, timeout: 1_200_000, ...(this.signal ? { signal: this.signal } : {}), }); if (install.code !== 0) { if (createdThisRun) rmSync(runtimeRoot, { recursive: true, force: true }); throw new OfficeError("安装私有 PDF 依赖失败。", "PDF_DEPENDENCY_INSTALL_FAILED", { stderr: install.stderr.slice(-5000) }); } return await this.status(true); } }