From 23d292fdff145b9f337dbf302717683083320a63 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 16:25:44 +0800 Subject: [PATCH] feat: word selection cursor and table com --- src/main/capabilities/word-extended.test.ts | 25 +++- src/main/capabilities/word-extended.ts | 140 ++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/main/capabilities/word-extended.test.ts b/src/main/capabilities/word-extended.test.ts index f3b8046..348fa9c 100644 --- a/src/main/capabilities/word-extended.test.ts +++ b/src/main/capabilities/word-extended.test.ts @@ -15,7 +15,7 @@ const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, async function run(id: string, args: Record = {}) { 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); }); diff --git a/src/main/capabilities/word-extended.ts b/src/main/capabilities/word-extended.ts index 8fb454e..6c5f1c3 100644 --- a/src/main/capabilities/word-extended.ts +++ b/src/main/capabilities/word-extended.ts @@ -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): 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 : {}; + 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 { + 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): Promise => + (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[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]; + }); +});