PRA-only-demo/packages/rpa-core/src/capabilities/word-extended.ts
yu caa8e19332 阶段1-rpa-core建包: engine+31个纯Node能力迁入packages/rpa-core, 领域类型单一源化, createEngine工厂收拢模块级状态
- engine/(registry/types/runtime/expression)物理迁入包, 解耦shared/types(域类型归包, 宿主type-only re-export)
- 31个纯Node能力+27个测试迁入包; 宿主留守Electron/浏览器/AI依赖能力, index.ts经@rpa/core/capabilities拉起
- 新增createEngine()/defaultEngine: excel缓存/Excel句柄/db连接/FTP客户端/OCR实例/拟真设置挂引擎实例
- remaining-core子流程依赖改为host-bridge注入, 包内不再import宿主flows
- 宿主main/index.ts启动时注入子流程源; electron-vite/vitest/tsconfig配@rpa/core源码别名
- 验证: typecheck绿, 快照909个id diff为零, 332测试过(2个桌面冒烟因会话无交互SetForegroundWindow被拒, 环境阻塞)
2026-11-04 12:36:03 +08:00

265 lines
16 KiB
TypeScript

import { promises as fs } from "node:fs";
import path from "node:path";
import { getHandler, register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
interface WordHandle { path: string; }
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
const current = args[key];
if (current !== undefined && current !== null && current !== "") return String(current);
}
return fallback;
}
function bool(value: unknown): boolean {
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
}
function handlePath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const raw = args.objWord ?? args.filePath ?? args.path;
const candidate = raw && typeof raw === "object" ? (raw as Partial<WordHandle>).path : raw;
const result = String(candidate || "").trim();
if (!result || result === "word对象") throw new RuntimeError("validation_error", "Word文档路径为空", { nodeId, componentId });
return path.resolve(result);
}
function script(filePath: string, body: string, save = 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)});${body};${save ? "$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()}`;
}
register("Word_New_V1", async (ctx, node, args, component) => {
const base = getHandler("Word_New");
if (!base) throw new RuntimeError("capability_not_implemented", "基础 Word 新建能力不可用", { nodeId: node.id, componentId: node.componentId });
return base(ctx, node, args, component);
});
register("Word_GetPath", (_ctx, node, args) => handlePath(args, node.id, node.componentId));
register("Word_ReadAll", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const encoded = await powershell(script(filePath, "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($doc.Content.Text))", false));
return Buffer.from(encoded.trim(), "base64").toString("utf8");
});
register("Word_Replace", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const oldText = text(args, ["oldText"]);
if (!oldText) throw new RuntimeError("validation_error", "原内容为空", { nodeId: node.id, componentId: node.componentId });
const newText = text(args, ["newText"]);
await powershell(script(filePath, `$find=$doc.Content.Find;[void]$find.Execute(${psString(oldText)},$false,$false,$false,$false,$false,$true,1,$false,${psString(newText)},2)`));
return { path: filePath } satisfies WordHandle;
});
register("Word_Enter", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertParagraphAfter()"));
return { path: filePath } satisfies WordHandle;
});
register("Word_InsertPage", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, "$range=$doc.Content;$range.Collapse(0);$range.InsertBreak(7)"));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontName", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, `$doc.Content.Font.Name=${psString(text(args, ["font"], "宋体"))}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontSize", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const size = Number(text(args, ["fontSize"], "9"));
if (!(size > 0)) throw new RuntimeError("validation_error", "字体大小无效", { nodeId: node.id, componentId: node.componentId });
await powershell(script(filePath, `$doc.Content.Font.Size=${size}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontStyle", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
await powershell(script(filePath, `$doc.Content.Font.Bold=${bool(args.isBold) ? 1 : 0};$doc.Content.Font.Italic=${bool(args.isItalic) ? 1 : 0};$doc.Content.Font.Underline=${bool(args.isUnderline) ? 1 : 0}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetFontColor", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const hex = text(args, ["fontColor"], "#000000").replace("#", "");
if (!/^[0-9a-fA-F]{6}$/.test(hex)) throw new RuntimeError("validation_error", "字体颜色格式无效", { nodeId: node.id, componentId: node.componentId });
const red = Number.parseInt(hex.slice(0, 2), 16); const green = Number.parseInt(hex.slice(2, 4), 16); const blue = Number.parseInt(hex.slice(4, 6), 16);
await powershell(script(filePath, `$doc.Content.Font.Color=${red + green * 256 + blue * 65536}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SetAlign", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const align = text(args, ["align"], "left").toLowerCase();
const value = align.includes("center") || align === "1" ? 1 : align.includes("right") || align === "2" ? 2 : align.includes("justify") || align === "3" ? 3 : 0;
await powershell(script(filePath, `$doc.Content.ParagraphFormat.Alignment=${value}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_InsertPicture", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const image = path.resolve(text(args, ["imageaddr"]));
await fs.access(image);
await powershell(script(filePath, `$range=$doc.Content;$range.Collapse(0);[void]$doc.InlineShapes.AddPicture(${psString(image)},$false,$true,$range)`));
return { path: filePath } satisfies WordHandle;
});
register("Word_WriteTable", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
let data: unknown = args.data;
if (typeof data === "string") { try { data = JSON.parse(data); } catch { data = [[data]]; } }
const rows = Array.isArray(data) ? data.map((row) => Array.isArray(row) ? row : [row]) : [[data]];
const columns = Math.max(1, ...rows.map((row) => row.length));
const encoded = Buffer.from(JSON.stringify(rows), "utf8").toString("base64");
await powershell(script(filePath, `$json=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(${psString(encoded)}));$data=$json|ConvertFrom-Json;$range=$doc.Content;$range.Collapse(0);$table=$doc.Tables.Add($range,${rows.length},${columns});for($r=0;$r-lt${rows.length};$r++){for($c=0;$c-lt${columns};$c++){if($c-lt$data[$r].Count){$table.Cell($r+1,$c+1).Range.Text=[string]$data[$r][$c]}}};$table.Borders.Enable=${bool(args.is_border ?? "1") ? 1 : 0}`));
return { path: filePath } satisfies WordHandle;
});
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];
});
});