import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import { OfficeError } from "../core/errors.ts"; import type { ProbeExecutor } from "../status/types.ts"; export interface WordRuntimeOptions { projectRoot: string; cwd: string; exec: ProbeExecutor; signal?: AbortSignal; } export interface ProcessOutput { stdout: string; stderr: string; code: number; } export interface FontCheck { available: string[]; missing: string[]; } function lastJson(stdout: string): T { const lines = stdout.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 non-JSON progress output. } } throw new OfficeError("命令未返回可解析的 JSON。", "INVALID_COMMAND_OUTPUT", { stdout: stdout.slice(-2000) }); } function resolveDependencyRoot(projectRoot: string, packageName: string): string { try { const require = createRequire(path.join(projectRoot, "package.json")); return path.dirname(require.resolve(`${packageName}/package.json`)); } catch { return path.join(projectRoot, "node_modules", packageName); } } export class WordRuntime { readonly bunPath: string; readonly docxCliPath: string; readonly powershellPath: string; readonly options: WordRuntimeOptions; constructor(options: WordRuntimeOptions) { this.options = options; this.bunPath = path.join(resolveDependencyRoot(options.projectRoot, "bun"), "bin", "bun.exe"); this.docxCliPath = path.join(resolveDependencyRoot(options.projectRoot, "bun-docx"), "dist", "index.js"); const windowsRoot = process.env.WINDIR ?? "C:\\Windows"; const builtInPowerShell = path.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); this.powershellPath = existsSync(builtInPowerShell) ? builtInPowerShell : "powershell.exe"; } assertReady(): void { if (process.platform !== "win32") throw new OfficeError("Word 第二阶段仅支持 Windows。", "UNSUPPORTED_PLATFORM"); if (!existsSync(this.bunPath)) throw new OfficeError("项目本地 Bun 未安装。请先运行经确认的项目依赖安装。", "BUN_NOT_INSTALLED"); if (!existsSync(this.docxCliPath)) throw new OfficeError("项目本地 bun-docx 未安装。", "BUN_DOCX_NOT_INSTALLED"); } async docx(args: string[], timeout = 120_000): Promise { this.assertReady(); const result = await this.options.exec(this.bunPath, [this.docxCliPath, ...args], { cwd: this.options.cwd, timeout, ...(this.options.signal ? { signal: this.options.signal } : {}), }); return result; } async docxOk(args: string[], timeout = 120_000): Promise { const result = await this.docx(args, timeout); if (result.code !== 0) { let parsed: unknown; try { parsed = lastJson(result.stdout); } catch { parsed = undefined; } throw new OfficeError("bun-docx 操作失败。", "DOCX_COMMAND_FAILED", { exitCode: result.code, error: parsed, stdout: result.stdout.slice(-4000), stderr: result.stderr.slice(-4000), }); } return result; } async docxJson(args: string[], timeout = 120_000): Promise { const result = await this.docx(args, timeout); if (result.code !== 0) { let parsed: unknown; try { parsed = lastJson(result.stdout); } catch { parsed = undefined; } throw new OfficeError("bun-docx 操作失败。", "DOCX_COMMAND_FAILED", { exitCode: result.code, error: parsed, stdout: result.stdout.slice(-4000), stderr: result.stderr.slice(-4000), }); } return lastJson(result.stdout); } async validateDocx(path: string): Promise<{ path: string; valid: boolean; errors: number }> { const result = await this.docx(["validate", path, "--json"], 120_000); const report = lastJson<{ path: string; valid: boolean; errors: number }>(result.stdout); if (report.valid !== true || (report.errors ?? 0) !== 0) { throw new OfficeError("DOCX 结构验证失败。", "DOCX_VALIDATION_FAILED", { validation: report }); } return report; } async powershellJson(scriptName: string, parameters: string[] = [], timeout = 120_000): Promise { const scriptPath = path.join(this.options.projectRoot, "scripts", "word", scriptName); if (!existsSync(scriptPath)) throw new OfficeError(`Word COM 脚本不存在:${scriptName}`, "SCRIPT_NOT_FOUND"); const result = await this.options.exec(this.powershellPath, [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath, ...parameters, ], { cwd: this.options.cwd, timeout, ...(this.options.signal ? { signal: this.options.signal } : {}), }); if (result.code !== 0) { throw new OfficeError("Microsoft Word COM 操作失败。", "WORD_COM_FAILED", { exitCode: result.code, stdout: result.stdout.slice(-4000), stderr: result.stderr.slice(-4000), }); } return lastJson(result.stdout); } async withJsonPayload( scriptName: string, payload: Record, timeout = 120_000, ): Promise { const payloadPath = path.join(os.tmpdir(), `pi-word-pdf-${randomUUID()}.json`); writeFileSync(payloadPath, JSON.stringify(payload), { encoding: "utf8", mode: 0o600, flag: "wx" }); try { return await this.powershellJson(scriptName, ["-PayloadPath", payloadPath], timeout); } finally { if (existsSync(payloadPath)) { try { const length = readFileSync(payloadPath).length; if (length > 0) writeFileSync(payloadPath, Buffer.alloc(length), { flag: "r+" }); } catch { // Best-effort overwrite before deletion; Windows filesystems may not guarantee secure erasure. } rmSync(payloadPath, { force: true }); } } } async requireFonts(fonts: string[]): Promise { const requested = [...new Set(fonts.map((font) => font.trim()).filter(Boolean))]; if (requested.length === 0) return; const result = await this.withJsonPayload("fonts.ps1", { fonts: requested }, 30_000); if (result.missing.length > 0) { throw new OfficeError(`本机未安装请求的字体:${result.missing.join("、")}。请在可用字体中选择后重试。`, "FONT_UNAVAILABLE", { requested, available: result.available, missing: result.missing, }); } } }