PRA-only-demo/src/main/capabilities/office-file-extended.ts

277 lines
14 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 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
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<string, unknown>): 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<string, unknown>): Promise<void> {
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<number>();
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() ?? ""
};
});