- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export) - 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起 - 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例 - remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows - 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名 - 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
116 lines
5.2 KiB
TypeScript
116 lines
5.2 KiB
TypeScript
import path from "node:path";
|
||
import { promises as fs } from "node:fs";
|
||
import { register } from "../engine/registry";
|
||
import { RuntimeError } from "../engine/types";
|
||
import { defaultEngine } from "../engine/engine";
|
||
|
||
interface OcrWord { text: string; x: number; y: number; width: number; height: number; confidence: number }
|
||
interface RapidOcrLine { text: string; mean: number; box?: number[][] }
|
||
|
||
async function getOcr(): Promise<{ detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> }> {
|
||
if (defaultEngine.ocrInstance) return defaultEngine.ocrInstance as { detect: (image: string) => Promise<{ texts: RapidOcrLine[] }> };
|
||
const mod = await import("@gutenye/ocr-node");
|
||
const instance = await mod.default.create();
|
||
defaultEngine.ocrInstance = instance;
|
||
return instance;
|
||
}
|
||
|
||
function requireImage(args: Record<string, unknown>, nodeId: string): string {
|
||
// uiSelector 是 OcrOffline_GetOcrTextNew 目录里唯一的图片字段(P0-1 修复)
|
||
for (const key of ["image_path", "imagePath", "file_path", "filePath", "path", "image", "pictureSource", "uiSelector"]) {
|
||
const v = String(args[key] ?? "").trim();
|
||
if (v) return v;
|
||
}
|
||
throw new RuntimeError("validation_error", "图片路径为空", { nodeId, componentId: nodeId, recommendation: "填写本地图片路径(png/jpg/bmp)" });
|
||
}
|
||
|
||
function boxToRect(box: number[][] | undefined): { x: number; y: number; width: number; height: number } {
|
||
const points = box ?? [];
|
||
const xs = points.map(p => p[0]);
|
||
const ys = points.map(p => p[1]);
|
||
const minX = Math.min(...(xs.length ? xs : [0]));
|
||
const minY = Math.min(...(ys.length ? ys : [0]));
|
||
const maxX = Math.max(...(xs.length ? xs : [0]));
|
||
const maxY = Math.max(...(ys.length ? ys : [0]));
|
||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||
}
|
||
|
||
register("OcrOffline_GetOcrTextNew", async (ctx, node, args) => {
|
||
const imagePath = requireImage(args, node.id);
|
||
const ocr = await getOcr();
|
||
const result = await ocr.detect(imagePath);
|
||
const text = result.texts.map(t => t.text).join("\n");
|
||
ctx.log("OCR 识别完成:" + text.length + " 字符 / " + result.texts.length + " 行", "info", node.id);
|
||
return text;
|
||
});
|
||
|
||
register("OcrOffline_GetOcrTextPosition", async (ctx, node, args) => {
|
||
const imagePath = requireImage(args, node.id);
|
||
const ocr = await getOcr();
|
||
const result = await ocr.detect(imagePath);
|
||
const words = result.texts.map(t => {
|
||
const rect = boxToRect(t.box);
|
||
return { text: t.text, x: rect.x, y: rect.y, width: rect.width, height: rect.height, confidence: t.mean };
|
||
});
|
||
ctx.log("OCR 定位完成:" + words.length + " 行", "info", node.id);
|
||
return words;
|
||
});
|
||
|
||
/** 按垂直中心聚类成行(行高 60% 为阈值),行内按 x 排序,返回二维文本表格 */
|
||
function groupOcrRows(words: RapidOcrLine[]): string[][] {
|
||
const prepared = words
|
||
.filter((word) => String(word.text ?? "").trim())
|
||
.map((word) => {
|
||
const box = word.box ?? [];
|
||
const xs = box.map((point) => point[0]);
|
||
const ys = box.map((point) => point[1]);
|
||
const x = xs.length ? Math.min(...xs) : 0;
|
||
const y = ys.length ? Math.min(...ys) : 0;
|
||
const height = ys.length ? Math.max(...ys) - y : 0;
|
||
return { text: word.text, x, cy: y + height / 2, h: height };
|
||
})
|
||
.sort((a, b) => a.cy - b.cy || a.x - b.x);
|
||
const rows: Array<{ cy: number; h: number; cells: typeof prepared }> = [];
|
||
let current: (typeof rows)[number] | null = null;
|
||
for (const item of prepared) {
|
||
if (!current || Math.abs(item.cy - current.cy) > Math.max(current.h, item.h, 8) * 0.6) {
|
||
current = { cy: item.cy, h: item.h, cells: [] };
|
||
rows.push(current);
|
||
}
|
||
current.cells.push(item);
|
||
}
|
||
return rows.map((row) => row.cells.sort((a, b) => a.x - b.x).map((cell) => cell.text.trim()));
|
||
}
|
||
|
||
register("OcrOffline_GetOcrTable", async (ctx, node, args) => {
|
||
const imagePath = requireImage(args, node.id);
|
||
const ocr = await getOcr();
|
||
const result = await ocr.detect(imagePath);
|
||
const rows = groupOcrRows(result.texts);
|
||
ctx.log("OCR 表格识别完成:" + rows.length + " 行", "info", node.id);
|
||
|
||
// showTable=1:写入流程数据表(与 Bivartable 系列组件共享同一份数据簿)
|
||
if (String(args.showTable ?? "1") === "1") {
|
||
ctx.vars["__rpaDatabook"] = rows;
|
||
ctx.log("已写入流程数据表", "info", node.id);
|
||
}
|
||
|
||
// isExcel=1:另存为 Excel 文件
|
||
let excelFile = "";
|
||
if (String(args.isExcel ?? "0") === "1") {
|
||
const ExcelJS = (await import("exceljs")).default;
|
||
const workbook = new ExcelJS.Workbook();
|
||
const sheet = workbook.addWorksheet("Sheet1");
|
||
sheet.addRows(rows);
|
||
const rawFolder = String(args.excelPath ?? "").trim();
|
||
// ${res} 流程资源目录占位与空值统一落到运行证据目录
|
||
const folder = rawFolder && rawFolder !== "${res}" ? rawFolder : ctx.evidenceDir;
|
||
const fileName = String(args.excelName ?? "表格数据.xlsx").replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 8));
|
||
excelFile = path.resolve(path.isAbsolute(fileName) ? fileName : path.join(folder, fileName));
|
||
await fs.mkdir(path.dirname(excelFile), { recursive: true });
|
||
await workbook.xlsx.writeFile(excelFile);
|
||
ctx.log("已保存 Excel:" + excelFile, "info", node.id);
|
||
}
|
||
|
||
return excelFile ? { rows, excelFile } : rows;
|
||
}); |