import ExcelJS from "exceljs"; import { promises as fs } from "node:fs"; import path from "node:path"; import { PDFDocument } from "pdf-lib"; import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { acquireWorkbook } from "./excel-store"; type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string }; function value(args: Record, keys: string[], fallback: unknown = ""): unknown { for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key]; return fallback; } function text(args: Record, keys: string[], fallback = ""): string { return String(value(args, keys, fallback)); } function truthy(candidate: unknown): boolean { return [true, 1, "1", "true", "True", "yes", "on"].includes(candidate as never); } function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string { const raw = typeof candidate === "object" && candidate !== null ? (candidate as { path?: unknown }).path : candidate; const filePath = String(raw ?? "").trim(); if (!filePath) throw new RuntimeError("validation_error", `${label} is empty`, { nodeId, componentId, recommendation: `Configure ${label}` }); return path.resolve(filePath); } async function loadWorkbook(args: Record, nodeId: string, componentId: string): Promise { const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId); const workbook = await acquireWorkbook(filePath); return { workbook, filePath }; } function worksheetOf(workbook: ExcelJS.Workbook, args: Record): ExcelJS.Worksheet { const name = text(args, ["sheet", "sheetName"]); const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0]; if (!sheet) throw new Error(`Sheet not found: ${name || "first sheet"}`); return sheet; } async function saveWhenRequested(source: WorkbookSource, args: Record): Promise { if (truthy(value(args, ["save", "isSave"]))) await source.workbook.xlsx.writeFile(source.filePath); } function columnNumber(candidate: unknown): number { const raw = String(candidate ?? "A").trim().toUpperCase(); if (/^\d+$/.test(raw)) return Math.max(1, Number(raw)); if (!/^[A-Z]+$/.test(raw)) throw new Error(`Invalid column: ${raw}`); let result = 0; for (const character of raw) result = result * 26 + character.charCodeAt(0) - 64; return result; } function columnLetter(index: number): string { if (!Number.isInteger(index) || index < 1) throw new Error(`Invalid column number: ${index}`); let current = index; let result = ""; while (current > 0) { current -= 1; result = String.fromCharCode(65 + current % 26) + result; current = Math.floor(current / 26); } return result; } function cellValue(candidate: ExcelJS.CellValue): unknown { if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result ?? ""; if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text; return candidate ?? ""; } function parseList(candidate: unknown, converter: (item: string) => number): number[] { if (Array.isArray(candidate)) return candidate.flatMap((item) => parseList(item, converter)); const raw = String(candidate ?? "").trim(); if (!raw) return []; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.flatMap((item) => parseList(item, converter)); } catch { /* parse as a delimited value */ } return raw.split(/[,;\s]+/).filter(Boolean).flatMap((part) => { const match = part.match(/^([A-Za-z]+|\d+)[-:]([A-Za-z]+|\d+)$/); if (!match) return [converter(part)]; const start = converter(match[1]); const end = converter(match[2]); return Array.from({ length: Math.abs(end - start) + 1 }, (_, index) => Math.min(start, end) + index); }); } const readRowCount: CapabilityHandler = async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const byColumn = text(args, ["range"]).includes("列"); const column = columnNumber(value(args, ["column"], "A")); let count = 0; sheet.eachRow({ includeEmpty: false }, (row) => { if (truthy(args.ignoreHidden) && row.hidden) return; if (!byColumn || cellValue(row.getCell(column).value) !== "") count += 1; }); return count; }; register("Excel_Cs_ReadRowCount", readRowCount); register("Excel_Cs_ReadCellFormula", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const address = `${columnLetter(columnNumber(value(args, ["column"], "A")))}${Math.max(1, Number(value(args, ["row"], 1)))}`; const candidate = sheet.getCell(address).value; return candidate && typeof candidate === "object" && "formula" in candidate ? candidate.formula : ""; }); const getExcelPath: CapabilityHandler = (_ctx, node, args) => requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", node.id, node.componentId); register(["Excel_Cs_GetPath", "Excel_GetPath"], getExcelPath); register("Excel_Cs_ConvertColumn", (_ctx, _node, args) => { const input = value(args, ["convertInput", "column"], 1); return text(args, ["convertType"], "0") === "0" ? columnLetter(Number(input)) : columnNumber(input); }); register("Excel_Cs_GetEmptyCell", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const column = columnNumber(value(args, ["column"], "A")); let row = Math.max(1, Number(value(args, ["row"], 1))); while (cellValue(sheet.getCell(row, column).value) !== "") row += 1; return `${columnLetter(column)}${row}`; }); const readColumn: CapabilityHandler = async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A")); const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1))); const values = Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value)); // 触碰过尾部空行(如定位空单元格)会撑大 rowCount,裁掉尾部空值避免误报 while (values.length > 0 && (values[values.length - 1] === "" || values[values.length - 1] === null || values[values.length - 1] === undefined)) values.pop(); return values; }; register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);register("Excel_Cs_DeleteRow", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const mode = text(args, ["mode"]); const input = mode.includes("多") ? args.rows : mode.includes("范围") ? `${value(args, ["startRow"], 1)}-${value(args, ["lastRow"], 1)}` : value(args, ["row"], 1); const rows = parseList(input, Number); for (const row of [...new Set(rows)].sort((a, b) => b - a)) sheet.spliceRows(row, 1); await saveWhenRequested(source, args); return rows.length; }); register("Excel_Cs_DeleteColumn", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const mode = text(args, ["mode"]); const input = mode.includes("多") ? args.columns : mode.includes("范围") ? `${value(args, ["startColumn"], "A")}-${value(args, ["lastColumn"], "A")}` : value(args, ["column"], "A"); const columns = parseList(input, columnNumber); for (const column of [...new Set(columns)].sort((a, b) => b - a)) sheet.spliceColumns(column, 1); await saveWhenRequested(source, args); return columns.length; }); register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); // 兼容各别名版本的键:V4=excelRng、V2/V3=range、Legacy Excel_ClearRange=cellPos、V1=startRow/startColumn/lastRow/lastColumn let range = text(args, ["excelRng", "range", "cellPos"]); if (!range) { const startRow = text(args, ["startRow"]); const startColumn = text(args, ["startColumn"]); if (startRow && startColumn) { const lastRow = text(args, ["lastRow"], startRow); const lastColumn = text(args, ["lastColumn"], startColumn); range = `${startColumn}${startRow}:${lastColumn}${lastRow}`; } } if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId }); const [start, end = start] = range.split(":"); const first = sheet.getCell(start); const last = sheet.getCell(end); // 清除模式:全部=内容+格式,格式=仅格式,其余(内容/默认)=仅内容;Legacy isFormat=True 同样清格式 const mode = text(args, ["clearType", "mode"], "内容"); const clearFormat = mode.includes("全部") || mode.includes("格式") || truthy(value(args, ["isFormat"])); const clearValue = !mode.includes("格式"); for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) { const cell = sheet.getCell(row, column); if (clearValue) cell.value = null; if (clearFormat) cell.style = {}; } await saveWhenRequested(source, args); return range; }); register("Excel_Cs_MergeCells", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const range = text(args, ["range", "excelRng"]); if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId }); if (text(args, ["merge"], "0") === "0") sheet.mergeCells(range); else sheet.unMergeCells(range); await saveWhenRequested(source, args); return range; }); register("Excel_Cs_SetHeight", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const rows = parseList(value(args, ["rows", "row"], 1), Number); for (const row of rows) sheet.getRow(row).height = Number(value(args, ["height"], 14.5)); await saveWhenRequested(source, args); return rows.length; }); register("Excel_Cs_SetWidth", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const columns = parseList(value(args, ["columns", "column"], "A"), columnNumber); for (const column of columns) sheet.getColumn(column).width = Number(value(args, ["width"], 8.38)); await saveWhenRequested(source, args); return columns.length; }); register("Excel_Cs_CopyV2", async (_ctx, node, args) => { const source = await loadWorkbook(args, node.id, node.componentId); const sheet = worksheetOf(source.workbook, args); const name = text(args, ["newSheet", "name"], `${sheet.name}_copy`); const existing = source.workbook.getWorksheet(name); if (existing) { if (!truthy(args.override)) throw new RuntimeError("validation_error", `Sheet already exists: ${name}`, { nodeId: node.id, componentId: node.componentId }); source.workbook.removeWorksheet(existing.id); } const copy = source.workbook.addWorksheet(name); sheet.eachRow({ includeEmpty: true }, (row, rowNumber) => { const target = copy.getRow(rowNumber); target.values = row.values; target.height = row.height; row.eachCell({ includeEmpty: true }, (cell, column) => { target.getCell(column).style = { ...cell.style }; }); }); sheet.columns.forEach((column, index) => { copy.getColumn(index + 1).width = column.width; }); await saveWhenRequested(source, args); return name; }); function parsePdfPages(raw: string, pageCount: number): number[] { // 页码留空时返回空集:由调用方抛出「No valid PDF pages」,避免静默复制全本 if (!raw.trim()) return []; const pages = new Set(); for (const value of parseList(raw, Number)) if (value >= 1 && value <= pageCount) pages.add(value - 1); return [...pages]; } register("PDF_CreateNewPdf", async (_ctx, node, args) => { const sourcePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId); const destination = requiredPath(value(args, ["newPdfPath", "output", "outPath"]), "output path", node.id, node.componentId); const outputPath = path.extname(destination).toLowerCase() === ".pdf" ? destination : path.join(destination, `${path.basename(sourcePath, path.extname(sourcePath))}-new.pdf`); const source = await PDFDocument.load(await fs.readFile(sourcePath), { ignoreEncryption: true }); const indices = text(args, ["pdfRange"], "all") === "all" ? source.getPageIndices() : parsePdfPages(text(args, ["pdfNum"]), source.getPageCount()); if (!indices.length) throw new RuntimeError("validation_error", "No valid PDF pages", { nodeId: node.id, componentId: node.componentId }); const target = await PDFDocument.create(); for (const page of await target.copyPages(source, indices)) target.addPage(page); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, await target.save()); return outputPath; }); register("PDF_GetPdfInformation", async (_ctx, node, args) => { const filePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId); const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true, updateMetadata: false }); const stat = await fs.stat(filePath); return { path: filePath, fileName: path.basename(filePath), pageCount: document.getPageCount(), size: stat.size, title: document.getTitle() ?? "", author: document.getAuthor() ?? "", subject: document.getSubject() ?? "", creator: document.getCreator() ?? "", producer: document.getProducer() ?? "", creationDate: document.getCreationDate()?.toISOString() ?? "", modificationDate: document.getModificationDate()?.toISOString() ?? "" }; });