Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo
This commit is contained in:
commit
9a5f86c92a
@ -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
926
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
60
src/main/capabilities/cv-vision.ts
Normal file
60
src/main/capabilities/cv-vision.ts
Normal 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 };
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
67
src/main/capabilities/message-recv.test.ts
Normal file
67
src/main/capabilities/message-recv.test.ts
Normal 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);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
54
src/main/capabilities/pdf-high.test.ts
Normal file
54
src/main/capabilities/pdf-high.test.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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];
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user