Files
PRA-only-demo/packages/rpa-core/src/capabilities/excel-com.ts
T

289 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>, 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<string, unknown>, 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, unknown>): 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<unknown> {
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<string, unknown> = {};
try { rule = JSON.parse(String(args.rule || "{}")) as Record<string, unknown>; } 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);