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, 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; });