import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { OfficeError } from "./errors.ts"; function rejectNul(value: string): void { if (value.includes("\0")) throw new OfficeError("路径包含 NUL 字符。", "INVALID_PATH"); } export function resolveFromCwd(value: string, cwd: string): string { rejectNul(value); const trimmed = value.trim(); if (!trimmed) throw new OfficeError("路径不能为空。", "INVALID_PATH"); return path.normalize(path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed)); } function walkExistingSegments(target: string): void { const parsed = path.parse(target); const relative = target.slice(parsed.root.length); let current = parsed.root; for (const segment of relative.split(path.sep).filter(Boolean)) { current = path.join(current, segment); if (!existsSync(current)) break; const stat = lstatSync(current); if (stat.isSymbolicLink()) { throw new OfficeError(`拒绝符号链接或联接路径:${current}`, "REPARSE_PATH", { path: current }); } } } export function requireInputFile(value: string, cwd: string, extensions: string[]): string { const resolved = resolveFromCwd(value, cwd); walkExistingSegments(resolved); if (!existsSync(resolved)) throw new OfficeError(`文件不存在:${resolved}`, "FILE_NOT_FOUND", { path: resolved }); const stat = lstatSync(resolved); if (!stat.isFile()) throw new OfficeError(`路径不是普通文件:${resolved}`, "NOT_A_FILE", { path: resolved }); const extension = path.extname(resolved).toLowerCase(); if (!extensions.includes(extension)) { throw new OfficeError(`不支持的文件类型 ${extension || "(无扩展名)"}。`, "UNSUPPORTED_EXTENSION", { allowed: extensions }); } if (path.basename(resolved).startsWith("~$")) throw new OfficeError("拒绝处理 Microsoft Word 临时锁文件。", "WORD_LOCK_FILE"); return realpathSync.native(resolved); } export function requireOutputPath(value: string, cwd: string, extension: string): string { const resolved = resolveFromCwd(value, cwd); if (path.extname(resolved).toLowerCase() !== extension) { throw new OfficeError(`输出文件必须使用 ${extension} 扩展名。`, "INVALID_OUTPUT_EXTENSION", { path: resolved }); } const parent = path.dirname(resolved); walkExistingSegments(parent); if (!existsSync(parent) || !lstatSync(parent).isDirectory()) { throw new OfficeError(`输出目录不存在:${parent}`, "OUTPUT_DIRECTORY_NOT_FOUND", { path: parent }); } return resolved; } export function defaultModifiedPath(inputPath: string, extension = path.extname(inputPath)): string { const parsed = path.parse(inputPath); return path.join(parsed.dir, `${parsed.name}_修改版${extension}`); } export interface OutputPlan { finalPath: string; stagedPath: string; overwrite: boolean; } export function planOutput(options: { inputPath?: string | undefined; outputPath?: string | undefined; cwd: string; extension: string; overwrite?: boolean | undefined; }): OutputPlan { const overwrite = options.overwrite === true; const candidate = options.outputPath ? requireOutputPath(options.outputPath, options.cwd, options.extension) : options.inputPath ? defaultModifiedPath(options.inputPath, options.extension) : undefined; if (!candidate) throw new OfficeError("新建文档必须指定输出路径。", "OUTPUT_REQUIRED"); const finalPath = path.normalize(candidate); if (options.inputPath && path.resolve(finalPath).toLowerCase() === path.resolve(options.inputPath).toLowerCase() && !overwrite) { throw new OfficeError("默认禁止覆盖输入文件;如确需覆盖,请明确设置 overwrite=true。", "OVERWRITE_REQUIRED"); } if (existsSync(finalPath) && !overwrite) { throw new OfficeError(`输出文件已存在:${finalPath}`, "OUTPUT_EXISTS", { path: finalPath }); } const parsed = path.parse(finalPath); const stagedPath = path.join(parsed.dir, `.${parsed.name}.pi-word-pdf-${randomUUID()}.tmp${parsed.ext}`); return { finalPath, stagedPath, overwrite }; } export function publishStagedFile(plan: OutputPlan): void { if (!existsSync(plan.stagedPath)) throw new OfficeError("暂存输出不存在,无法发布。", "STAGED_OUTPUT_MISSING"); if (!existsSync(plan.finalPath)) { renameSync(plan.stagedPath, plan.finalPath); return; } if (!plan.overwrite) throw new OfficeError(`输出文件已存在:${plan.finalPath}`, "OUTPUT_EXISTS"); const backupPath = `${plan.finalPath}.pi-word-pdf-backup-${randomUUID()}`; renameSync(plan.finalPath, backupPath); try { renameSync(plan.stagedPath, plan.finalPath); rmSync(backupPath, { force: true }); } catch (error) { if (existsSync(plan.finalPath)) rmSync(plan.finalPath, { force: true }); if (existsSync(backupPath)) renameSync(backupPath, plan.finalPath); throw error; } } export function cleanupStaged(plan: OutputPlan): void { rmSync(plan.stagedPath, { force: true }); } export function createOutputDirectory(value: string, cwd: string): string { const resolved = resolveFromCwd(value, cwd); walkExistingSegments(path.dirname(resolved)); if (existsSync(resolved)) { if (!lstatSync(resolved).isDirectory()) throw new OfficeError("预览输出路径不是目录。", "NOT_A_DIRECTORY"); if (lstatSync(resolved).isSymbolicLink()) throw new OfficeError("拒绝符号链接预览目录。", "REPARSE_PATH"); if (readdirSync(resolved).length > 0) throw new OfficeError("预览输出目录必须为空,避免覆盖未知文件。", "PREVIEW_DIRECTORY_NOT_EMPTY"); return resolved; } mkdirSync(resolved, { recursive: false }); return resolved; }