feat: word selection cursor and table com
This commit is contained in:
parent
32269a5760
commit
23d292fdff
@ -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