import fs from "node:fs"; import path from "node:path"; import ExcelJS from "exceljs"; import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { powershell, psString } from "../driver/windows/powershell"; import { releaseWorkbook } from "./excel-store"; function text(args: Record, keys: string[], fallback = ""): string { for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); } return fallback; } function bool(value: unknown, fallback = false): boolean { if (value === undefined || value === null || value === "") return fallback; return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(value as never); } function number(value: unknown, fallback = 0): number { const result = Number(value); return Number.isFinite(result) ? result : fallback; } function workbookPath(args: Record, nodeId: string, componentId: string, keys = ["workbook", "objExcel", "objExcelWorkBook", "destWorkbook"]): string { for (const key of keys) { const raw = args[key]; const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw; const value = String(candidate ?? "").trim(); if (value && value !== "Excel文件" && value !== "objExcelWorkBook") return path.resolve(value); } throw new RuntimeError("validation_error", "Excel文件路径为空", { nodeId, componentId }); } function sheetExpression(args: Record): string { const sheet = text(args, ["sheet", "sheetName"]); return sheet ? `$workbook.Worksheets.Item(${psString(sheet)})` : "$workbook.ActiveSheet"; } async function runExcelCom(filePath: string, body: string, save = false, readonly = false, timeout = 120000): Promise { const openMode = readonly ? "$true" : "$false"; const saveMode = save ? "$true" : "$false"; const script = [ "$excel=$null;$workbook=$null;$rpaOutput=$null", "try {", " $excel=New-Object -ComObject Excel.Application;$excel.Visible=$false;$excel.DisplayAlerts=$false", " $workbook=$excel.Workbooks.Open(" + psString(filePath) + ",0," + openMode + ")", " " + body, " if (" + saveMode + ") { $workbook.Save() }", " if ($null -ne $rpaOutput) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($rpaOutput | ConvertTo-Json -Compress -Depth 6))) | Write-Output }", "} finally {", " if($null -ne $workbook){try{$workbook.Close($false)}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($workbook)}catch{}}", " if($null -ne $excel){try{$excel.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)}catch{}}", " [GC]::Collect();[GC]::WaitForPendingFinalizers()", "}" ].join("\n"); const output = (await powershell(script, timeout)).trim(); if (save) releaseWorkbook(filePath); if (!output) return null; try { return JSON.parse(Buffer.from(output, "base64").toString("utf8")); } catch { throw new RuntimeError("excel_com_failed", "Excel COM 输出异常", { detail: output.slice(0, 300) }); } } function list(value: unknown): string[] { if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean); const raw = String(value ?? "").trim(); if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; } catch { return raw.split(/[,,;;\s]+/).filter(Boolean); } } const group: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const ungroup = String(args.group ?? "0") === "1"; const columns = String(args.groupType ?? "0") === "1"; const start = columns ? text(args, ["startColumn"], "A") : String(number(args.startRow, 1)); const end = columns ? text(args, ["endColumn"], text(args, ["startColumn"], "A")) : String(number(args.endRow, number(args.startRow, 1))); const method = ungroup ? "Ungroup" : "Group"; const body = `$sheet=${sheetExpression(args)};$start=${psString(start)};$end=${psString(end)}; $count=if(${columns ? "$true" : "$false"}){$sheet.UsedRange.Columns.Count}else{$sheet.UsedRange.Rows.Count}; foreach($name in @("start","end")){ $v=[int](Get-Variable $name -ValueOnly); if($v -lt 0){ Set-Variable $name ($count+$v+1) } }; $range=$sheet.Range("$($start):$($end)");$range.${method}|Out-Null`; await runExcelCom(filePath, body, bool(args.save)); return { grouped: !ungroup, type: columns ? "columns" : "rows" }; }; const deletePicture: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]); const name = text(args, ["name", "pictureName"]); if (!name) throw new RuntimeError("validation_error", "图片名称为空", { nodeId: node.id, componentId: node.componentId }); const body = `$sheet=${sheetExpression(args)};$sheet.Shapes.Item(${psString(name)}).Delete()`; await runExcelCom(filePath, body, bool(args.save)); return { deleted: name }; }; const insertPicture: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]); const image = path.resolve(text(args, ["path", "pictureName"])); if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "图片文件不存在", { nodeId: node.id, componentId: node.componentId, detail: image }); const row = number(args.row, 1); const column = number(args.column, 1); const left = number(args.Left ?? args.left, 20); const top = number(args.Top ?? args.top, 20); const width = number(args.Width ?? args.width, -1); const height = number(args.Height ?? args.height, -1); const scale = width > 0 && height > 0 ? "$true" : "$false"; const body = `$sheet=${sheetExpression(args)};$cell=$sheet.Cells(${row},${column});$left=${left}+if($left -gt 0){$cell.Left}else{0};$top=${top}+if($top -gt 0){$cell.Top}else{0};[void]$sheet.Shapes.AddPicture(${psString(image)},$false,${scale},$left,$top,${width},${height})`; await runExcelCom(filePath, body, bool(args.save)); return { inserted: image }; }; const encryptExcel: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const password = String(args.password1 ?? args.password ?? ""); const confirm = String(args.password2 ?? password); if (!password) throw new RuntimeError("validation_error", "Excel密码为空", { nodeId: node.id, componentId: node.componentId }); if (password !== confirm) throw new RuntimeError("validation_error", "两次输入的Excel密码不一致", { nodeId: node.id, componentId: node.componentId }); const temporary = `${filePath}.${Date.now()}.enc.xlsx`; try { await runExcelCom(filePath, `$workbook.SaveAs(${psString(temporary)},51,${psString(password)},${psString(confirm)})`, false); fs.rmSync(temporary, { force: true }); // COM SaveAs cannot replace the workbook while it is open, so save beside it and rename after Close. } finally { if (fs.existsSync(temporary)) { fs.rmSync(filePath, { force: true }); fs.renameSync(temporary, filePath); } releaseWorkbook(filePath); } return filePath; }; const moveSheet: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const mode = text(args, ["mode"], "按序号"); const after = text(args, ["option"], "之前").includes("之后"); const targetName = text(args, ["name", "index"]); const position = text(args, ["position", "index"], "第一位"); const index = number(args.index, 1); const afterMode = after ? "$true" : "$false"; const body = `$sheet=${sheetExpression(args)};$count=$workbook.Worksheets.Count;$targetIndex=0; if(${psString(mode)} -eq "按名称"){ $targetName=${psString(targetName)}; for($i=1;$i -le $count;$i++){ if($workbook.Worksheets.Item($i).Name -eq $targetName){$targetIndex=$i;break} }; if($targetIndex -eq 0){throw "target_sheet_not_found"} } elseif(${psString(mode)} -eq "按位置"){ $p=${psString(position)}; if($p -eq "第一位"){$targetIndex=1}elseif($p -eq "最后一位"){$targetIndex=$count}else{ $delta=-1; if($p -eq "向后移一位"){$delta=1}; $targetIndex=$sheet.Index+$delta } } else { $targetIndex=${index}; if($targetIndex -lt 0){$targetIndex=$count+$targetIndex+1} }; $targetIndex=[Math]::Min([Math]::Max(1,$targetIndex),$count);$target=$workbook.Worksheets.Item($targetIndex); if(${afterMode}){ if($target.Index -ne $sheet.Index){ $sheet.Move([Type]::Missing,$target)|Out-Null } } else { if($target.Index -ne $sheet.Index){ $sheet.Move($target)|Out-Null } }`; await runExcelCom(filePath, body, bool(args.save)); return { moved: true }; }; const mergeSheet: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const destPath = workbookPath(args, node.id, node.componentId, ["destWorkbook", "workbook"]); const horizontal = String(args.option ?? "0") === "1"; const horizontalMode = horizontal ? "$true" : "$false"; const saveDest = bool(args.save) ? "$true" : "$false"; const body = `$source=${sheetExpression(args)};$dest=$excel.Workbooks.Open(${psString(destPath)});$target=$dest.Worksheets.Item(${psString(text(args, ["destSheet"], "Sheet1"))}); if(${horizontalMode}){$offset=$target.UsedRange.Columns.Count+1}else{$offset=$target.UsedRange.Rows.Count+1}; for($r=1;$r -le $source.UsedRange.Rows.Count;$r++){for($c=1;$c -le $source.UsedRange.Columns.Count;$c++){ if(${horizontalMode}){$target.Cells($r,$offset+$c-1).Value2=$source.Cells($r,$c).Value2}else{$target.Cells($offset+$r-1,$c).Value2=$source.Cells($r,$c).Value2}}}; if(${saveDest}){$dest.Save()}`; await runExcelCom(filePath, body, false); return { merged: true, target: destPath }; }; const refreshAll: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); await runExcelCom(filePath, "$workbook.RefreshAll()", bool(args.save)); return { refreshed: true }; }; const refreshPivot: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const name = text(args, ["pivotTable"]); const sheetExpr = sheetExpression(args); const body = name ? `${sheetExpr}.PivotTables(${psString(name)}).RefreshTable()` : `${sheetExpr}.PivotTables() | ForEach-Object { $_.RefreshTable() }`; await runExcelCom(filePath, body, bool(args.save)); return { refreshed: true, name: name || "all" }; }; const filterPivot: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const field = text(args, ["field"]); const values = list(args.fieldValue); if (!field) throw new RuntimeError("validation_error", "筛选字段为空", { nodeId: node.id, componentId: node.componentId }); const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64"); const body = `$pt=${sheetExpression(args)}.PivotTables(${psString(text(args, ["pivotTable"]))});$pf=$pt.PivotFields(${psString(field)}); try{$pf.ClearAllFilters()}catch{}; $wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json; foreach($item in $pf.PivotItems()){ $selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name}; $item.Visible=$selected }`; await runExcelCom(filePath, body, bool(args.save)); return { field, values }; }; const createPivot: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const chart = node.componentId.includes("PivotView"); let rule: Record = {}; try { rule = JSON.parse(String(args.rule || "{}")) as Record; } catch { /* sourceData can still be supplied separately. */ } const source = text({ ...rule, ...args }, ["SourceData", "sourceData", "source"]); if (!source) throw new RuntimeError("validation_error", "透视表数据源为空", { nodeId: node.id, componentId: node.componentId }); const name = text(rule, ["Name", "name"], chart ? "RPAPivotChart" : "RPAPivotTable"); const body = `$sheet=${sheetExpression(args)};$cache=$excel.ActiveWorkbook.PivotCaches().Create(1,$sheet.Range(${psString(source)})); $dest=$sheet.Cells(1,20);$pt=$cache.CreatePivotTable($dest,${psString(name)}); if(${chart ? "$true" : "$false"}){[void]$sheet.Shapes.AddChart(-4109,20,20,420,260)}`; await runExcelCom(filePath, body, bool(args.save)); return { name, chart }; }; const runMacro = (scriptKey: "script" | "code" | "macroName"): CapabilityHandler => async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]); const macro = text(args, [scriptKey, "script", "code", "macroName", "name"]); if (!macro) throw new RuntimeError("validation_error", "宏名称为空", { nodeId: node.id, componentId: node.componentId }); const parameters = list(args.args ?? args.macroArgs).map((item) => psString(item)).join(","); const body = `$result=$excel.Run(${psString(macro)}${parameters ? "," + parameters : ""});$rpaOutput=@{result=$result}`; return await runExcelCom(filePath, body, bool(args.save), false, 180000); }; const interop: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]); const property = text(args, ["property"]); if (!property) throw new RuntimeError("validation_error", "属性或方法名为空", { nodeId: node.id, componentId: node.componentId }); const objectKind = number(args.obj ?? args.object, 0); const range = text(args, ["range"], "A1"); const target = objectKind === 0 ? "$excel" : objectKind === 1 ? "$workbook" : objectKind === 2 ? sheetExpression(args) : `${sheetExpression(args)}.Range(${psString(range)})`; const value = args.value; const empty = value === undefined || value === null || value === ""; const body = empty ? `$target=${target};$rpaOutput=@{value=$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::GetProperty,$null,$target,@())}` : `$target=${target};[void]$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::SetProperty,$null,$target,@(${psString(String(value))}));$rpaOutput=@{set=$true}`; return await runExcelCom(filePath, body, bool(args.save)); }; const exportPdf: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const folder = text(args, ["path"]); const name = text(args, ["name"], path.basename(filePath, path.extname(filePath)) + ".pdf"); const output = path.join(path.resolve(folder), /\.(pdf)$/i.test(name) ? name : `${name}.pdf`); fs.mkdirSync(path.dirname(output), { recursive: true }); if (!bool(args.cover, true) && fs.existsSync(output)) throw new RuntimeError("validation_error", "PDF 文件已存在", { detail: output }); const activateOnly = text(args, ["option"], "全部") === "激活页"; const body = `if(${activateOnly ? "$true" : "$false"}){$workbook.ActiveSheet.ExportAsFixedFormat(0,${psString(output)})}else{$workbook.ExportAsFixedFormat(0,${psString(output)})}`; await runExcelCom(filePath, body, false); return output; }; const exportRangePicture: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const folder = text(args, ["path"]); const name = text(args, ["name"], `excel-range-${Date.now()}`); const output = path.join(path.resolve(folder), /\.(png|jpg|jpeg)$/i.test(name) ? name : `${name}.png`); fs.mkdirSync(path.dirname(output), { recursive: true }); const body = `$range=${sheetExpression(args)}.Range(${psString(text(args, ["range"], "A1"))});$range.CopyPicture(1,2)|Out-Null;$shape=${sheetExpression(args)}.ChartObjects().Add(0,0,$range.Width,$range.Height);$chart=$shape.Chart;$chart.Paste();[void]$chart.Export(${psString(output)},"PNG");$shape.Delete()`; await runExcelCom(filePath, body, false); return output; }; const setSlicer: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]); const name = text(args, ["slicerName"]); const values = list(args.slicerOptions); if (!name) throw new RuntimeError("validation_error", "切片器名称为空", { nodeId: node.id, componentId: node.componentId }); const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64"); const body = `$cache=$workbook.SlicerCaches.Item(${psString(name)}); $wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json; foreach($item in $cache.SlicerItems()){ $item.Selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name} }`; await runExcelCom(filePath, body, bool(args.save)); return { slicer: name, values }; }; const excelLoop: CapabilityHandler = async (_ctx, node, args) => { const filePath = workbookPath(args, node.id, node.componentId, ["objExcel", "workbook"]); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); const rows: unknown[][] = []; for (const sheet of workbook.worksheets) { for (let row = 1; row <= sheet.rowCount; row += 1) { const cells: unknown[] = []; for (let column = 1; column <= sheet.columnCount; column += 1) cells.push(sheet.getCell(row, column).value ?? ""); if (cells.some((cell) => cell !== "")) rows.push(cells); } } return rows; }; register("Excel_Cs_Group", group); register("Excel_Cs_Encrypt", encryptExcel); register(["Excel_Cs_DeletePicture", "Excel_Cs_DeletePictureV2", "Excel_DeleteImage"], deletePicture); register(["Excel_Cs_InsertPicture", "Excel_Cs_InsertPictureV2", "Excel_InsertImage"], insertPicture); register("Excel_Cs_Move", moveSheet); register("Excel_Cs_Merge", mergeSheet); register(["Excel_Cs_RefreshAll", "Excel_Cs_RefreshPivotTable"], async (ctx, node, args, component) => node.componentId === "Excel_Cs_RefreshPivotTable" ? refreshPivot(ctx, node, args, component) : refreshAll(ctx, node, args, component)); register("Excel_Cs_FilterPivotTable", filterPivot); register(["Excel_Cs_InsertPivotTable", "Excel_Cs_InsertPivotView"], createPivot); register(["Excel_Cs_RunMacro", "Excel_Cs_RunMacroV2", "Excel_ExecuteMacro"], runMacro("script")); register("Excel_Cs_Interop", interop); register("Excel_Cs_Export", exportPdf); register("Excel_Cs_ExcelAddin", runMacro("code")); register("Excel_Cs_SetSlicer", setSlicer); register("Excel_Loop", excelLoop); register("Excel_ExportRangeAsPicture", exportRangePicture);