diff --git a/packages/rpa-core/src/capabilities/word-extended.test.ts b/packages/rpa-core/src/capabilities/word-extended.test.ts index 04996cc..5056881 100644 --- a/packages/rpa-core/src/capabilities/word-extended.test.ts +++ b/packages/rpa-core/src/capabilities/word-extended.test.ts @@ -56,4 +56,27 @@ describe("Word extended capabilities", () => { 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); + + it("saves and saves-as a Word document through real COM", async () => { + if (process.platform !== "win32") return; + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-saveas-")); directories.push(directory); + const handle = await run("Word_New_V1", { filePath: directory, name: "source.docx" }); + await run("Word_Write", { objWord: handle, text: "另存前内容" }); + + await expect(run("Word_Save", { objWord: handle })).resolves.toEqual({ path: path.join(directory, "source.docx") }); + await expect(run("Word_Save", { objWord: { path: path.join(directory, "missing.docx") } })).rejects.toThrow("Word文件不存在"); + + const targetDir = path.join(directory, "out"); + const saved = await run("Word_SaveAs", { objWord: handle, fileAddr: targetDir, filename: "renamed" }) as { path: string }; + expect(saved.path).toBe(path.join(targetDir, "renamed.docx")); + await expect(readFile(saved.path)).resolves.toBeTruthy(); + + const kept = await run("Word_SaveAs", { objWord: saved, fileAddr: directory }) as { path: string }; + expect(kept.path).toBe(path.join(directory, "renamed.docx")); + await expect(readFile(kept.path)).resolves.toBeTruthy(); + + const relative = await run("Word_SaveAs", { objWord: kept, fileAddr: "sub", filename: "rel-target" }) as { path: string }; + expect(relative.path).toBe(path.join(directory, "sub", "rel-target.docx")); + await expect(readFile(relative.path)).resolves.toBeTruthy(); + }, 180000); }); diff --git a/packages/rpa-core/src/capabilities/word.ts b/packages/rpa-core/src/capabilities/word.ts index 5a5a086..b312d27 100644 --- a/packages/rpa-core/src/capabilities/word.ts +++ b/packages/rpa-core/src/capabilities/word.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { access, mkdir } from "node:fs/promises"; import { register } from "../engine/registry"; import { powershell, psString } from "../driver/windows/powershell"; @@ -41,4 +42,31 @@ register("Word_Write", async (_ctx, _node, args) => { return { path: filePath } satisfies WordHandle; }); -register(["Word_Save", "Word_SaveAs"], async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle)); +async function ensureFile(value: string): Promise { + try { + await access(value); + } catch { + throw new Error(`Word文件不存在: ${value}`); + } + return value; +} + +register("Word_Save", async (_ctx, _node, args) => { + const filePath = await ensureFile(ensure(handlePath(args), "Word文件路径")); + // 打开即存:架构上每次操作独立开合 Word 实例,Save 负责兜底刷新并证明文件真实可写 + await powershell(wordScript(`$doc = $word.Documents.Open(${psString(filePath)}); $doc.Save()`)); + return { path: filePath } satisfies WordHandle; +}); + +register("Word_SaveAs", async (_ctx, _node, args) => { + const previousPath = await ensureFile(ensure(handlePath(args), "Word文件路径")); + const rawDir = text(args, ["fileAddr", "path", "folder", "savePath"]); + const rawName = text(args, ["filename", "name"]); + // 目录空缺沿用原目录;目录相对时基于原文档目录解析;文件名空缺沿用原名(与 catalog 语义一致) + const dir = rawDir ? path.resolve(path.dirname(previousPath), rawDir) : path.dirname(previousPath); + const target = path.resolve(dir, rawName || path.basename(previousPath)); + const withExt = path.extname(target) ? target : `${target}.docx`; + await mkdir(path.dirname(withExt), { recursive: true }); + await powershell(wordScript(`$doc = $word.Documents.Open(${psString(previousPath)}); $doc.SaveAs2(${psString(withExt)}); ${psString(withExt)}`)); + return { path: withExt } satisfies WordHandle; +});