This commit is contained in:
yu 2026-10-30 18:02:50 +08:00
commit 9a5f86c92a
12 changed files with 1820 additions and 49 deletions

View File

@ -28,7 +28,8 @@ export default {
asarUnpack: [
"node_modules/onnxruntime-node/**",
"node_modules/sharp/**",
"node_modules/@gutenye/**"
"node_modules/@gutenye/**",
"node_modules/@napi-rs/**"
],
compression: "maximum",
npmRebuild: false,

926
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,13 +17,17 @@
"test:e2e": "node scripts/demo-e2e.mjs"
},
"dependencies": {
"@cantoo/pdf-lib": "^2.11.1",
"@gutenye/ocr-models": "^1.4.2",
"@gutenye/ocr-node": "^1.4.9",
"@napi-rs/canvas": "^1.1.0",
"@xyflow/react": "^12.12.0",
"adm-zip": "^0.5.16",
"basic-ftp": "^6.2.3",
"exceljs": "^4.4.0",
"imapflow": "^2.10.0",
"lucide-react": "^0.468.0",
"mailparser": "^3.14.1",
"nodemailer": "^6.9.16",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",

View File

@ -0,0 +1,60 @@
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell } from "./desktop";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
interface CvTarget { text: string; cx: number; cy: number }
let paddleOcr: { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> } | null = null;
async function getOcr() {
if (paddleOcr) return paddleOcr;
const mod = await import("@gutenye/ocr-node");
paddleOcr = await mod.default.create();
return paddleOcr as { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> };
}
function cvTarget(args: Record<string, unknown>, nodeId: string): string {
for (const key of ["cvSelector", "target", "text"]) {
const v = String(args[key] ?? "").trim();
if (v) return v.replace(/^text:/, "").replace(/^图片:/, "");
}
throw new RuntimeError("validation_error", "CV 目标为空", { nodeId, componentId: nodeId, recommendation: "填写要在屏幕上查找的目标文本" });
}
async function grabScreen(imagePath: string): Promise<void> {
fs.mkdirSync(path.dirname(imagePath), { recursive: true });
const psScript = [
"Add-Type -AssemblyName System.Drawing | Out-Null;",
"$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;",
"$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height);",
"$g = [System.Drawing.Graphics]::FromImage($bmp);",
"$g.CopyFromScreen($b.X, $b.Y, 0, 0, $b.Size);",
"$g.Dispose();",
"$bmp.Save(" + JSON.stringify(imagePath) + ");",
"$bmp.Dispose();"
].join(" ");
await powershell(psScript, 30000);
}
interface OcrLine { text: string; box?: number[][]; mean?: number }
async function locateTextOnScreen(target: string, nodeId: string): Promise<{ target: string; text: string; cx: number; cy: number }> {
const shotPath = path.join(os.tmpdir(), "rpa-cv-screen-" + Date.now() + ".png");
await grabScreen(shotPath);
const ocr = await getOcr();
const result = await ocr.detect(shotPath);
const texts = result.texts ?? [];
const keyword = target.trim();
const hit = texts.find(t => t.text && t.text.includes(keyword));
try { fs.unlinkSync(shotPath); } catch {}
if (!hit || !hit.box || hit.box.length < 4) {
throw new RuntimeError("cv_not_found", "屏幕上未找到目标文本:" + keyword, { nodeId, componentId: nodeId, recommendation: "确认目标文本在屏幕上可见且清晰" });
}
const xs = hit.box.map(p => p[0]);
const ys = hit.box.map(p => p[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
return { target: keyword, text: hit.text, cx, cy };
}

View File

@ -8,7 +8,20 @@ describe("desktop UIA ground capabilities", () => {
"Element_SetValueGround",
"Element_ExistsGround",
"Element_GetValueGround",
"Element_WaitElementGround"
"Element_WaitElementGround",
"Element_GetCheckGround",
"Element_SetCheckGround",
"Element_RegionGround",
"Element_GetSelectGround",
"Element_SetSelectGround",
"Element_GetAttributeGround",
"Element_SetAttributeGround",
"Element_GetButtonEnableGround",
"Element_ShotGround",
"Element_MouseToGround",
"Element_ScrollIntoViewGround",
"Element_ChildrenGround",
"Element_DesktopChildren"
])("registers %s", (id) => {
expect(getHandler(id), id).toBeTruthy();
});

View File

@ -1,4 +1,6 @@
import { register } from "../engine/registry";
import fs from "node:fs";
import path from "node:path";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
@ -468,3 +470,242 @@ register("Element_WaitElementGround", waitElementGround);
function outputPath(args: Record<string, unknown>, extension = "png"): string {
const raw = args.filePath ?? args.folder ?? args.path;
const folder = path.resolve(String(raw && String(raw).trim() ? raw : process.cwd()));
const rawName = String(args.filename ?? args.fileName ?? args.name ?? `element-${Date.now()}.${extension}`)
.replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 10));
const name = /\.[A-Za-z0-9]{2,8}$/.test(rawName) ? rawName : `${rawName}.${extension}`;
return path.join(folder, name);
}
function propertyValue(scriptLines: string[], t: UiaTarget): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
$el = $null
try {
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
${scriptLines.join("\n")}`;
}
const getCheckGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getCheck", propertyValue([
"$checked = $false",
"try { $checked = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}",
"Write-Output (@{ value = [bool]$checked; name = $el.Current.Name; enabled = [bool]$el.Current.IsEnabled } | ConvertTo-Json -Compress)"
], t), runScript, args);
return result.value === true;
};
const setCheckGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const desired = ["1", "true", "yes", "on"].includes(String(args.isChecked ?? "1").toLowerCase());
return runWithImplicitWait(t, "setCheck", propertyValue([
`$desired = ${desired ? "$true" : "$false"}`,
"$current = $false; $hasToggle = $false",
"try { $pattern = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern); $current = $pattern.Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On; $hasToggle = $true } catch {}",
"$method = if ($hasToggle) { 'toggle_pattern' } else { 'coordinate_click' }",
"if (-not $hasToggle -or $current -ne $desired) {",
" if ($hasToggle) { $pattern.Toggle() | Out-Null }",
" else {",
" $rect = $el.Current.BoundingRectangle; Add-RpaUser32",
" [Win32Rpa.RpaUser32Click]::SetCursorPos([int]($rect.X + $rect.Width / 2), [int]($rect.Y + $rect.Height / 2)) | Out-Null",
" Start-Sleep -Milliseconds 100; [Win32Rpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
" Start-Sleep -Milliseconds 50; [Win32Rpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)",
" }",
"}",
"$after = $false; try { $after = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}",
"Write-Output (@{ set = ($after -eq $desired); checked = [bool]$after; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
const regionGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const relative = String(args.position ?? "3") === "2";
const result = await runWithImplicitWait(t, "getRegion", propertyValue([
"$rect = $el.Current.BoundingRectangle; $ox = 0; $oy = 0",
relative ? [
"try {",
" Add-RpaUser32",
' Add-Type -MemberDefinition \'[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }\' -Name RpaWindowRect -Namespace Win32RpaRect | Out-Null',
" $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $ancestor = $el",
" while ($null -ne $ancestor) {",
" $hwnd = $ancestor.Current.NativeWindowHandle",
" if ($hwnd -ne 0) { $wr = New-Object Win32RpaRect.RpaWindowRect+RECT; [void][Win32RpaRect.RpaWindowRect]::GetWindowRect([IntPtr]$hwnd, [ref]$wr); $ox = $wr.Left; $oy = $wr.Top; break }",
" $ancestor = $walker.GetParent($ancestor)",
" }",
"} catch {}",
].join("\n") : "",
"Write-Output (@{ x = [int]($rect.X - $ox); y = [int]($rect.Y - $oy); width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)"
], t), runScript, args);
return result;
};
function getSelectScript(t: UiaTarget): string {
const byText = String(t.args.choose ?? "1") !== "2";
return propertyValue([
"$result = @(); $method = 'selection_pattern'",
"try { $sel = $el.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern); foreach ($item in $sel.Current.GetSelection()) { $value = $item.Current.Name; if (-not " + (byText ? "$true" : "$false") + ") { $value = $item.Current.AutomationId }; $result += @{ text = $item.Current.Name; value = $item.Current.AutomationId; selected = $true } } } catch {}",
"if ($result.Count -eq 0) { try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }; $result += @{ text = [string]$value; value = [string]$value; selected = $true }; $method = 'value_pattern' }",
"Write-Output (@{ result = $result; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress -Depth 3)"
], t);
}
const getSelectGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getSelect", getSelectScript(t), runScript, args);
const items = Array.isArray(result.result) ? result.result : [];
return items.length === 1 ? items[0] : items;
};
function setSelectScript(t: UiaTarget, values: string[]): string {
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
const choose = String(t.args.choose ?? "1");
return propertyValue([
`$desired = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json`,
"$items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' })",
"if ($items.Count -eq 0) { try { $el.Expand() } catch {}; Start-Sleep -Milliseconds 250; $items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' }) }",
"$targets = @(); $method = 'selection_item_pattern'",
"foreach ($wanted in @($desired)) {",
` if ('${choose}' -eq '0') { $ix = [int]$wanted - 1; if ($ix -ge 0 -and $ix -lt $items.Count) { $targets += $items[$ix] } }`,
` elseif ('${choose}' -eq '2') { $targets += @($items | Where-Object { $_.Current.AutomationId -eq [string]$wanted }) }`,
" else { $targets += @($items | Where-Object { $_.Current.Name -eq [string]$wanted }) }",
"}",
"$ok = $targets.Count -gt 0",
"foreach ($item in $targets) { try { $item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() } catch { try { $item.Invoke() } catch { $ok = $false } } }",
"try { $el.Collapse() } catch {}",
"Write-Output (@{ set = [bool]$ok; matched = $targets.Count; available = $items.Count; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
const setSelectGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const raw = args.itemsToSelect ?? args.value ?? args.text;
let values: string[] = [];
if (Array.isArray(raw)) values = raw.map((value) => String(value));
else if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
try { const parsed = JSON.parse(String(raw)); values = Array.isArray(parsed) ? parsed.map((value) => String(value)) : [String(parsed)]; }
catch { values = [String(raw)]; }
}
if (!values.length) throw new RuntimeError("validation_error", "下拉框选择项为空", { nodeId: node.id, componentId: node.componentId });
return runWithImplicitWait(t, "setSelect", setSelectScript(t, values), runScript, args);
};
function attrScript(t: UiaTarget, attrName: string, attrValue = ""): string {
const name = attrName.toLowerCase();
if (name === "value" || name === "text") {
return propertyValue([
"try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }",
"Write-Output (@{ value = [string]$value; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
if (!attrValue) {
return propertyValue([
"$p = @{ name = $el.Current.Name; id = $el.Current.AutomationId; class = $el.Current.ClassName; type = $el.Current.ControlType.ProgrammaticName; enabled = $el.Current.IsEnabled; help = $el.Current.HelpText; value = $el.Current.Name }",
`$key = ${psString(name || "name")}`,
"$result = if ($p.Contains($key)) { $p[$key] } else { $p.name }",
"Write-Output (@{ value = [string]$result; properties = $p; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
if (name !== "value" && name !== "text") throw new RuntimeError("validation_error", `桌面 UIA 不支持设置属性:${attrName};可设置 value/text`, { nodeId: t.node.id, componentId: t.node.componentId });
return propertyValue([
"$ok = $false; $method = 'value_pattern'",
`try { $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).SetValue(${psString(attrValue)}); $ok = $true } catch {}`,
"if (-not $ok) { $method = 'keyboard'; Add-Type -AssemblyName System.Windows.Forms; $el.SetFocus() | Out-Null; Start-Sleep -Milliseconds 100; [System.Windows.Forms.SendKeys]::SendWait('^a'); [System.Windows.Forms.SendKeys]::SendWait('^v'); Start-Sleep -Milliseconds 100; $ok = $true }",
"Write-Output (@{ set = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
const getAttributeGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const attrName = String(args.attrName ?? args.attribute ?? "name");
const result = await runWithImplicitWait(t, "getAttribute", attrScript(t, attrName), runScript, args);
return result.value ?? "";
};
const setAttributeGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const attrName = String(args.attrName ?? args.attribute ?? "value");
const attrValue = String(args.attrValue ?? args.value ?? "");
return runWithImplicitWait(t, "setAttribute", attrScript(t, attrName, attrValue), runScript, args);
};
const getButtonEnableGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getButtonEnable", propertyValue(["Write-Output (@{ value = [bool]$el.Current.IsEnabled; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args);
return result.value === true;
};
const shotGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const output = outputPath(args);
fs.mkdirSync(path.dirname(output), { recursive: true });
const result = await runWithImplicitWait(t, "shot", propertyValue([
"$rect = $el.Current.BoundingRectangle; if ($rect.Width -le 0 -or $rect.Height -le 0) { throw 'element_not_visible' }",
"Add-Type -AssemblyName System.Drawing | Out-Null",
"$bitmap = New-Object System.Drawing.Bitmap([int]$rect.Width, [int]$rect.Height)",
"$graphics = [System.Drawing.Graphics]::FromImage($bitmap)",
"$graphics.CopyFromScreen([int]$rect.X, [int]$rect.Y, 0, 0, $bitmap.Size); $graphics.Dispose()",
`$bitmap.Save(${psString(output)}, [System.Drawing.Imaging.ImageFormat]::Png); $bitmap.Dispose()`,
`Write-Output (@{ value = ${psString(output)}; x = [int]$rect.X; y = [int]$rect.Y; width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)`
], t), runScript, args);
return result.value ?? output;
};
const mouseToGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "mouseTo", propertyValue([
"$rect = $el.Current.BoundingRectangle; Add-RpaUser32",
"$x = [int]($rect.X + $rect.Width / 2); $y = [int]($rect.Y + $rect.Height / 2)",
"[Win32Rpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null",
"Write-Output (@{ x = $x; y = $y; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
const scrollIntoViewGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "scrollIntoView", propertyValue([
"$ok = $false; $method = 'scroll_item_pattern'",
"try { $el.GetCurrentPattern([System.Windows.Automation.ScrollItemPattern]::Pattern).ScrollIntoView(); $ok = $true } catch {}",
"if (-not $ok) { $method = 'focus'; try { $el.SetFocus() | Out-Null; $ok = $true } catch {} }",
"Write-Output (@{ scrolled = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
function childrenGroundHandler(single: boolean): CapabilityHandler {
return async (_ctx, node, args) => {
const t = target(args, node);
const num = Math.max(0, Math.floor(Number(args.num ?? 0) || 0));
const result = await runWithImplicitWait(t, "getChildren", propertyValue([
"$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $child = $walker.GetFirstChild($el); $children = @()",
"while ($null -ne $child) {",
" $r = $child.Current.BoundingRectangle",
" $children += @{ name = $child.Current.Name; id = $child.Current.AutomationId; type = $child.Current.ControlType.ProgrammaticName; className = $child.Current.ClassName; enabled = [bool]$child.Current.IsEnabled; x = [int]$r.X; y = [int]$r.Y; width = [int]$r.Width; height = [int]$r.Height }",
" $child = $walker.GetNextSibling($child)",
"}",
single ? `$n = ${num}; if ($n -ge 1 -and $n -le $children.Count) { $children = @($children[$n - 1]) }` : "",
"Write-Output (@{ result = $children; count = $children.Count } | ConvertTo-Json -Compress -Depth 4)"
], t), runScript, args);
return result.result ?? [];
};
}
register("Element_GetCheckGround", getCheckGround);
register("Element_SetCheckGround", setCheckGround);
register("Element_RegionGround", regionGround);
register("Element_GetSelectGround", getSelectGround);
register("Element_SetSelectGround", setSelectGround);
register("Element_GetAttributeGround", getAttributeGround);
register("Element_SetAttributeGround", setAttributeGround);
register("Element_GetButtonEnableGround", getButtonEnableGround);
register("Element_ShotGround", shotGround);
register("Element_MouseToGround", mouseToGround);
register("Element_ScrollIntoViewGround", scrollIntoViewGround);
register("Element_ChildrenGround", childrenGroundHandler(false));
register("Element_DesktopChildren", childrenGroundHandler(true));

View File

@ -0,0 +1,67 @@
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler, hasCapability } from "../engine/registry";
import "./message";
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
const ids = [
"Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2",
"Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2",
"Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"
];
afterEach(async () => {});
describe("mail receive and reply", () => {
it("registers find, reply and Outlook message capabilities", () => {
for (const id of ids) expect(hasCapability(id), id).toBe(true);
});
it("replies through SMTP with the selected mail sender", async () => {
let received = "";
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.write("220 localhost ESMTP\r\n");
let buffer = "";
let dataMode = false;
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\r\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (dataMode) {
if (line === ".") {
dataMode = false;
socket.write("250 queued\r\n");
} else received += line + "\n";
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
else if (/^MAIL FROM/i.test(line)) socket.write("250 ok\r\n");
else if (/^RCPT TO/i.test(line)) socket.write("250 ok\r\n");
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as net.AddressInfo).port;
try {
const handler = getHandler("Mail_ReplyMail_v2");
expect(handler).toBeTruthy();
const result = await handler!(context, node("Mail_ReplyMail_v2"), {
account_obj: { host: "127.0.0.1", port, account: "robot@example.com", secure: "False" },
mail_obj: { from: "owner@example.com", subject: "status", messageId: "<abc@example.com>" },
content: "reply delivered"
}, component);
expect(result).toMatchObject({ accepted: ["owner@example.com"] });
expect(received).toContain("reply delivered");
expect(received).toContain("Re: status");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30000);
});

View File

@ -1,5 +1,7 @@
import nodemailer from "nodemailer";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell } from "./desktop";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
@ -42,3 +44,131 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
transporter.close();
}
});
export interface NormalizedMail {
uid: string; from: string; to: string[]; cc: string[]; subject: string;
text: string; html: string; date: string; messageId: string; hasAttachment: boolean;
attachmentNames: string[];
}
export function normalizeAccount(args: Record<string, unknown>): Record<string, unknown> {
return accountObject(args.account_obj ?? args.accountObj ?? args.account);
}
function addressList(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.map((item) => String(typeof item === "object" && item && "text" in (item as Record<string, unknown>) ? (item as { text: unknown }).text : item)).filter(Boolean);
if (typeof value === "object") { const record = value as { text?: unknown; value?: unknown }; return addressList(record.text ?? record.value); }
return String(value).split(/[;,,;\s]+/).filter(Boolean);
}
export function normalizeMail(input: Record<string, unknown>, uid = ""): NormalizedMail {
const attachments = Array.isArray(input.attachments) ? input.attachments as Array<Record<string, unknown>> : [];
return {
uid: String(input.uid ?? uid),
from: String(input.from ?? input.sender ?? ""),
to: addressList(input.to), cc: addressList(input.cc),
subject: String(input.subject ?? ""),
text: String(input.text ?? input.body ?? ""),
html: String(input.html ?? ""),
date: input.date instanceof Date ? input.date.toISOString() : String(input.date ?? input.receivedTime ?? ""),
messageId: String(input.messageId ?? ""),
hasAttachment: Boolean(input.hasAttachment ?? attachments.length),
attachmentNames: attachments.map((item) => String(item.filename ?? item.name ?? "")).filter(Boolean)
};
}
function matches(mail: NormalizedMail, args: Record<string, unknown>): boolean {
const contains = (needle: unknown, haystack: string): boolean => !needle || haystack.toLowerCase().includes(String(needle).toLowerCase());
return contains(args.From_data, mail.from)
&& contains(args.To_data, mail.to.join(" "))
&& contains(args.Subject_data, mail.subject)
&& contains(args.Body_data, mail.text + "\n" + mail.html)
&& (args.attachment === undefined || String(args.attachment) === "2" || (String(args.attachment) === "1") === mail.hasAttachment);
}
async function findImapMail(args: Record<string, unknown>, node: { id: string; componentId: string }): Promise<NormalizedMail[]> {
const configured = normalizeAccount(args);
const merged = { ...args, ...configured };
const account = text(merged, ["account", "username", "user", "from"]);
const password = text(merged, ["password", "pass", "token"]);
const host = text(merged, ["imap", "imapHost", "imap_host", "server", "host"]);
if (!host || !account) throw new RuntimeError("validation_error", "IMAP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId });
const port = Number(text(merged, ["imapPort", "imap_port", "recvPort", "port"], "993"));
const secure = ["true", "1", "是", "True"].includes(text(merged, ["imap_ssl", "imapSsl", "is_ssl", "secure"], "True"));
const folder = text(args, ["folder_name", "email_folder", "folder"], "INBOX");
const { ImapFlow } = await import("imapflow");
const client = new ImapFlow({ host, port, secure, auth: { user: account, pass: password }, logger: false });
const mails: NormalizedMail[] = [];
try {
await client.connect();
const lock = await client.getMailboxLock(folder);
try {
const range = String(args.search_range ?? "ALL").toUpperCase();
const criteria: Record<string, unknown> = range === "UNSEEN" ? { seen: false } : range === "SEEN" ? { seen: true } : range === "FLAGGED" ? { flagged: true } : range === "ANSWERED" ? { answered: true } : range === "DELETED" ? { deleted: true } : range === "DRAFT" ? { draft: true } : { all: true };
if (args.From_data) criteria.from = String(args.From_data);
if (args.To_data) criteria.to = String(args.To_data);
if (args.Subject_data) criteria.subject = String(args.Subject_data);
const uids = await client.search(criteria as never, { uid: true }) || [];
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
for (let index = uids.length - 1; index >= 0 && mails.length < Math.max(20, selected); index -= 1) {
const download = await client.fetchOne(String(uids[index]), { uid: true, envelope: true, flags: true, bodyStructure: true, source: true }, { uid: true });
if (!download || !download.source) continue;
const { simpleParser } = await import("mailparser");
const parsed = await simpleParser(download.source);
const mail = normalizeMail(parsed as unknown as Record<string, unknown>, String(download.uid));
if (matches(mail, args)) mails.push(mail);
}
} finally { lock.release(); }
} finally { client.close(); }
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
return mails.slice(0, selected);
}
register(["Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2"], async (_ctx, node, args) => findImapMail(args, node));
register(["Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2"], async (_ctx, node, args) => {
const configured = accountObject(args.mail_obj ?? args.email_obj ?? args.account_obj);
const mail = configured && Object.keys(configured).length ? configured as unknown as NormalizedMail : args.mail as unknown as NormalizedMail;
const merged = { ...args, ...normalizeAccount(args) };
const account = text(merged, ["account", "username", "user", "from"]);
const password = text(merged, ["password", "pass"]);
const host = text(merged, ["smtp", "smtpHost", "smtp_host", "server", "host"]);
if (!host || !account) throw new RuntimeError("validation_error", "SMTP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId });
const port = Number(text(merged, ["smtpPort", "smtp_port", "sendPort", "port"], "465"));
const secure = ["true", "1", "是", "True"].includes(text(merged, ["smtp_ssl", "smtpSsl", "is_ssl", "secure"], "True"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
try {
const result = await transporter.sendMail({
from: account,
to: mail?.from ? [mail.from] : list(text(args, ["to", "receiver"])),
subject: text(args, ["subject"], mail?.subject ? "Re: " + mail.subject : "Re:"),
text: text(args, ["content", "body"]),
inReplyTo: mail?.messageId, references: mail?.messageId,
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
} finally { transporter.close(); }
});
function outlookScript(args: Record<string, unknown>): string {
const criteria = Buffer.from(JSON.stringify({ to: String(args.To_data ?? ""), from: String(args.From_data ?? ""), subject: String(args.Subject_data ?? ""), body: String(args.Body_data ?? "") }), "utf8").toString("base64");
return [
"$outlook=New-Object -ComObject Outlook.Application;$ns=$outlook.GetNamespace('MAPI')",
`$folderPath=${JSON.stringify(String(args.folder_name || args.email_folder || "INBOX"))}`,
"$folder=$ns.GetDefaultFolder(6);foreach($part in ($folderPath -split '/')){ if($part -and $part -ne 'INBOX'){ $folder=$folder.Folders.Item($part) } }",
"$folder.Items.Sort('[ReceivedTime]',$true)|Out-Null;$rows=@();$count=[Math]::Min(500,$folder.Items.Count)",
`$criteria=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${criteria}'))|ConvertFrom-Json`,
"for($i=1;$i -le $count;$i++){ $item=$folder.Items.Item($i); $subject=[string]$item.Subject; $body=[string]$item.Body; $to=[string]$item.To; $from=[string]$item.SenderEmailAddress; if(($criteria.to -and $to -notlike ('*'+$criteria.to+'*')) -or ($criteria.from -and $from -notlike ('*'+$criteria.from+'*')) -or ($criteria.subject -and $subject -notlike ('*'+$criteria.subject+'*')) -or ($criteria.body -and $body -notlike ('*'+$criteria.body+'*'))){continue}; $bodyB64=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body)); $rows += @{uid=[string]$item.EntryID;from=$from;to=$to;subject=$subject;text=$bodyB64;html='';date=$item.ReceivedTime.ToString('o');messageId=[string]$item.EntryID;hasAttachment=($item.Attachments.Count -gt 0);attachmentNames=@()}; if($rows.Count -ge 100){break} }",
"$json=$rows|ConvertTo-Json -Compress -Depth 4;[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output"
].join(";");
}
register(["Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"], async (_ctx, node, args) => {
const output = await powershell(outlookScript(args), 120000);
if (!output.trim()) throw new RuntimeError("outlook_unavailable", "Outlook COM 未返回数据;请确认 Outlook 已安装并至少配置一个邮箱", { nodeId: node.id, componentId: node.componentId });
const rows = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8"));
const mails = (Array.isArray(rows) ? rows : [rows]).map((row: Record<string, unknown>) => normalizeMail({ ...row, text: row.text ? Buffer.from(String(row.text), "base64").toString("utf8") : "" }));
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
return mails.slice(0, selected);
});

View File

@ -0,0 +1,54 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import "./pdf";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown>) {
const handler = getHandler(id);
expect(handler, id).toBeTruthy();
return handler!(context, node(id), args, component);
}
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
describe("PDF high-level capabilities", () => {
it("creates a pdf fixture", async () => {
const directory = await mkdtempSafe();
const pdf = await PDFDocument.create();
const page = pdf.addPage([220, 120]);
const font = await pdf.embedFont(StandardFonts.Helvetica);
const png = new PNG({ width: 8, height: 8 });
png.data.fill(255); png.data[0] = 20; png.data[1] = 90; png.data[2] = 200; png.data[3] = 255;
page.drawImage(await pdf.embedPng(PNG.sync.write(png)), { x: 140, y: 70, width: 40, height: 40 });
for (const [value, x, y] of [["Name", 24, 90], ["Age", 120, 90], ["Alice", 24, 72], ["30", 120, 72]] as Array<[string, number, number]>) page.drawText(value, { x, y, size: 9, font });
await writeFile(path.join(directory, "source.pdf"), await pdf.save());
const filePath = path.join(directory, "source.pdf");
expect((await readFile(path.join(directory, "source.pdf"))).byteLength).toBeGreaterThan(0);
const images = await run("PDF_GetPdfImg", { fileInPath: filePath, pdfRange: "all", pdfNum: "1", imgSavePath: directory, imageType: "png" }) as string[];
expect(images.length).toBeGreaterThan(0);
expect((await readFile(images[0])).subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
await expect(run("PDF_GetPdfTable", { fileInPath: filePath, pdfRange: "all", pdfNum: "1" })).resolves.toEqual([["Name", "Age"], ["Alice", "30"]]);
const encryptedPath = path.join(directory, "encrypted.pdf");
await writeFile(encryptedPath, await readFile(filePath));
await expect(run("PDF_PdfEncryption", { fileInPath: encryptedPath, passWord: "secret" })).resolves.toBe(encryptedPath);
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise;
expect(opened.numPages).toBe(1);
});
});
async function mkdtempSafe(): Promise<string> {
const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-"));
directories.push(directory);
return directory;
}

View File

@ -1,6 +1,8 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import fsSync from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PDFDocument } from "pdf-lib";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
@ -60,3 +62,203 @@ register("PDF_GetPdfText", async (_ctx, node, args) => {
}
return chunks.join("\n");
});
type PdfJsDocument = Awaited<ReturnType<typeof import("pdfjs-dist/legacy/build/pdf.mjs")["getDocument"]>["promise"]>;
function standardFontUrl(): string | undefined {
const candidates = [
process.env.RPA_PDFJS_STANDARD_FONTS,
path.join(process.cwd(), "node_modules", "pdfjs-dist", "standard_fonts"),
path.join(process.execPath, "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts"),
path.join(process.execPath, "..", "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts")
].filter(Boolean) as string[];
const found = candidates.find((candidate) => fsSync.existsSync(candidate));
return found ? pathToFileURL(path.join(found, path.sep)).href : undefined;
}
class LocalStandardFontFactory {
baseUrl: string;
constructor(options: { baseUrl: string }) { this.baseUrl = options.baseUrl; }
async fetch({ filename }: { filename: string }): Promise<Uint8Array> {
return new Uint8Array(await fs.readFile(path.join(fileURLToPath(this.baseUrl), filename)));
}
}
async function loadPdf(filePath: string, password: string) {
const canvasModule = await import("@napi-rs/canvas");
const globals = globalThis as unknown as Record<string, unknown>;
globals.Path2D = canvasModule.Path2D;
globals.DOMMatrix = canvasModule.DOMMatrix;
globals.ImageData = canvasModule.ImageData;
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
return { pdfjs, document: await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)), password: password || undefined, isEvalSupported: false, useSystemFonts: false, standardFontDataUrl: standardFontUrl(), StandardFontDataFactory: LocalStandardFontFactory }).promise };
}
function pageIndices(document: PdfJsDocument, raw: unknown): number[] {
const range = String(raw ?? "all").trim().toLowerCase();
if (!range || range === "all") return Array.from({ length: document.numPages }, (_, index) => index + 1);
const result = new Set<number>();
for (const part of range.split(/[,,;;\s]+/).filter(Boolean)) {
const match = part.match(/^(\d+)-(\d+)$/);
if (match) {
const start = Math.max(1, Number(match[1])); const end = Math.min(document.numPages, Number(match[2]));
for (let index = start; index <= end; index += 1) result.add(index);
} else {
const index = Number(part);
if (index >= 1 && index <= document.numPages) result.add(Math.floor(index));
}
}
const values = [...result].sort((left, right) => left - right);
if (!values.length) throw new Error(`PDF 页码范围无效:${range}`);
return values;
}
async function renderPage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, scale = 2): Promise<Buffer> {
const canvasModule = await import("@napi-rs/canvas");
const viewport = page.getViewport({ scale });
const canvas = canvasModule.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
const context = canvas.getContext("2d");
context.fillStyle = "white"; context.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise;
return canvas.toBuffer("image/png");
}
function saveImage(folder: string, prefix: string, suffix: string, data: Buffer, type: string): string {
const ext = /jpe?g/i.test(type) ? "jpg" : "png";
const output = path.join(path.resolve(folder), `${prefix}_${suffix}_${Date.now()}.${ext}`);
fsSync.mkdirSync(path.dirname(output), { recursive: true });
fsSync.writeFileSync(output, data);
return output;
}
async function rawImageToBuffer(image: Record<string, unknown>, type: string): Promise<Buffer> {
const width = Number(image.width || 0); const height = Number(image.height || 0);
const data = image.data;
if (!width || !height || !(data instanceof Uint8Array) || data.length === 0) throw new Error("PDF 图片数据无效");
if (data[0] === 0xff && data[1] === 0xd8) return Buffer.from(data);
const canvasModule = await import("@napi-rs/canvas");
const canvas = canvasModule.createCanvas(width, height); const context = canvas.getContext("2d");
const target = context.createImageData(width, height); const kind = Number(image.kind || 0);
if (kind === 1) {
const bytesPerRow = Math.ceil(width / 8);
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
const bit = (data[y * bytesPerRow + (x >> 3)] >> (7 - (x & 7))) & 1; const output = (y * width + x) * 4; const color = bit ? 255 : 0;
target.data[output] = color; target.data[output + 1] = color; target.data[output + 2] = color; target.data[output + 3] = 255;
}
} else if (kind === 3) { target.data.set(data.subarray(0, Math.min(data.length, target.data.length))); }
else {
for (let source = 0, targetIndex = 0; source < data.length && targetIndex < target.data.length; source += 3, targetIndex += 4) {
target.data[targetIndex] = data[source]; target.data[targetIndex + 1] = data[source + 1] ?? 0; target.data[targetIndex + 2] = data[source + 2] ?? 0; target.data[targetIndex + 3] = 255;
}
}
context.putImageData(target, 0, 0);
return /jpe?g/i.test(type) ? canvas.toBuffer("image/jpeg") : canvas.toBuffer("image/png");
}
async function objectImage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, objId: unknown): Promise<Record<string, unknown> | null> {
if (typeof objId !== "string") return null;
const objects = objId.startsWith("g_") ? page.commonObjs : page.objs;
try { if (objects.has(objId)) return objects.get(objId) as Record<string, unknown>; } catch {}
return await new Promise((resolve) => { try { objects.get(objId, (value: unknown) => resolve(value as Record<string, unknown>)); } catch { resolve(null); } });
}
const encryption: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const password = String(args.passWord ?? args.password ?? "");
if (!password) throw new RuntimeError("validation_error", "PDF密码为空", { nodeId: node.id, componentId: node.componentId });
const { PDFDocument } = await import("@cantoo/pdf-lib");
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
document.encrypt({ userPassword: password, ownerPassword: password, permissions: { printing: "highResolution", copying: true, modifying: false } });
const temporary = `${filePath}.${Date.now()}.enc`;
await fs.writeFile(temporary, await document.save());
await fs.rename(temporary, filePath);
return filePath;
};
const extractImages: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const folder = required(text(args, ["imgSavePath", "pdfSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
const type = text(args, ["imageType", "type"], "png");
const prefix = text(args, ["imageName", "name"], "pdf-image");
const { pdfjs, document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
const outputs: string[] = [];
for (const pageNumber of pageIndices(document, args.pdfNum)) {
const page = await document.getPage(pageNumber);
const opList = await page.getOperatorList();
let sequence = 0;
for (let index = 0; index < opList.fnArray.length; index += 1) {
const op = opList.fnArray[index]; const commandArgs = opList.argsArray[index] as unknown[];
let image: Record<string, unknown> | null = null;
if (op === pdfjs.OPS.paintImageXObject || op === pdfjs.OPS.paintImageXObjectRepeat) image = await objectImage(page, commandArgs[0]);
else if (op === pdfjs.OPS.paintInlineImageXObject) image = commandArgs[0] as Record<string, unknown>;
else if (op === pdfjs.OPS.paintInlineImageXObjectGroup) image = commandArgs[0] as Record<string, unknown>;
if (!image) continue;
sequence += 1;
outputs.push(saveImage(folder, `${prefix}-p${pageNumber}`, String(sequence), await rawImageToBuffer(image, type), type));
}
}
return outputs;
};
const pdfToImages: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const folder = required(text(args, ["pdfSavePath", "imgSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
const type = text(args, ["imageType", "type"], "png"); const prefix = text(args, ["imageName", "name"], "pdf-page");
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
const outputs: string[] = [];
for (const pageNumber of pageIndices(document, args.pdfNum)) {
const buffer = await renderPage(await document.getPage(pageNumber));
outputs.push(saveImage(folder, prefix, String(pageNumber), buffer, type));
}
return outputs;
};
interface TextToken { x: number; y: number; value: string; }
async function extractTableRows(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>): Promise<string[][]> {
const content = await page.getTextContent();
const tokens = content.items.flatMap((item) => {
if (!("str" in item) || !item.str.trim()) return [];
const transform = (item as { transform?: number[] }).transform ?? [1,0,0,1,0,0];
return [{ x: Number(transform[4] || 0), y: Number(transform[5] || 0), value: item.str.trim() }];
});
if (tokens.length < 2) return [];
const lines = new Map<number, TextToken[]>();
for (const token of [...tokens].sort((left, right) => right.y - left.y || left.x - right.x)) {
const lineKey = [...lines.keys()].find((key) => Math.abs(key - token.y) <= 3.5) ?? token.y;
if (!lines.has(lineKey)) lines.set(lineKey, []);
lines.get(lineKey)!.push(token);
}
const lineList = [...lines.entries()].map(([y, values]) => ({ y, values: values.sort((left, right) => left.x - right.x) })).sort((left, right) => right.y - left.y);
const counts = new Map<number, number>();
for (const line of lineList) for (const token of line.values) {
const key = Math.round(token.x / 5) * 5;
counts.set(key, (counts.get(key) || 0) + 1);
}
const columns = [...counts.entries()].filter(([, count]) => count >= Math.min(2, lineList.length)).sort((left, right) => left[0] - right[0]).map(([key]) => key);
if (columns.length < 2) return [];
const rows: string[][] = [];
for (const line of lineList) {
const cells = columns.map(() => "");
for (const token of line.values) {
let nearest = 0;
for (let index = 1; index < columns.length; index += 1) if (Math.abs(token.x - columns[index]) < Math.abs(token.x - columns[nearest])) nearest = index;
cells[nearest] = cells[nearest] ? `${cells[nearest]} ${token.value}` : token.value;
}
if (cells.filter(Boolean).length >= 2) rows.push(cells);
}
return rows.length >= 2 ? rows : [];
}
const readTables: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
const rows: string[][] = [];
for (const pageNumber of pageIndices(document, args.pdfNum)) rows.push(...await extractTableRows(await document.getPage(pageNumber)));
return rows;
};
register("PDF_PdfEncryption", encryption);
register("PDF_GetPdfImg", extractImages);
register("PDF_PdfToImg", pdfToImages);
register("PDF_GetPdfTable", readTables);

View File

@ -15,7 +15,7 @@ const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0,
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close"];
const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close", "Word_ReadSelect", "Word_CutSelect", "Word_CopySelect", "Word_PasteSelect", "Word_DeleteSelect", "Word_SelectAll", "Word_SelectRow", "Word_SetPosition", "Word_MovePosition", "Word_PositionToSelect", "Word_ReadTable"];
describe("Word extended capabilities", () => {
it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); });
@ -33,4 +33,27 @@ describe("Word extended capabilities", () => {
await run("Word_Enter", { objWord: handle });
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
}, 120000);
it("uses a persistent selection and reads a Word table through COM", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-selection-")); directories.push(directory);
let handle = await run("Word_New_V1", { filePath: directory, name: "selection.docx" });
await run("Word_Write", { objWord: handle, text: "alpha beta gamma" });
handle = await run("Word_PositionToSelect", { objWord: handle, text: "beta", pos_index: 1, direction: "select" });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("beta");
handle = await run("Word_CopySelect", { objWord: handle });
handle = await run("Word_MovePosition", { objWord: handle, length: 1, unit: "word", direction: "right", isSelect: false });
await run("Word_PasteSelect", { objWord: handle });
handle = await run("Word_PositionToSelect", { objWord: handle, text: "gamma", direction: "select" });
handle = await run("Word_CutSelect", { objWord: handle });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("");
await run("Word_DeleteSelect", { objWord: handle });
handle = await run("Word_SetPosition", { objWord: handle, length: -2, unit: "word" });
handle = await run("Word_SelectRow", { objWord: handle, st: 1, ed: 1 });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha");
handle = await run("Word_SelectAll", { objWord: handle });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha beta beta");
await run("Word_WriteTable", { objWord: handle, data: [["name", "age"], ["Alice", "30"]] });
await expect(run("Word_ReadTable", { objWord: handle, find_type: "content", table_content: "Alice" })).resolves.toEqual([["name", "age"], ["Alice", "30"]]);
}, 180000);
});

View File

@ -122,3 +122,143 @@ register("Word_WriteTable", async (_ctx, node, args) => {
});
register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true }));
interface WordSelection { start: number; end: number; }
function selectionOf(args: Record<string, unknown>): WordSelection {
const handle = args.objWord;
const raw = handle && typeof handle === "object" ? (handle as { selection?: unknown }).selection : null;
const record = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
let start = Math.max(0, Math.floor(Number(record.start ?? 0) || 0));
let end = Math.max(0, Math.floor(Number(record.end ?? record.active ?? start) || 0));
if (end < start) [start, end] = [end, start];
return { start, end };
}
function selectionScript(filePath: string, selection: WordSelection, body = "", withText = true): string {
return `$word=$null;$doc=$null;try{` +
`$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});` +
`$sel=$doc.Range(${selection.start},${selection.end});$sel.Select()|Out-Null;${body};if($null -eq $sel){$sel=$doc.Content}` +
`$text="";if(${withText ? "$true" : "$false"}){$text=[string]$sel.Text};$out=@{start=[int]$sel.Start;end=[int]$sel.End;text=$text}|ConvertTo-Json -Compress;` +
`[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($out))|Write-Output;$doc.Save()` +
`}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`;
}
async function runSelection(filePath: string, selection: WordSelection, body = "", withText = true): Promise<WordSelection & { text: string }> {
const output = await powershell(selectionScript(filePath, selection, body, withText));
if (output.includes("__WORD_NOT_FOUND__")) throw new RuntimeError("word_text_not_found", "Word 查找文本不存在", { detail: `path=${filePath}` });
const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")) as { start: number; end: number; text: string };
return { start: parsed.start, end: parsed.end, text: parsed.text };
}
function selectionResult(filePath: string, selection: WordSelection): { path: string; selection: WordSelection } {
return { path: filePath, selection };
}
const selectionText = async (_ctx: unknown, node: { id: string; componentId: string }, args: Record<string, unknown>): Promise<string> =>
(await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args))).text;
const selectionMutation = (body: string): CapabilityHandlerLike =>
async (_ctx, node, args) => selectionResult(handlePath(args, node.id, node.componentId), await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args), body));
type CapabilityHandlerLike = Parameters<typeof register>[1];
register("Word_ReadSelect", selectionText);
register("Word_CopySelect", selectionMutation("$sel.Copy()|Out-Null"));
register("Word_CutSelect", selectionMutation("$sel.Cut()|Out-Null"));
register("Word_DeleteSelect", selectionMutation("[void]$sel.Delete()"));
register("Word_PasteSelect", selectionMutation("$sel.Paste()|Out-Null"));
register("Word_SelectAll", selectionMutation("$sel=$doc.Content;$sel.Select()|Out-Null"));
register("Word_SelectRow", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const startRow = Math.max(1, Math.floor(Number(args.st ?? 1) || 1));
const endRow = Math.max(startRow, Math.floor(Number(args.ed ?? args.start ?? startRow) || startRow));
const body = [
`$first=$doc.GoTo(3,1,${startRow}).Bookmarks("\\Line").Range`,
`$last=$doc.GoTo(3,1,${endRow}).Bookmarks("\\Line").Range`,
"$sel=$doc.Range($first.Start,$last.End);$sel.Select()|Out-Null"
].join(";");
return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body));
});
const wordUnit = (value: unknown): number => {
const unit = String(value ?? "word").toLowerCase();
return unit === "row" || unit === "line" ? 5 : unit === "section" || unit === "paragraph" ? 4 : 2;
};
register("Word_SetPosition", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const count = Math.floor(Number(args.length ?? 1) || 0);
const unit = wordUnit(args.unit);
const body = `$anchor=if(${selectionOf(args).end > selectionOf(args).start ? "$true" : "$false"}){$sel.End}else{$sel.Start};$sel=$doc.Range($anchor,$anchor);[void]$sel.Move(${unit},${count})`;
return selectionResult(filePath, await runSelection(filePath, selectionOf(args), body));
});
register("Word_MovePosition", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const count = Math.floor(Number(args.length ?? 1) || 0);
const unit = wordUnit(args.unit);
const left = String(args.direction ?? "right").toLowerCase().includes("left");
const countValue = left ? -Math.abs(count) : Math.abs(count);
const extend = bool(args.isSelect ?? args.shift ?? false);
const selection = selectionOf(args);
const anchor = left ? selection.start : selection.end;
const body = extend
? `$sel=$doc.Range(${anchor},${anchor});if(${left ? "$true" : "$false"}){[void]$sel.MoveStart(${unit},${countValue})}else{[void]$sel.MoveEnd(${unit},${countValue})}`
: `$sel=$doc.Range(${anchor},${anchor});[void]$sel.Move(${unit},${countValue})`;
return selectionResult(filePath, await runSelection(filePath, selection, body));
});
register("Word_PositionToSelect", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const needle = text(args, ["text", "find", "value"]);
if (!needle) throw new RuntimeError("validation_error", "查找文本为空", { nodeId: node.id, componentId: node.componentId });
const encodedNeedle = Buffer.from(needle, "utf8").toString("base64");
const requested = Math.floor(Number(args.pos_index ?? 1) || 1);
const mode = String(args.direction ?? "left").toLowerCase();
const body = [
`$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedNeedle}'))`,
"$search=$doc.Content;$all=@();while($true){",
" $candidate=$search.Duplicate;$found=$candidate.Find.Execute($needle)",
" if(-not $found){break}",
" $all+=$candidate.Duplicate",
" if($candidate.End -ge $search.End){break}",
" $search.SetRange($candidate.End,$search.End)",
"}",
"if($all.Count -eq 0){Write-Output '__WORD_NOT_FOUND__';exit 0}",
`$ix=${requested};if($ix -le 0){$ix=$all.Count+$ix+1}`,
"if($ix -lt 1){$ix=1};if($ix -gt $all.Count){$ix=$all.Count};$match=$all[$ix-1]",
mode === "select"
? "$sel=$match.Duplicate;$sel.Select()|Out-Null"
: `$p=$match.${mode.includes("right") ? "End" : "Start"};$sel=$doc.Range($p,$p);$sel.Select()|Out-Null`
].join(";");
return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body));
});
register("Word_ReadTable", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const byContent = String(args.find_type ?? "index").toLowerCase().includes("content");
const tableIndex = Math.max(1, Math.floor(Number(args.table_index ?? 1) || 1));
const content = String(args.table_content ?? "");
const encodedContent = Buffer.from(content, "utf8").toString("base64");
const selector = byContent
? `$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedContent}'));$tableIndex=0;for($i=1;$i -le $doc.Tables.Count;$i++){if($doc.Tables.Item($i).Range.Text.Contains($needle)){$tableIndex=$i;break}}`
: `$tableIndex=${tableIndex}`;
const body = [
selector,
"if($tableIndex -lt 1 -or $tableIndex -gt $doc.Tables.Count){Write-Output '__WORD_TABLE_NOT_FOUND__';exit 0}",
"$table=$doc.Tables.Item($tableIndex);$rows=@()",
"for($r=1;$r -le $table.Rows.Count;$r++){ $row=@(); for($c=1;$c -le $table.Columns.Count;$c++){ try { $text=$table.Cell($r,$c).Range.Text } catch { $text='' }; $clean=''; foreach($ch in $text.ToCharArray()){ $code=[int]$ch; if($code -ne 13 -and $code -ne 7){$clean += $ch} }; $row += $clean }; $rows += ,$row }",
"$json=$rows|ConvertTo-Json -Compress -Depth 5",
"[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output"
].join(";");
const output = await powershell(`$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`);
if (output.includes("__WORD_TABLE_NOT_FOUND__")) throw new RuntimeError("word_table_not_found", "Word 表格不存在", { detail: `path=${filePath};tableIndex=${tableIndex};content=${content}` });
const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8"));
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows.map((row) => {
const value = row && typeof row === "object" && Array.isArray((row as { value?: unknown }).value) ? (row as { value: unknown[] }).value : row;
return Array.isArray(value) ? value : [value];
});
});