From 029473b0fbfa28b5ce8ee21cc0dbef2b14342b51 Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:37:47 +0100 Subject: [PATCH 1/9] feat: implement docx support with DOM parsing --- package-lock.json | 84 +++++-- package.json | 2 + src/handlers/filesystem-handlers.ts | 60 ++++- src/server.ts | 87 ++++++- src/tools/docx/index.ts | 10 + src/tools/docx/read.ts | 231 +++++++++++++++++ src/tools/docx/types.ts | 77 ++++++ src/tools/docx/utils.ts | 197 +++++++++++++++ src/tools/docx/write.ts | 370 ++++++++++++++++++++++++++++ src/tools/filesystem.ts | 97 +++++++- src/tools/schemas.ts | 62 +++++ src/utils/files/base.ts | 20 +- src/utils/files/docx.ts | 162 ++++++++++++ src/utils/files/factory.ts | 21 +- 14 files changed, 1439 insertions(+), 41 deletions(-) create mode 100644 src/tools/docx/index.ts create mode 100644 src/tools/docx/read.ts create mode 100644 src/tools/docx/types.ts create mode 100644 src/tools/docx/utils.ts create mode 100644 src/tools/docx/write.ts create mode 100644 src/utils/files/docx.ts diff --git a/package-lock.json b/package-lock.json index 328fe9e6..e57a1de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wonderwhy-er/desktop-commander", - "version": "0.2.32", + "version": "0.2.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wonderwhy-er/desktop-commander", - "version": "0.2.32", + "version": "0.2.34", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -14,6 +14,7 @@ "@opendocsg/pdf2md": "^0.2.2", "@supabase/supabase-js": "^2.89.0", "@vscode/ripgrep": "^1.15.9", + "@xmldom/xmldom": "^0.8.11", "cross-fetch": "^4.1.0", "exceljs": "^4.4.0", "fastest-levenshtein": "^1.0.16", @@ -23,6 +24,7 @@ "md-to-pdf": "^5.2.5", "open": "^10.2.0", "pdf-lib": "^1.17.1", + "pizzip": "^3.2.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", @@ -2206,7 +2208,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2530,6 +2531,15 @@ } } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2587,6 +2597,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -2601,7 +2612,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2649,7 +2659,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2936,7 +2945,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-union": { "version": "2.1.0", @@ -3198,6 +3208,7 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -3222,6 +3233,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -3231,6 +3243,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3242,13 +3255,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -3300,7 +3315,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4013,7 +4027,8 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/core-util-is": { "version": "1.0.3", @@ -4591,6 +4606,7 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -4622,8 +4638,7 @@ "version": "0.0.1534754", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "4.0.2", @@ -5310,6 +5325,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5371,6 +5387,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5379,7 +5396,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ext-list": { "version": "2.2.2", @@ -5668,6 +5686,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -5686,6 +5705,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -5694,7 +5714,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/find-up": { "version": "4.1.0", @@ -5790,6 +5811,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -7232,7 +7254,6 @@ "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", "license": "MIT", - "peer": true, "dependencies": { "@samverschueren/stream-to-observable": "^0.3.0", "is-observable": "^1.1.0", @@ -8019,6 +8040,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8028,6 +8050,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -8064,6 +8087,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8650,6 +8674,7 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", + "peer": true, "bin": { "mime": "cli.js" }, @@ -8841,6 +8866,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9623,7 +9649,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "4.0.0", @@ -9710,6 +9737,21 @@ "node": ">=0.10.0" } }, + "node_modules/pizzip": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/pizzip/-/pizzip-3.2.0.tgz", + "integrity": "sha512-X4NPNICxCfIK8VYhF6wbksn81vTiziyLbvKuORVAmolvnUzl1A1xmz9DAWKxPRq9lZg84pJOOAMq3OE61bD8IQ==", + "license": "(MIT OR GPL-3.0)", + "dependencies": { + "pako": "^2.1.0" + } + }, + "node_modules/pizzip/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -10552,6 +10594,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -10576,6 +10619,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -10584,7 +10628,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -10692,6 +10737,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -11862,6 +11908,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", + "peer": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -11897,7 +11944,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12174,6 +12220,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -12273,7 +12320,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12323,7 +12369,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -12836,7 +12881,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9d9d04ee..d1e999d2 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@opendocsg/pdf2md": "^0.2.2", "@supabase/supabase-js": "^2.89.0", "@vscode/ripgrep": "^1.15.9", + "@xmldom/xmldom": "^0.8.11", "cross-fetch": "^4.1.0", "exceljs": "^4.4.0", "fastest-levenshtein": "^1.0.16", @@ -94,6 +95,7 @@ "md-to-pdf": "^5.2.5", "open": "^10.2.0", "pdf-lib": "^1.17.1", + "pizzip": "^3.2.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", diff --git a/src/handlers/filesystem-handlers.ts b/src/handlers/filesystem-handlers.ts index 03412a7d..cb697633 100644 --- a/src/handlers/filesystem-handlers.ts +++ b/src/handlers/filesystem-handlers.ts @@ -25,7 +25,8 @@ import { ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, - WritePdfArgsSchema + WritePdfArgsSchema, + WriteDocxArgsSchema } from '../tools/schemas.js'; /** @@ -106,6 +107,30 @@ export async function handleReadFile(args: unknown): Promise { }; } + // Handle DOCX files + if (fileResult.metadata?.isDocx) { + const meta = fileResult.metadata; + const author = meta?.author ? `, Author: ${meta?.author}` : ""; + const title = meta?.title ? `, Title: ${meta?.title}` : ""; + const paragraphInfo = meta?.paragraphCount ? ` (${meta.paragraphCount} paragraphs, ${meta.wordCount || 0} words)` : ""; + + // Body XML is returned as content - include instructions for modification + const extractedText = meta?.extractedText + ? (meta.extractedText.length > 2000 + ? `\n\n--- Extracted Text (first 2000 chars, for reference) ---\n${meta.extractedText.substring(0, 2000)}...\n\n--- End Extracted Text ---\n\n` + : `\n\n--- Extracted Text (for reference) ---\n${meta.extractedText}\n\n--- End Extracted Text ---\n\n`) + : ''; + + return { + content: [ + { + type: "text", + text: `DOCX file: ${parsed.path}${author}${title}${paragraphInfo}\n\n--- Body XML (modify this and use write_docx to save) ---\n${fileResult.content}${extractedText}--- End Body XML ---\n\nTo modify this DOCX:\n1. Edit the body XML above based on user's query\n2. Use write_docx tool with the modified body XML\n3. All styles and formatting will be preserved` + } + ] + }; + } + // Handle image files if (fileResult.metadata?.isImage) { // For image files, return as an image content type @@ -162,8 +187,12 @@ export async function handleReadMultipleFiles(args: unknown): Promise { if (result.error) { return `${result.path}: Error - ${result.error}`; - } else if (result.isPdf) { - return `${result.path}: PDF file with ${result.payload?.pages?.length} pages`; + } else if (result.isPdf && result.payload) { + const pdfPayload = result.payload as { pages: Array }; + return `${result.path}: PDF file with ${pdfPayload.pages?.length || 0} pages`; + } else if (result.isDocx) { + const docxPayload = result.payload as { paragraphCount?: number; wordCount?: number } | undefined; + return `${result.path}: DOCX file with ${docxPayload?.paragraphCount || 0} paragraphs`; } else if (result.mimeType) { return `${result.path}: ${result.mimeType} ${result.isImage ? '(image)' : '(text)'}`; } else { @@ -180,9 +209,10 @@ export async function handleReadMultipleFiles(args: unknown): Promise { - page.images.forEach((image, i) => { + if (result.isPdf && result.payload) { + const pdfPayload = result.payload as { pages: Array<{ text: string; images: Array<{ data: string; mimeType: string }> }> }; + pdfPayload.pages.forEach((page) => { + page.images.forEach((image) => { contentItems.push({ type: "image", data: image.data, @@ -376,3 +406,21 @@ export async function handleWritePdf(args: unknown): Promise { return createErrorResponse(errorMessage); } } + +/** + * Handle write_docx command + */ +export async function handleWriteDocx(args: unknown): Promise { + try { + const parsed = WriteDocxArgsSchema.parse(args); + const { writeDocx } = await import('../tools/filesystem.js'); + await writeDocx(parsed.path, parsed.content, parsed.outputPath); + const targetPath = parsed.outputPath || parsed.path; + return { + content: [{ type: "text", text: `Successfully wrote DOCX to ${targetPath}${parsed.outputPath ? `\nOriginal file: ${parsed.path}` : ''}` }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return createErrorResponse(errorMessage); + } +} diff --git a/src/server.ts b/src/server.ts index 7b92d531..1ffabd22 100644 --- a/src/server.ts +++ b/src/server.ts @@ -49,6 +49,7 @@ import { GetPromptsArgsSchema, GetRecentToolCallsArgsSchema, WritePdfArgsSchema, + WriteDocxArgsSchema, } from './tools/schemas.js'; import { getConfig, setConfigValue } from './tools/config.js'; import { getUsageStats } from './tools/usage.js'; @@ -301,6 +302,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { - PDF: Extracts text content as markdown with page structure * offset/length work as page pagination (0-based) * Includes embedded images when available + - DOCX (.docx): Extracts text content with paragraph structure + * offset/length work as paragraph pagination (0-based) + * Preserves document metadata (title, author, word count) ${PATH_GUIDANCE} ${CMD_PREFIX_DESCRIPTION}`, @@ -336,7 +340,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: ` Write or append to file contents. - IMPORTANT: DO NOT use this tool to create PDF files. Use 'write_pdf' for all PDF creation tasks. + IMPORTANT: DO NOT use this tool to create PDF or DOCX files. + - Use 'write_pdf' for all PDF creation and modification tasks. + - Use 'write_docx' for all DOCX creation and modification tasks. CHUNKING IS STANDARD PRACTICE: Always write files in chunks of 25-30 lines maximum. This is the normal, recommended way to write files - not an emergency measure. @@ -439,6 +445,81 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { openWorldHint: false, }, }, + { + name: "write_docx", + description: ` + Create a new DOCX file or modify an existing one. + + THIS IS THE ONLY TOOL FOR CREATING AND MODIFYING DOCX FILES. + + RULES ABOUT FILENAMES: + - When creating a new DOCX, 'outputPath' MUST be provided and MUST use a new unique filename (e.g., "result_01.docx", "report_2025_01.docx", etc.). + - When modifying an existing DOCX, 'outputPath' should be provided to avoid overwriting the original. + + MODES: + 1. REPLACE BODY XML (RECOMMENDED - preserves all styles): + - Pass body XML string (from read_file) as 'content'. + - LLM modifies the body XML based on user query. + - All styles and formatting are preserved. + - Source file must exist (use same path as read_file). + + Example workflow: + 1. read_file(path="document.docx") → returns body XML + 2. Modify the body XML based on user request + 3. write_docx(path="document.docx", content="...", outputPath="updated_document.docx") + + write_docx(path="doc.docx", content="...", outputPath="new_doc.docx") + + 2. CREATE NEW DOCX FROM TEXT: + - Pass plain text string as 'content' (not starting with { + const inputBuf = await fs.readFile(path); + return new PizZip(inputBuf); +} + +/** + * Extract text content from DOCX document + */ +export async function extractTextFromDocx(path: string): Promise { + const zip = await loadDocx(path); + const docFile = zip.file('word/document.xml'); + + if (!docFile) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + + const xmlStr = docFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + const body = dom.getElementsByTagName('w:body').item(0); + + if (!body) { + throw new Error('Invalid DOCX: missing w:body'); + } + + const paragraphs: string[] = []; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; // element nodes only + if ((child as Element).nodeName !== 'w:p') continue; + + const text = getTextFromParagraph(child as Element).trim(); + if (text) { + paragraphs.push(text); + } + } + + return paragraphs.join('\n\n'); +} + +/** + * Extract paragraphs from DOCX document + */ +export async function extractParagraphs(path: string): Promise { + const zip = await loadDocx(path); + const docFile = zip.file('word/document.xml'); + + if (!docFile) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + + const xmlStr = docFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + const body = dom.getElementsByTagName('w:body').item(0); + + if (!body) { + throw new Error('Invalid DOCX: missing w:body'); + } + + const paragraphs: DocxParagraph[] = []; + let index = 0; + + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; // element nodes only + if ((child as Element).nodeName !== 'w:p') continue; + + const text = getTextFromParagraph(child as Element).trim(); + paragraphs.push({ + index, + text, + hasText: text.length > 0 + }); + index++; + } + + return paragraphs; +} + +/** + * Get DOCX metadata from core properties + */ +async function getCoreProperties(zip: PizZip): Promise> { + const corePropsFile = zip.file('docProps/core.xml'); + if (!corePropsFile) { + return {}; + } + + const xmlStr = corePropsFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + + const getProperty = (name: string): string | undefined => { + const elements = dom.getElementsByTagName(name); + if (elements.length > 0) { + return elements[0].textContent || undefined; + } + return undefined; + }; + + return { + title: getProperty('dc:title'), + author: getProperty('dc:creator'), + subject: getProperty('dc:subject'), + creator: getProperty('cp:creator') + }; +} + +/** + * Get comprehensive DOCX metadata + */ +export async function getDocxMetadata(path: string): Promise { + const zip = await loadDocx(path); + const paragraphs = await extractParagraphs(path); + + const coreProps = await getCoreProperties(zip); + + // Calculate word count (approximate) + const fullText = paragraphs.map(p => p.text).join(' '); + const wordCount = fullText.split(/\s+/).filter(w => w.length > 0).length; + + return { + ...coreProps, + paragraphCount: paragraphs.length, + wordCount + }; +} + +/** + * Extract body XML from DOCX file + * Returns the body element as XML string for LLM modification + */ +export async function extractBodyXml(path: string): Promise { + const inputBuf = await fs.readFile(path); + const zip = new PizZip(inputBuf); + + const docFile = zip.file('word/document.xml'); + if (!docFile) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + + const xmlStr = docFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + + // Locate body + const body = dom.getElementsByTagName('w:body').item(0); + if (!body) { + throw new Error('Invalid DOCX: missing w:body'); + } + + // Serialize body element to XML string + const serializer = new XMLSerializer(); + return serializer.serializeToString(body); +} + +/** + * Read DOCX file and return structured content + */ +export async function readDocx( + path: string, + options?: { offset?: number; length?: number } +): Promise<{ + text: string; + paragraphs: DocxParagraph[]; + metadata: DocxMetadata; + bodyXml: string; +}> { + const zip = await loadDocx(path); + const docFile = zip.file('word/document.xml'); + + if (!docFile) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + + const xmlStr = docFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + const body = dom.getElementsByTagName('w:body').item(0); + + if (!body) { + throw new Error('Invalid DOCX: missing w:body'); + } + + const allParagraphs: DocxParagraph[] = []; + let index = 0; + + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; + if ((child as Element).nodeName !== 'w:p') continue; + + const text = getTextFromParagraph(child as Element).trim(); + allParagraphs.push({ + index, + text, + hasText: text.length > 0 + }); + index++; + } + + // Apply pagination if requested + let paragraphs = allParagraphs; + if (options?.offset !== undefined || options?.length !== undefined) { + const offset = options.offset || 0; + const length = options.length !== undefined ? options.length : allParagraphs.length; + paragraphs = allParagraphs.slice(offset, offset + length); + } + + const metadata = await getDocxMetadata(path); + const text = paragraphs.map(p => p.text).join('\n\n'); + + // Extract body XML for LLM modification + const serializer = new XMLSerializer(); + const bodyXml = serializer.serializeToString(body); + + return { + text, + paragraphs, + metadata, + bodyXml + }; +} + diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts new file mode 100644 index 00000000..67ecd6ce --- /dev/null +++ b/src/tools/docx/types.ts @@ -0,0 +1,77 @@ +/** + * Type definitions for DOCX operations + */ + +/** + * DOCX metadata extracted from document + */ +export interface DocxMetadata { + /** Document title (from core properties) */ + title?: string; + /** Document author (from core properties) */ + author?: string; + /** Document subject (from core properties) */ + subject?: string; + /** Document creator (from core properties) */ + creator?: string; + /** Total number of paragraphs in document */ + paragraphCount: number; + /** Total word count (approximate) */ + wordCount: number; +} + +/** + * Represents a paragraph in a DOCX document + */ +export interface DocxParagraph { + /** Paragraph index (0-based) */ + index: number; + /** Plain text content of paragraph */ + text: string; + /** Whether paragraph contains text */ + hasText: boolean; +} + +/** + * Represents a text run (formatted text segment) in a DOCX document + */ +export interface DocxRun { + /** Text content of the run */ + text: string; + /** Whether run has bold formatting */ + bold?: boolean; + /** Whether run has italic formatting */ + italic?: boolean; + /** Font color (hex format, e.g., "FF0000") */ + color?: string; + /** Font size (in half-points) */ + fontSize?: number; + /** Font name */ + fontName?: string; +} + +/** + * Modification operation for DOCX content + */ +export interface DocxModification { + /** Type of modification */ + type: 'replace' | 'insert' | 'delete' | 'style'; + /** Target paragraph index (0-based) */ + paragraphIndex?: number; + /** Text to find (for replace operations) */ + findText?: string; + /** Text to replace with */ + replaceText?: string; + /** Text to insert */ + insertText?: string; + /** Style options for text */ + style?: { + /** Font color (hex format) */ + color?: string; + /** Bold formatting */ + bold?: boolean; + /** Italic formatting */ + italic?: boolean; + }; +} + diff --git a/src/tools/docx/utils.ts b/src/tools/docx/utils.ts new file mode 100644 index 00000000..9cb04427 --- /dev/null +++ b/src/tools/docx/utils.ts @@ -0,0 +1,197 @@ +/** + * Utility functions for DOCX manipulation + */ + +/** + * Convert NodeList or HTMLCollection to array + */ +export function nodeListToArray(nl: NodeListOf | NodeList | HTMLCollectionOf | { length: number; item(index: number): T | null }): T[] { + const arr: T[] = []; + for (let i = 0; i < nl.length; i++) { + const item = nl.item(i); + if (item) { + arr.push(item as T); + } + } + return arr; +} + +/** + * Get all text from a paragraph by concatenating w:t nodes in document order + * Follows exact same pattern as reference implementation + */ +export function getTextFromParagraph(pNode: Element): string { + // Concatenate w:t text in document order + const tNodes = pNode.getElementsByTagName('w:t'); + let out = ''; + for (const t of nodeListToArray(tNodes)) { + out += t.textContent || ''; + } + return out; +} + +/** + * Set paragraph text with minimal changes (preserves formatting structure) + * Replaces text in the first w:t; clear remaining w:t nodes (minimal change) + * Follows exact same pattern as reference implementation + */ +export function setParagraphTextMinimal(pNode: Element, newText: string): void { + // Replace text in the first w:t; clear remaining w:t nodes (minimal change) + const tNodes = nodeListToArray(pNode.getElementsByTagName('w:t')); + if (tNodes.length === 0) return; + + tNodes[0].textContent = newText; + for (let i = 1; i < tNodes.length; i++) { + tNodes[i].textContent = ''; + } +} + +/** + * Ensure a run node has color formatting + * Follows exact same pattern as reference implementation + * Preserves all existing w:rPr children, only modifies/adds w:color + */ +export function ensureRunColorRed(runNode: Element, color: string = 'FF0000'): void { + const doc = runNode.ownerDocument; + if (!doc) return; + + // Find or create w:rPr under this w:r + let rPr: Element | null = null; + for (const child of nodeListToArray(runNode.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:rPr') { + rPr = child as Element; + break; + } + } + + if (!rPr) { + rPr = doc.createElement('w:rPr'); + // Insert rPr as the first element child of w:r (standard structure) + if (runNode.firstChild) { + runNode.insertBefore(rPr, runNode.firstChild); + } else { + runNode.appendChild(rPr); + } + } + + // Find existing w:color + let colorNode: Element | null = null; + for (const child of nodeListToArray(rPr.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:color') { + colorNode = child as Element; + break; + } + } + + if (!colorNode) { + colorNode = doc.createElement('w:color'); + rPr.appendChild(colorNode); + } + + // Set w:val attribute (namespace handling: xmldom will keep prefix) + colorNode.setAttribute('w:val', color); +} + +/** + * Apply color formatting to all runs in a paragraph + * Follows exact same pattern as reference implementation + */ +export function colorParagraphRuns(pNode: Element, color: string = 'FF0000'): void { + // For each w:r in the paragraph, add/set w:color to specified color + const rNodes = nodeListToArray(pNode.getElementsByTagName('w:r')); + for (const r of rNodes) { + ensureRunColorRed(r as Element, color); + } +} + +/** + * Apply style to all runs in a paragraph + * Follows exact same pattern as reference implementation + * Preserves all existing w:rPr children, only modifies/adds specified style properties + */ +export function styleParagraphRuns( + pNode: Element, + style: { color?: string; bold?: boolean; italic?: boolean } +): void { + const doc = pNode.ownerDocument; + if (!doc) return; + + // For each w:r in the paragraph, apply styles + const rNodes = nodeListToArray(pNode.getElementsByTagName('w:r')); + for (const r of rNodes) { + // Find or create w:rPr under this w:r (same pattern as ensureRunColorRed) + let rPr: Element | null = null; + for (const child of nodeListToArray(r.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:rPr') { + rPr = child as Element; + break; + } + } + + if (!rPr) { + rPr = doc.createElement('w:rPr'); + // Insert rPr as the first element child of w:r (standard structure) + if (r.firstChild) { + r.insertBefore(rPr, r.firstChild); + } else { + r.appendChild(rPr); + } + } + + // Apply color (same pattern as ensureRunColorRed) + if (style.color) { + let colorNode: Element | null = null; + for (const child of nodeListToArray(rPr.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:color') { + colorNode = child as Element; + break; + } + } + if (!colorNode) { + colorNode = doc.createElement('w:color'); + rPr.appendChild(colorNode); + } + // Set w:val attribute (namespace handling: xmldom will keep prefix) + colorNode.setAttribute('w:val', style.color); + } + + // Apply bold + if (style.bold !== undefined) { + let boldNode: Element | null = null; + for (const child of nodeListToArray(rPr.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:b') { + boldNode = child as Element; + break; + } + } + if (style.bold) { + if (!boldNode) { + boldNode = doc.createElement('w:b'); + rPr.appendChild(boldNode); + } + } else if (boldNode) { + rPr.removeChild(boldNode); + } + } + + // Apply italic + if (style.italic !== undefined) { + let italicNode: Element | null = null; + for (const child of nodeListToArray(rPr.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:i') { + italicNode = child as Element; + break; + } + } + if (style.italic) { + if (!italicNode) { + italicNode = doc.createElement('w:i'); + rPr.appendChild(italicNode); + } + } else if (italicNode) { + rPr.removeChild(italicNode); + } + } + } +} + diff --git a/src/tools/docx/write.ts b/src/tools/docx/write.ts new file mode 100644 index 00000000..4063daa5 --- /dev/null +++ b/src/tools/docx/write.ts @@ -0,0 +1,370 @@ +/** + * DOCX writing and modification utilities + * Modifies DOCX files while preserving formatting and styles + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import type { DocxModification } from './types.js'; +import { nodeListToArray, getTextFromParagraph, setParagraphTextMinimal, styleParagraphRuns, colorParagraphRuns } from './utils.js'; + +/** + * Load DOCX file and return ZIP archive + */ +async function loadDocx(path: string): Promise { + const inputBuf = await fs.readFile(path); + return new PizZip(inputBuf); +} + +/** + * Get all paragraph elements from body in document order + */ +function getParagraphs(body: Element): Element[] { + const paragraphs: Element[] = []; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:p') { + paragraphs.push(child as Element); + } + } + return paragraphs; +} + +/** + * Modify DOCX content with specified operations + * Preserves all other files and components in the DOCX + */ +export async function modifyDocxContent( + inputPath: string, + outputPath: string, + modifications: DocxModification[] +): Promise { + const inputBuf = await fs.readFile(inputPath); + const zip = new PizZip(inputBuf); + + // Preserve ALL files in the ZIP - we'll only modify word/document.xml + const docFile = zip.file('word/document.xml'); + if (!docFile) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + + const xmlStr = docFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + + // Locate body + const body = dom.getElementsByTagName('w:body').item(0); + if (!body) { + throw new Error('Invalid DOCX: missing w:body'); + } + + // Apply modifications in order + // Follow the reference pattern: iterate DIRECT children of body in order + for (const mod of modifications) { + switch (mod.type) { + case 'replace': { + // Iterate DIRECT children of body in order; only touch the matching paragraph + // Follows exact same pattern as reference implementation + if (mod.findText !== undefined) { + const findTextTrimmed = mod.findText.trim(); + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; // element nodes only + if ((child as Element).nodeName !== 'w:p') continue; + + const txt = getTextFromParagraph(child as Element).trim(); + if (txt === findTextTrimmed) { + // Only modify text if replaceText is provided + if (mod.replaceText !== undefined) { + setParagraphTextMinimal(child as Element, mod.replaceText); + } + // Apply style if provided (preserves existing styles, only adds/modifies specified) + if (mod.style) { + if (mod.style.color) { + colorParagraphRuns(child as Element, mod.style.color); + } + if (mod.style.bold !== undefined || mod.style.italic !== undefined) { + styleParagraphRuns(child as Element, mod.style); + } + } + break; // Only replace first match + } + } + } + break; + } + + case 'insert': { + if (mod.paragraphIndex !== undefined && mod.insertText !== undefined) { + const doc = body.ownerDocument; + if (!doc) break; + + // Create new paragraph with minimal structure + const newP = doc.createElement('w:p'); + const newR = doc.createElement('w:r'); + const newT = doc.createElement('w:t'); + newT.textContent = mod.insertText; + newR.appendChild(newT); + newP.appendChild(newR); + + // Re-collect paragraphs to get current count + const paragraphs = getParagraphs(body); + const insertIndex = mod.paragraphIndex < 0 + ? paragraphs.length + mod.paragraphIndex + 1 + : mod.paragraphIndex; + + // Insert at correct position by iterating DIRECT children + if (insertIndex >= 0 && insertIndex <= paragraphs.length) { + if (insertIndex === paragraphs.length) { + // Append to end + body.appendChild(newP); + } else if (insertIndex < paragraphs.length) { + // Find the paragraph at insertIndex by iterating DIRECT children + let currentIndex = 0; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; + if ((child as Element).nodeName !== 'w:p') continue; + + if (currentIndex === insertIndex) { + body.insertBefore(newP, child); + break; + } + currentIndex++; + } + } + } + } + break; + } + + case 'delete': { + if (mod.paragraphIndex !== undefined) { + // Re-collect paragraphs to get current count + const paragraphs = getParagraphs(body); + const deleteIndex = mod.paragraphIndex < 0 + ? paragraphs.length + mod.paragraphIndex + : mod.paragraphIndex; + + // Find and delete by iterating DIRECT children + if (deleteIndex >= 0 && deleteIndex < paragraphs.length) { + let currentIndex = 0; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; + if ((child as Element).nodeName !== 'w:p') continue; + + if (currentIndex === deleteIndex) { + body.removeChild(child); + break; + } + currentIndex++; + } + } + } + break; + } + + case 'style': { + if (mod.paragraphIndex !== undefined && mod.style) { + // Re-collect paragraphs to get current count + const paragraphs = getParagraphs(body); + const styleIndex = mod.paragraphIndex < 0 + ? paragraphs.length + mod.paragraphIndex + : mod.paragraphIndex; + + // Find paragraph by iterating DIRECT children + if (styleIndex >= 0 && styleIndex < paragraphs.length) { + let currentIndex = 0; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1) continue; + if ((child as Element).nodeName !== 'w:p') continue; + + if (currentIndex === styleIndex) { + // Apply style to existing paragraph (preserves other styles) + if (mod.style.color) { + colorParagraphRuns(child as Element, mod.style.color); + } + if (mod.style.bold !== undefined || mod.style.italic !== undefined) { + styleParagraphRuns(child as Element, mod.style); + } + break; + } + currentIndex++; + } + } + } + break; + } + } + } + + // Serialize the modified document.xml + const outXml = new XMLSerializer().serializeToString(dom); + + // Update ONLY word/document.xml in the ZIP + // All other files (styles, images, relationships, etc.) are preserved automatically + zip.file('word/document.xml', outXml); + + // Generate the output ZIP with all original files preserved + // Use same generation method as reference implementation + const outBuf = zip.generate({ type: 'nodebuffer' }); + + await fs.writeFile(outputPath, outBuf); +} + +/** + * Replace body XML in existing DOCX file + * This function copies the DOCX to a temp location, extracts document.xml to temp, + * updates the body, generates the DOCX, and cleans up + * This approach avoids "max compaction per block" errors + */ +export async function replaceBodyXml( + inputPath: string, + outputPath: string, + newBodyXml: string +): Promise { + // Create temp file paths + const tempDir = os.tmpdir(); + const tempDocxName = `docx_temp_${Date.now()}_${Math.random().toString(36).substring(7)}.docx`; + const tempDocxPath = path.join(tempDir, tempDocxName); + const tempXmlName = `docx_dom_${Date.now()}_${Math.random().toString(36).substring(7)}.xml`; + const tempXmlPath = path.join(tempDir, tempXmlName); + + try { + // Step 1: Copy DOCX to temp location + const inputBuf = await fs.readFile(inputPath); + await fs.writeFile(tempDocxPath, inputBuf); + + // Step 2: Load temp DOCX + const zip = new PizZip(inputBuf); + + // Step 3: Extract document.xml to temp file + const docFile = zip.file('word/document.xml'); + if (!docFile) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + + const xmlStr = docFile.asText(); + await fs.writeFile(tempXmlPath, xmlStr); + + // Step 4: Parse the temp XML file + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + + // Step 5: Locate body and replace with new body XML + const body = dom.getElementsByTagName('w:body').item(0); + if (!body) { + throw new Error('Invalid DOCX: missing w:body'); + } + + // Parse the new body XML + const newBodyDom = new DOMParser().parseFromString( + `${newBodyXml}`, + 'application/xml' + ); + const newBodyElement = newBodyDom.documentElement.firstChild as Element; + + if (!newBodyElement || newBodyElement.nodeName !== 'w:body') { + throw new Error('Invalid body XML: must start with '); + } + + // Replace the body: clear existing children and append new ones + // We need to import the new body's children into the original document + const doc = body.ownerDocument; + if (!doc) { + throw new Error('Document owner not found'); + } + + // Remove all existing children from body + while (body.firstChild) { + body.removeChild(body.firstChild); + } + + // Import and append all children from new body + for (const child of nodeListToArray(newBodyElement.childNodes)) { + const importedChild = doc.importNode(child, true); + body.appendChild(importedChild); + } + + // Step 6: Serialize the updated DOM + const outXml = new XMLSerializer().serializeToString(dom); + + // Step 7: Update document.xml in ZIP and generate output + zip.file('word/document.xml', outXml); + const outBuf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, outBuf); + } finally { + // Step 8: Clean up temp files + try { + await fs.unlink(tempDocxPath); + } catch { + // Ignore errors when cleaning up temp DOCX file + } + try { + await fs.unlink(tempXmlPath); + } catch { + // Ignore errors when cleaning up temp XML file + } + } +} + +/** + * Create a new DOCX file from text content + * Note: This creates a minimal DOCX structure + */ +export async function writeDocx( + outputPath: string, + content: string | DocxModification[] +): Promise { + if (typeof content === 'string') { + // Create minimal DOCX from text + const zip = new PizZip(); + + // Create minimal document.xml + const docXml = ` + + + ${content.split('\n\n').map(para => { + const escaped = para + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return ` + + ${escaped} + + `; + }).join('\n')} + +`; + + zip.file('word/document.xml', docXml); + + // Create minimal [Content_Types].xml + const contentTypes = ` + + + + +`; + zip.file('[Content_Types].xml', contentTypes); + + // Create minimal _rels/.rels + const rels = ` + + +`; + zip.folder('_rels')?.file('.rels', rels); + + // Create minimal word/_rels/document.xml.rels + zip.folder('word')?.folder('_rels')?.file('document.xml.rels', ` + +`); + + const outBuf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, outBuf); + } else { + // Modifications require an existing file + throw new Error('Modifications require an existing DOCX file. Use modifyDocxContent() instead.'); + } +} diff --git a/src/tools/filesystem.ts b/src/tools/filesystem.ts index 78c2b2de..a14403cf 100644 --- a/src/tools/filesystem.ts +++ b/src/tools/filesystem.ts @@ -9,6 +9,7 @@ import { getFileHandler, TextFileHandler } from '../utils/files/index.js'; import type { ReadOptions, FileResult, PdfPageItem } from '../utils/files/base.js'; import { isPdfFile } from "./mime-types.js"; import { parsePdfToMarkdown, editPdf, PdfOperations, PdfMetadata, parseMarkdownToPdf } from './pdf/index.js'; +import type { DocxModification } from './docx/types.js'; import { isBinaryFile } from 'isbinaryfile'; // CONSTANTS SECTION - Consolidate all timeouts and thresholds @@ -266,7 +267,12 @@ type PdfPayload = { pages: PdfPageItem[]; } -type FileResultPayloads = PdfPayload; +type DocxPayload = { + paragraphCount: number; + wordCount: number; +} + +type FileResultPayloads = PdfPayload | DocxPayload; /** * Read file content from a URL @@ -533,6 +539,7 @@ export interface MultiFileResult { isImage?: boolean; error?: string; isPdf?: boolean; + isDocx?: boolean; payload?: FileResultPayloads; } @@ -552,20 +559,34 @@ export async function readMultipleFiles(paths: string[]): Promise { + const validPath = await validatePath(filePath); + const { writeDocx: writeDocxImpl, modifyDocxContent, replaceBodyXml } = await import('./docx/index.js'); + + if (typeof content === 'string') { + // Check if content is body XML (starts with { + // If it's a string that looks like JSON array, parse it + if (typeof val === 'string' && val.trim().startsWith('[')) { + try { + return JSON.parse(val); + } catch { + // If parsing fails, return as-is (might be text content) + return val; + } + } + // Otherwise return as-is + return val; + }, + z.union([z.string(), z.array(DocxOperationSchema)]) + ), + outputPath: z.string().optional(), +}); + export const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); diff --git a/src/utils/files/base.ts b/src/utils/files/base.ts index 775acdb6..796625ce 100644 --- a/src/utils/files/base.ts +++ b/src/utils/files/base.ts @@ -130,6 +130,15 @@ export interface FileMetadata { totalPages?: number; pages?: PdfPageItem[]; + /** For DOCX files */ + isDocx?: boolean; + subject?: string; + creator?: string; + paragraphCount?: number; + wordCount?: number; + paragraphs?: DocxParagraphItem[]; + extractedText?: string; + /** Error information if operation failed */ error?: boolean; errorMessage?: string; @@ -147,6 +156,15 @@ export interface PdfPageItem { }>; } +/** + * DOCX paragraph content item + */ +export interface DocxParagraphItem { + index: number; + text: string; + hasText: boolean; +} + /** * Excel sheet metadata */ @@ -212,7 +230,7 @@ export interface FileInfo { permissions: string; /** File type classification */ - fileType: 'text' | 'excel' | 'image' | 'binary'; + fileType: 'text' | 'excel' | 'image' | 'binary' | 'docx'; /** Type-specific metadata */ metadata?: FileMetadata; diff --git a/src/utils/files/docx.ts b/src/utils/files/docx.ts new file mode 100644 index 00000000..e876d66e --- /dev/null +++ b/src/utils/files/docx.ts @@ -0,0 +1,162 @@ +/** + * DOCX File Handler + * Implements FileHandler interface for DOCX documents + * Handles reading, writing, and modifying DOCX files while preserving formatting + */ + +import fs from 'fs/promises'; +import { FileHandler, FileResult, FileInfo, ReadOptions, EditResult } from './base.js'; +import { readDocx, getDocxMetadata, modifyDocxContent, writeDocx } from '../../tools/docx/index.js'; +import type { DocxModification } from '../../tools/docx/types.js'; + +/** + * File handler for DOCX documents + * Extracts text and metadata, supports paragraph-based pagination + */ +export class DocxFileHandler implements FileHandler { + private readonly extensions = ['.docx']; + + /** + * Check if this handler can handle the given file + */ + canHandle(path: string): boolean { + const ext = path.toLowerCase(); + return this.extensions.some(e => ext.endsWith(e)); + } + + /** + * Read DOCX content - returns body XML for LLM modification + */ + async read(path: string, options?: ReadOptions): Promise { + const { offset = 0, length } = options ?? {}; + + try { + const result = await readDocx(path, { + offset, + length + }); + + // Return body XML as content - LLMs can modify this and write it back + return { + content: result.bodyXml, + mimeType: 'application/xml', + metadata: { + isDocx: true, + author: result.metadata.author, + title: result.metadata.title, + subject: result.metadata.subject, + creator: result.metadata.creator, + paragraphCount: result.metadata.paragraphCount, + wordCount: result.metadata.wordCount, + paragraphs: result.paragraphs, + // Include extracted text for reference + extractedText: result.text + } + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: `Error reading DOCX: ${errorMessage}`, + mimeType: 'text/plain', + metadata: { + error: true, + errorMessage + } + }; + } + } + + /** + * Write DOCX - NOT SUPPORTED via write_file + * Use write_docx tool instead to preserve styles + */ + async write(path: string, content: any, mode?: 'rewrite' | 'append'): Promise { + throw new Error( + 'DOCX files cannot be written using write_file tool. ' + + 'Use write_docx tool instead to create or modify DOCX files while preserving styles and formatting.' + ); + } + + /** + * Edit DOCX by applying modifications + */ + async editRange( + path: string, + range: string, + content: any, + options?: Record + ): Promise { + try { + // Parse content as modifications + let modifications: DocxModification[] = []; + + if (Array.isArray(content)) { + modifications = content; + } else if (typeof content === 'string') { + // Try to parse as JSON + try { + modifications = JSON.parse(content); + } catch { + // If not JSON, treat as single replace operation + modifications = [{ + type: 'replace', + findText: range, + replaceText: content + }]; + } + } + + const outputPath = options?.outputPath || path; + await modifyDocxContent(path, outputPath, modifications); + + return { + success: true, + editsApplied: modifications.length + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + success: false, + editsApplied: 0, + errors: [{ location: range, error: errorMessage }] + }; + } + } + + /** + * Get DOCX file information + */ + async getInfo(path: string): Promise { + const stats = await fs.stat(path); + + // Get DOCX metadata + let metadata: any = { isDocx: true }; + try { + const docxMetadata = await getDocxMetadata(path); + metadata = { + isDocx: true, + title: docxMetadata.title, + author: docxMetadata.author, + subject: docxMetadata.subject, + creator: docxMetadata.creator, + paragraphCount: docxMetadata.paragraphCount, + wordCount: docxMetadata.wordCount + }; + } catch { + // If we can't parse, just return basic info + } + + return { + size: stats.size, + created: stats.birthtime, + modified: stats.mtime, + accessed: stats.atime, + isDirectory: false, + isFile: true, + permissions: (stats.mode & 0o777).toString(8), + fileType: 'binary', + metadata + }; + } +} + diff --git a/src/utils/files/factory.ts b/src/utils/files/factory.ts index 34a2e1f4..63e53ecb 100644 --- a/src/utils/files/factory.ts +++ b/src/utils/files/factory.ts @@ -12,6 +12,7 @@ import { ImageFileHandler } from './image.js'; import { BinaryFileHandler } from './binary.js'; import { ExcelFileHandler } from './excel.js'; import { PdfFileHandler } from './pdf.js'; +import { DocxFileHandler } from './docx.js'; // Singleton instances of each handler let excelHandler: ExcelFileHandler | null = null; @@ -19,6 +20,7 @@ let imageHandler: ImageFileHandler | null = null; let textHandler: TextFileHandler | null = null; let binaryHandler: BinaryFileHandler | null = null; let pdfHandler: PdfFileHandler | null = null; +let docxHandler: DocxFileHandler | null = null; /** * Initialize handlers (lazy initialization) @@ -48,6 +50,11 @@ function getPdfHandler(): PdfFileHandler { return pdfHandler; } +function getDocxHandler(): DocxFileHandler { + if (!docxHandler) docxHandler = new DocxFileHandler(); + return docxHandler; +} + /** * Get the appropriate file handler for a given file path * @@ -57,10 +64,11 @@ function getPdfHandler(): PdfFileHandler { * * Priority order: * 1. PDF files (extension based) - * 2. Excel files (xlsx, xls, xlsm) - extension based - * 3. Image files (png, jpg, gif, webp) - extension based - * 4. Binary files - content-based detection via isBinaryFile - * 5. Text files (default) + * 2. DOCX files (docx) - extension based + * 3. Excel files (xlsx, xls, xlsm) - extension based + * 4. Image files (png, jpg, gif, webp) - extension based + * 5. Binary files - content-based detection via isBinaryFile + * 6. Text files (default) * * @param filePath File path to get handler for * @returns FileHandler instance that can handle this file @@ -71,6 +79,11 @@ export async function getFileHandler(filePath: string): Promise { return getPdfHandler(); } + // Check DOCX (extension-based, sync) + if (getDocxHandler().canHandle(filePath)) { + return getDocxHandler(); + } + // Check Excel (extension-based, sync) if (getExcelHandler().canHandle(filePath)) { return getExcelHandler(); From c0a4d7234806e70adf5329bef647b8f82d5b4978 Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:13:58 +0100 Subject: [PATCH 2/9] fix: errors in read docx --- src/handlers/filesystem-handlers.ts | 31 +- src/server.ts | 121 ++--- src/tools/docx/dom.ts | 265 +++++++++++ src/tools/docx/index.ts | 26 +- src/tools/docx/modify.ts | 331 ++++++++++++++ src/tools/docx/ops/index.ts | 34 ++ .../ops/replace-paragraph-at-body-index.ts | 36 ++ .../docx/ops/replace-paragraph-text-exact.ts | 30 ++ .../docx/ops/set-color-for-paragraph-exact.ts | 31 ++ src/tools/docx/ops/set-color-for-style.ts | 35 ++ src/tools/docx/read.ts | 214 +++++---- src/tools/docx/types.ts | 139 ++++-- src/tools/docx/utils.ts | 197 --------- src/tools/docx/validate.ts | 61 +++ src/tools/docx/write.ts | 416 +++--------------- src/tools/docx/zip.ts | 44 ++ src/tools/schemas.ts | 79 ++-- 17 files changed, 1280 insertions(+), 810 deletions(-) create mode 100644 src/tools/docx/dom.ts create mode 100644 src/tools/docx/modify.ts create mode 100644 src/tools/docx/ops/index.ts create mode 100644 src/tools/docx/ops/replace-paragraph-at-body-index.ts create mode 100644 src/tools/docx/ops/replace-paragraph-text-exact.ts create mode 100644 src/tools/docx/ops/set-color-for-paragraph-exact.ts create mode 100644 src/tools/docx/ops/set-color-for-style.ts delete mode 100644 src/tools/docx/utils.ts create mode 100644 src/tools/docx/validate.ts create mode 100644 src/tools/docx/zip.ts diff --git a/src/handlers/filesystem-handlers.ts b/src/handlers/filesystem-handlers.ts index cb697633..b967ec48 100644 --- a/src/handlers/filesystem-handlers.ts +++ b/src/handlers/filesystem-handlers.ts @@ -26,6 +26,7 @@ import { MoveFileArgsSchema, GetFileInfoArgsSchema, WritePdfArgsSchema, + ReadDocxArgsSchema, WriteDocxArgsSchema } from '../tools/schemas.js'; @@ -408,16 +409,36 @@ export async function handleWritePdf(args: unknown): Promise { } /** - * Handle write_docx command + * Handle read_docx command — returns compact JSON outline + */ +export async function handleReadDocx(args: unknown): Promise { + try { + const parsed = ReadDocxArgsSchema.parse(args); + const { readDocxOutline } = await import('../tools/docx/index.js'); + const outline = await readDocxOutline(parsed.path); + return { + content: [{ type: "text", text: JSON.stringify(outline, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return createErrorResponse(errorMessage); + } +} + +/** + * Handle write_docx command — patch-based update */ export async function handleWriteDocx(args: unknown): Promise { try { const parsed = WriteDocxArgsSchema.parse(args); - const { writeDocx } = await import('../tools/filesystem.js'); - await writeDocx(parsed.path, parsed.content, parsed.outputPath); - const targetPath = parsed.outputPath || parsed.path; + const { writeDocxPatched } = await import('../tools/docx/index.js'); + const result = await writeDocxPatched( + parsed.inputPath, + parsed.outputPath, + parsed.ops as any + ); return { - content: [{ type: "text", text: `Successfully wrote DOCX to ${targetPath}${parsed.outputPath ? `\nOriginal file: ${parsed.path}` : ''}` }], + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/server.ts b/src/server.ts index 1ffabd22..78f44203 100644 --- a/src/server.ts +++ b/src/server.ts @@ -49,6 +49,7 @@ import { GetPromptsArgsSchema, GetRecentToolCallsArgsSchema, WritePdfArgsSchema, + ReadDocxArgsSchema, WriteDocxArgsSchema, } from './tools/schemas.js'; import { getConfig, setConfigValue } from './tools/config.js'; @@ -305,6 +306,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { - DOCX (.docx): Extracts text content with paragraph structure * offset/length work as paragraph pagination (0-based) * Preserves document metadata (title, author, word count) + * ⚠️ For DOCX editing workflows, use read_docx instead — it returns a compact outline (minimal tokens) with bodyChildIndex for precise write_docx targeting. Only use read_file for DOCX when you need full paragraph text for display. ${PATH_GUIDANCE} ${CMD_PREFIX_DESCRIPTION}`, @@ -445,68 +447,71 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { openWorldHint: false, }, }, + { + name: "read_docx", + description: ` + Read a DOCX file and return a compact JSON outline for precise targeting. + + ⚠️ ALWAYS use this ONCE before write_docx — do NOT call read_file on .docx files for editing workflows. + This returns a token-efficient outline; read_file returns full text and wastes context. + + Returns a structured outline with: + - paragraphs[]: each has bodyChildIndex (position among ALL w:body children), paragraphIndex, style id, and text + - stylesSeen[]: list of paragraph style ids found in the document + - counts: { tables, images, bodyChildren } + + WORKFLOW (always follow this — only 2 tool calls needed): + 1. read_docx(path="doc.docx") → get compact outline (call ONCE, do NOT repeat) + 2. Plan your ops from the outline + 3. write_docx(inputPath="doc.docx", outputPath="doc_v2.docx", ops=[...]) + + Only works within allowed directories. + + ${PATH_GUIDANCE} + ${CMD_PREFIX_DESCRIPTION}`, + inputSchema: zodToJsonSchema(ReadDocxArgsSchema), + annotations: { + title: "Read DOCX Outline", + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }, { name: "write_docx", description: ` - Create a new DOCX file or modify an existing one. + Apply patch-based updates to an existing DOCX file. - THIS IS THE ONLY TOOL FOR CREATING AND MODIFYING DOCX FILES. + THIS IS THE ONLY TOOL FOR MODIFYING DOCX FILES. + Never overwrites the input — always writes to outputPath. + Preserves tables, images, numbering, headers/footers, styles, section breaks, and paragraph/table order. - RULES ABOUT FILENAMES: - - When creating a new DOCX, 'outputPath' MUST be provided and MUST use a new unique filename (e.g., "result_01.docx", "report_2025_01.docx", etc.). - - When modifying an existing DOCX, 'outputPath' should be provided to avoid overwriting the original. + ⚠️ MANDATORY WORKFLOW (exactly 2 tool calls — no more): + 1. read_docx(path) → get outline ONCE (do NOT call read_file or read_docx again) + 2. write_docx(inputPath, outputPath, ops) → apply all changes in ONE call - MODES: - 1. REPLACE BODY XML (RECOMMENDED - preserves all styles): - - Pass body XML string (from read_file) as 'content'. - - LLM modifies the body XML based on user query. - - All styles and formatting are preserved. - - Source file must exist (use same path as read_file). - - Example workflow: - 1. read_file(path="document.docx") → returns body XML - 2. Modify the body XML based on user request - 3. write_docx(path="document.docx", content="...", outputPath="updated_document.docx") - - write_docx(path="doc.docx", content="...", outputPath="new_doc.docx") - - 2. CREATE NEW DOCX FROM TEXT: - - Pass plain text string as 'content' (not starting with ( + nl: NodeListOf | NodeList | { length: number; item(index: number): T | null }, +): T[] { + const arr: T[] = []; + for (let i = 0; i < nl.length; i++) { + const n = nl.item(i); + if (n) arr.push(n as T); + } + return arr; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Body access +// ═══════════════════════════════════════════════════════════════════════ + +/** Return the single element from a parsed document.xml DOM. */ +export function getBody(doc: Document): Element { + const body = doc.getElementsByTagName('w:body').item(0); + if (!body) throw new Error('Invalid DOCX DOM: missing '); + return body; +} + +/** + * Return ALL direct element children of w:body **in document order**. + * Includes w:p, w:tbl, w:sdt, w:sectPr, etc. + */ +export function getBodyChildren(body: Element): Element[] { + const out: Element[] = []; + for (const node of nodeListToArray(body.childNodes)) { + if (node.nodeType === 1) out.push(node as Element); + } + return out; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Body signature +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Build a compact signature string from the body children array. + * Maps each node's qualified name to a short local name: + * w:p → p, w:tbl → tbl, w:sdt → sdt, w:sectPr → sectPr, … + * Returns e.g. "p,tbl,p,p,sectPr". + */ +export function bodySignature(children: Element[]): string { + return children + .map((ch) => { + const name = ch.nodeName; + const idx = name.indexOf(':'); + return idx >= 0 ? name.substring(idx + 1) : name; + }) + .join(','); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Paragraph text helpers +// ═══════════════════════════════════════════════════════════════════════ + +/** Concatenate text from every descendant of a paragraph. */ +export function getParagraphText(p: Element): string { + const tNodes = p.getElementsByTagName('w:t'); + let out = ''; + for (let i = 0; i < tNodes.length; i++) { + out += tNodes.item(i)?.textContent ?? ''; + } + return out; +} + +/** Read the style id from w:pPr/w:pStyle/@w:val, or null if absent. */ +export function getParagraphStyle(p: Element): string | null { + for (const child of nodeListToArray(p.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:pPr') { + const pPr = child as Element; + for (const prChild of nodeListToArray(pPr.childNodes)) { + if (prChild.nodeType === 1 && (prChild as Element).nodeName === 'w:pStyle') { + return (prChild as Element).getAttribute('w:val'); + } + } + return null; + } + } + return null; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Minimal text replacement +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Replace the text of a paragraph with minimal DOM changes. + * Sets the FIRST w:t to `text`, clears every subsequent w:t. + * Sets xml:space="preserve" so leading/trailing spaces survive. + * Does NOT recreate runs or remove paragraph properties. + */ +export function setParagraphTextMinimal(p: Element, text: string): void { + const tNodes = p.getElementsByTagName('w:t'); + if (tNodes.length === 0) return; + + const first = tNodes.item(0)!; + first.textContent = text; + first.setAttribute('xml:space', 'preserve'); + + for (let i = 1; i < tNodes.length; i++) { + tNodes.item(i)!.textContent = ''; + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Run-level formatting helpers +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Ensure a element has w:rPr/w:color[@w:val=hex]. + * Creates w:rPr and w:color if they don't exist. + * Only touches the colour — leaves every other run property intact. + */ +export function ensureRunColor(run: Element, hex: string): void { + const doc = run.ownerDocument; + if (!doc) return; + + let rPr = findDirectChild(run, 'w:rPr'); + if (!rPr) { + rPr = doc.createElement('w:rPr'); + if (run.firstChild) { + run.insertBefore(rPr, run.firstChild); + } else { + run.appendChild(rPr); + } + } + + let colorEl = findDirectChild(rPr, 'w:color'); + if (!colorEl) { + colorEl = doc.createElement('w:color'); + rPr.appendChild(colorEl); + } + + colorEl.setAttribute('w:val', hex); +} + +/** + * Apply run-level colour to every in a paragraph. + */ +export function colorParagraphRuns(p: Element, color: string): void { + const runs = nodeListToArray(p.getElementsByTagName('w:r')); + for (const r of runs) { + ensureRunColor(r as Element, color); + } +} + +/** + * Apply bold / italic / color to every in a paragraph. + * Preserves all existing w:rPr children; only modifies specified props. + */ +export function styleParagraphRuns( + p: Element, + style: { color?: string; bold?: boolean; italic?: boolean }, +): void { + const doc = p.ownerDocument; + if (!doc) return; + + const runs = nodeListToArray(p.getElementsByTagName('w:r')); + for (const r of runs) { + let rPr = findDirectChild(r as Element, 'w:rPr'); + if (!rPr) { + rPr = doc.createElement('w:rPr'); + if (r.firstChild) { + r.insertBefore(rPr, r.firstChild); + } else { + r.appendChild(rPr); + } + } + + if (style.color) { + let colorNode = findDirectChild(rPr, 'w:color'); + if (!colorNode) { + colorNode = doc.createElement('w:color'); + rPr.appendChild(colorNode); + } + colorNode.setAttribute('w:val', style.color); + } + + if (style.bold !== undefined) { + toggleElement(doc, rPr, 'w:b', style.bold); + } + + if (style.italic !== undefined) { + toggleElement(doc, rPr, 'w:i', style.italic); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Counting helpers +// ═══════════════════════════════════════════════════════════════════════ + +/** Count direct w:tbl children of body. */ +export function countTables(children: Element[]): number { + return children.filter((ch) => ch.nodeName === 'w:tbl').length; +} + +/** Count descendants (rough image count). */ +export function countImages(body: Element): number { + return body.getElementsByTagName('w:drawing').length; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Private helpers (DRY: used by multiple public functions) +// ═══════════════════════════════════════════════════════════════════════ + +/** Find the first direct child element with the given nodeName. */ +function findDirectChild(parent: Element, nodeName: string): Element | null { + for (const child of nodeListToArray(parent.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === nodeName) { + return child as Element; + } + } + return null; +} + +/** Add or remove a simple flag element (e.g. w:b, w:i) inside a parent. */ +function toggleElement( + doc: Document, + parent: Element, + nodeName: string, + enabled: boolean, +): void { + const existing = findDirectChild(parent, nodeName); + if (enabled && !existing) { + parent.appendChild(doc.createElement(nodeName)); + } else if (!enabled && existing) { + parent.removeChild(existing); + } +} + diff --git a/src/tools/docx/index.ts b/src/tools/docx/index.ts index 1aafc7b0..ab18c069 100644 --- a/src/tools/docx/index.ts +++ b/src/tools/docx/index.ts @@ -1,10 +1,26 @@ /** - * DOCX file manipulation tools - * Provides utilities for reading, writing, and modifying DOCX files - * while preserving formatting and styles + * DOCX file manipulation tools — barrel exports. */ +// Patch-based tools (read_docx / write_docx) +export { readDocxOutline } from './read.js'; +export { writeDocxPatched } from './write.js'; + +// Legacy functions (used by read_file, write_file, edit_block handlers) export { readDocx, extractTextFromDocx, getDocxMetadata, extractBodyXml } from './read.js'; -export { writeDocx, modifyDocxContent, replaceBodyXml } from './write.js'; -export type { DocxMetadata, DocxParagraph, DocxRun, DocxModification } from './types.js'; +export { writeDocx, modifyDocxContent, replaceBodyXml } from './modify.js'; +// Types +export type { + DocxMetadata, + DocxParagraph, + DocxRun, + DocxModification, + ParagraphOutline, + ReadDocxResult, + WriteDocxStats, + WriteDocxResult, + BodySnapshot, + DocxOp, + OpResult, +} from './types.js'; diff --git a/src/tools/docx/modify.ts b/src/tools/docx/modify.ts new file mode 100644 index 00000000..cdf0df0f --- /dev/null +++ b/src/tools/docx/modify.ts @@ -0,0 +1,331 @@ +/** + * Legacy DOCX modification operations. + * + * These functions support the older write_file / edit_block paths that + * modify DOCX via simple operations (replace, insert, delete, style). + * They are distinct from the new patch-based writeDocxPatched pipeline. + * + * Single Responsibility: create / modify DOCX content using the legacy + * DocxModification interface. Delegates XML parsing and element + * manipulation to the shared dom.ts module. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import type { DocxModification } from './types.js'; +import { + nodeListToArray, + getParagraphText, + setParagraphTextMinimal, + colorParagraphRuns, + styleParagraphRuns, +} from './dom.js'; + +// ═══════════════════════════════════════════════════════════════════════ +// Helpers (private to this module) +// ═══════════════════════════════════════════════════════════════════════ + +/** Get all direct w:p children of body in document order. */ +function getParagraphs(body: Element): Element[] { + const paragraphs: Element[] = []; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:p') { + paragraphs.push(child as Element); + } + } + return paragraphs; +} + +/** Parse DOCX and return { zip, dom, body }. */ +function parseDocument(inputBuf: Buffer): { + zip: PizZip; + dom: Document; + body: Element; +} { + const zip = new PizZip(inputBuf); + const docFile = zip.file('word/document.xml'); + if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml'); + + const dom = new DOMParser().parseFromString(docFile.asText(), 'application/xml'); + const body = dom.getElementsByTagName('w:body').item(0); + if (!body) throw new Error('Invalid DOCX: missing w:body'); + + return { zip, dom, body }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// modifyDocxContent — apply legacy modifications +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Open an existing DOCX, apply an ordered list of modifications to + * word/document.xml, and write the result to outputPath. + * Every other file in the ZIP (styles, images, rels, …) is preserved. + */ +export async function modifyDocxContent( + inputPath: string, + outputPath: string, + modifications: DocxModification[], +): Promise { + const inputBuf = await fs.readFile(inputPath); + const { zip, dom, body } = parseDocument(inputBuf); + + for (const mod of modifications) { + switch (mod.type) { + case 'replace': + applyReplace(body, mod); + break; + case 'insert': + applyInsert(body, mod); + break; + case 'delete': + applyDelete(body, mod); + break; + case 'style': + applyStyle(body, mod); + break; + } + } + + const outXml = new XMLSerializer().serializeToString(dom); + zip.file('word/document.xml', outXml); + const outBuf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, outBuf); +} + +// ═══════════════════════════════════════════════════════════════════════ +// replaceBodyXml — wholesale body replacement +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Replace the entire w:body content of a DOCX with new body XML. + * Used by the body-XML replacement mode of write_file. + */ +export async function replaceBodyXml( + inputPath: string, + outputPath: string, + newBodyXml: string, +): Promise { + const tempDir = os.tmpdir(); + const tempDocxPath = path.join(tempDir, `docx_temp_${Date.now()}_${Math.random().toString(36).substring(7)}.docx`); + const tempXmlPath = path.join(tempDir, `docx_dom_${Date.now()}_${Math.random().toString(36).substring(7)}.xml`); + + try { + const inputBuf = await fs.readFile(inputPath); + await fs.writeFile(tempDocxPath, inputBuf); + + const { zip, dom, body } = parseDocument(inputBuf); + await fs.writeFile(tempXmlPath, zip.file('word/document.xml')!.asText()); + + // Parse the new body XML + const newBodyDom = new DOMParser().parseFromString( + `${newBodyXml}`, + 'application/xml', + ); + const newBodyElement = newBodyDom.documentElement.firstChild as Element; + if (!newBodyElement || newBodyElement.nodeName !== 'w:body') { + throw new Error('Invalid body XML: must start with '); + } + + // Import children from new body into original document + const doc = body.ownerDocument; + if (!doc) throw new Error('Document owner not found'); + + while (body.firstChild) body.removeChild(body.firstChild); + + for (const child of nodeListToArray(newBodyElement.childNodes)) { + body.appendChild(doc.importNode(child, true)); + } + + const outXml = new XMLSerializer().serializeToString(dom); + zip.file('word/document.xml', outXml); + const outBuf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, outBuf); + } finally { + try { await fs.unlink(tempDocxPath); } catch { /* ignore */ } + try { await fs.unlink(tempXmlPath); } catch { /* ignore */ } + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// writeDocx — create minimal DOCX from plain text +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Create a brand-new minimal DOCX from a plain-text string. + * Double-newlines are treated as paragraph separators. + */ +export async function writeDocx( + outputPath: string, + content: string | DocxModification[], +): Promise { + if (typeof content !== 'string') { + throw new Error( + 'Modifications require an existing DOCX file. Use modifyDocxContent() instead.', + ); + } + + const zip = new PizZip(); + + const escaped = (s: string) => + s.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const docXml = ` + + +${content + .split('\n\n') + .map( + (para) => ` + + ${escaped(para)} + + `, + ) + .join('\n')} + +`; + + zip.file('word/document.xml', docXml); + + zip.file( + '[Content_Types].xml', + ` + + + + +`, + ); + + zip.folder('_rels')?.file( + '.rels', + ` + + +`, + ); + + zip.folder('word')?.folder('_rels')?.file( + 'document.xml.rels', + ` + +`, + ); + + const outBuf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, outBuf); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Private modification appliers (SRP: one function per modification type) +// ═══════════════════════════════════════════════════════════════════════ + +function applyReplace(body: Element, mod: DocxModification): void { + if (mod.findText === undefined) return; + const target = mod.findText.trim(); + + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1 || (child as Element).nodeName !== 'w:p') continue; + if (getParagraphText(child as Element).trim() !== target) continue; + + if (mod.replaceText !== undefined) { + setParagraphTextMinimal(child as Element, mod.replaceText); + } + if (mod.style) { + if (mod.style.color) colorParagraphRuns(child as Element, mod.style.color); + if (mod.style.bold !== undefined || mod.style.italic !== undefined) { + styleParagraphRuns(child as Element, mod.style); + } + } + break; // first match only + } +} + +function applyInsert(body: Element, mod: DocxModification): void { + if (mod.paragraphIndex === undefined || mod.insertText === undefined) return; + + const doc = body.ownerDocument; + if (!doc) return; + + const newP = doc.createElement('w:p'); + const newR = doc.createElement('w:r'); + const newT = doc.createElement('w:t'); + newT.textContent = mod.insertText; + newR.appendChild(newT); + newP.appendChild(newR); + + const paragraphs = getParagraphs(body); + const idx = mod.paragraphIndex < 0 + ? paragraphs.length + mod.paragraphIndex + 1 + : mod.paragraphIndex; + + if (idx < 0 || idx > paragraphs.length) return; + + if (idx === paragraphs.length) { + body.appendChild(newP); + } else { + let current = 0; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1 || (child as Element).nodeName !== 'w:p') continue; + if (current === idx) { + body.insertBefore(newP, child); + break; + } + current++; + } + } +} + +function applyDelete(body: Element, mod: DocxModification): void { + if (mod.paragraphIndex === undefined) return; + + const paragraphs = getParagraphs(body); + const idx = mod.paragraphIndex < 0 + ? paragraphs.length + mod.paragraphIndex + : mod.paragraphIndex; + + if (idx < 0 || idx >= paragraphs.length) return; + + let current = 0; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1 || (child as Element).nodeName !== 'w:p') continue; + if (current === idx) { + body.removeChild(child); + break; + } + current++; + } +} + +function applyStyle(body: Element, mod: DocxModification): void { + if (mod.paragraphIndex === undefined || !mod.style) return; + + const paragraphs = getParagraphs(body); + const idx = mod.paragraphIndex < 0 + ? paragraphs.length + mod.paragraphIndex + : mod.paragraphIndex; + + if (idx < 0 || idx >= paragraphs.length) return; + + let current = 0; + for (const child of nodeListToArray(body.childNodes)) { + if (child.nodeType !== 1 || (child as Element).nodeName !== 'w:p') continue; + if (current === idx) { + if (mod.style.color) colorParagraphRuns(child as Element, mod.style.color); + if (mod.style.bold !== undefined || mod.style.italic !== undefined) { + styleParagraphRuns(child as Element, mod.style); + } + break; + } + current++; + } +} + diff --git a/src/tools/docx/ops/index.ts b/src/tools/docx/ops/index.ts new file mode 100644 index 00000000..01fcbb91 --- /dev/null +++ b/src/tools/docx/ops/index.ts @@ -0,0 +1,34 @@ +/** + * Op dispatcher — routes each op to its implementation. + * + * Open/Closed Principle: adding a new op type requires only + * a new file + one extra case here; existing ops stay untouched. + */ + +import { applyReplaceParagraphTextExact } from './replace-paragraph-text-exact.js'; +import { applyReplaceParagraphAtBodyIndex } from './replace-paragraph-at-body-index.js'; +import { applySetColorForStyle } from './set-color-for-style.js'; +import { applySetColorForParagraphExact } from './set-color-for-paragraph-exact.js'; +import type { DocxOp, OpResult } from '../types.js'; + +/** Apply a single operation to the w:body element. */ +export function applyOp(body: Element, op: DocxOp): OpResult { + switch (op.type) { + case 'replace_paragraph_text_exact': + return applyReplaceParagraphTextExact(body, op); + case 'replace_paragraph_at_body_index': + return applyReplaceParagraphAtBodyIndex(body, op); + case 'set_color_for_style': + return applySetColorForStyle(body, op); + case 'set_color_for_paragraph_exact': + return applySetColorForParagraphExact(body, op); + default: + return { + op, + status: 'skipped', + matched: 0, + reason: `unknown_op_type: ${(op as any).type}`, + }; + } +} + diff --git a/src/tools/docx/ops/replace-paragraph-at-body-index.ts b/src/tools/docx/ops/replace-paragraph-at-body-index.ts new file mode 100644 index 00000000..64652fa5 --- /dev/null +++ b/src/tools/docx/ops/replace-paragraph-at-body-index.ts @@ -0,0 +1,36 @@ +/** + * Op: replace_paragraph_at_body_index + * + * Target the child of w:body at the given bodyChildIndex. + * If it is not a w:p → skip with reason "not_a_paragraph". + * Otherwise apply the same minimal text replacement. + */ + +import { getBodyChildren, setParagraphTextMinimal } from '../dom.js'; +import type { ReplaceParagraphAtBodyIndexOp, OpResult } from '../types.js'; + +export function applyReplaceParagraphAtBodyIndex( + body: Element, + op: ReplaceParagraphAtBodyIndexOp, +): OpResult { + const children = getBodyChildren(body); + + if (op.bodyChildIndex < 0 || op.bodyChildIndex >= children.length) { + return { + op, + status: 'skipped', + matched: 0, + reason: `bodyChildIndex ${op.bodyChildIndex} out of range (0..${children.length - 1})`, + }; + } + + const child = children[op.bodyChildIndex]; + + if (child.nodeName !== 'w:p') { + return { op, status: 'skipped', matched: 0, reason: 'not_a_paragraph' }; + } + + setParagraphTextMinimal(child, op.to); + return { op, status: 'applied', matched: 1 }; +} + diff --git a/src/tools/docx/ops/replace-paragraph-text-exact.ts b/src/tools/docx/ops/replace-paragraph-text-exact.ts new file mode 100644 index 00000000..9616cad9 --- /dev/null +++ b/src/tools/docx/ops/replace-paragraph-text-exact.ts @@ -0,0 +1,30 @@ +/** + * Op: replace_paragraph_text_exact + * + * Find FIRST paragraph whose trimmed text === `from`. + * Replace only the first w:t with `to`; clear other w:t nodes. + * Does NOT remove/recreate runs or paragraph properties. + */ + +import { getBodyChildren, getParagraphText, setParagraphTextMinimal } from '../dom.js'; +import type { ReplaceParagraphTextExactOp, OpResult } from '../types.js'; + +export function applyReplaceParagraphTextExact( + body: Element, + op: ReplaceParagraphTextExactOp, +): OpResult { + const children = getBodyChildren(body); + const target = op.from.trim(); + + for (const child of children) { + if (child.nodeName !== 'w:p') continue; + + if (getParagraphText(child).trim() === target) { + setParagraphTextMinimal(child, op.to); + return { op, status: 'applied', matched: 1 }; + } + } + + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; +} + diff --git a/src/tools/docx/ops/set-color-for-paragraph-exact.ts b/src/tools/docx/ops/set-color-for-paragraph-exact.ts new file mode 100644 index 00000000..cfd4e940 --- /dev/null +++ b/src/tools/docx/ops/set-color-for-paragraph-exact.ts @@ -0,0 +1,31 @@ +/** + * Op: set_color_for_paragraph_exact + * + * Find FIRST paragraph whose trimmed text === `text`. + * Apply run-level colour to every w:r in that paragraph. + */ + +import { getBodyChildren, getParagraphText, ensureRunColor } from '../dom.js'; +import type { SetColorForParagraphExactOp, OpResult } from '../types.js'; + +export function applySetColorForParagraphExact( + body: Element, + op: SetColorForParagraphExactOp, +): OpResult { + const children = getBodyChildren(body); + const target = op.text.trim(); + + for (const child of children) { + if (child.nodeName !== 'w:p') continue; + if (getParagraphText(child).trim() !== target) continue; + + const runs = child.getElementsByTagName('w:r'); + for (let i = 0; i < runs.length; i++) { + ensureRunColor(runs.item(i) as Element, op.color); + } + return { op, status: 'applied', matched: 1 }; + } + + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; +} + diff --git a/src/tools/docx/ops/set-color-for-style.ts b/src/tools/docx/ops/set-color-for-style.ts new file mode 100644 index 00000000..a0a3b8fe --- /dev/null +++ b/src/tools/docx/ops/set-color-for-style.ts @@ -0,0 +1,35 @@ +/** + * Op: set_color_for_style + * + * For every paragraph whose w:pPr/w:pStyle/@w:val === style, + * set run-level colour on every w:r in that paragraph. + * Does NOT modify word/styles.xml — only in-document run formatting. + */ + +import { getBodyChildren, getParagraphStyle, ensureRunColor } from '../dom.js'; +import type { SetColorForStyleOp, OpResult } from '../types.js'; + +export function applySetColorForStyle( + body: Element, + op: SetColorForStyleOp, +): OpResult { + const children = getBodyChildren(body); + let matched = 0; + + for (const child of children) { + if (child.nodeName !== 'w:p') continue; + if (getParagraphStyle(child) !== op.style) continue; + + const runs = child.getElementsByTagName('w:r'); + for (let i = 0; i < runs.length; i++) { + ensureRunColor(runs.item(i) as Element, op.color); + } + matched++; + } + + if (matched === 0) { + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; + } + return { op, status: 'applied', matched }; +} + diff --git a/src/tools/docx/read.ts b/src/tools/docx/read.ts index 40474492..681574f1 100644 --- a/src/tools/docx/read.ts +++ b/src/tools/docx/read.ts @@ -1,110 +1,147 @@ /** * DOCX reading utilities - * Extracts text and metadata from DOCX files while preserving structure + * Extracts text, metadata, and compact outlines from DOCX files. */ import fs from 'fs/promises'; import PizZip from 'pizzip'; import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; -import type { DocxMetadata, DocxParagraph } from './types.js'; -import { nodeListToArray, getTextFromParagraph } from './utils.js'; +import type { DocxMetadata, DocxParagraph, ParagraphOutline, ReadDocxResult } from './types.js'; +import { + nodeListToArray, + getParagraphText, + getParagraphStyle, + getBody, + getBodyChildren, + countTables, + countImages, +} from './dom.js'; + +// ═══════════════════════════════════════════════════════════════════════ +// Internal helpers +// ═══════════════════════════════════════════════════════════════════════ -/** - * Load DOCX file and return ZIP archive - */ async function loadDocx(path: string): Promise { const inputBuf = await fs.readFile(path); return new PizZip(inputBuf); } +// ═══════════════════════════════════════════════════════════════════════ +// readDocxOutline — compact JSON outline (used by read_docx tool) +// ═══════════════════════════════════════════════════════════════════════ + /** - * Extract text content from DOCX document + * Return a token-efficient outline of a DOCX file. + * Every paragraph gets a bodyChildIndex (among ALL w:body children) + * plus a paragraphIndex (counting only w:p), style id, and text. */ +export async function readDocxOutline(filePath: string): Promise { + const zip = await loadDocx(filePath); + const docFile = zip.file('word/document.xml'); + if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml'); + + const xmlStr = docFile.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + const body = getBody(dom); + const children = getBodyChildren(body); + + const paragraphs: ParagraphOutline[] = []; + const stylesSet = new Set(); + let paragraphIndex = 0; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeName !== 'w:p') continue; + + const text = getParagraphText(child).trim(); + const style = getParagraphStyle(child); + + if (style) stylesSet.add(style); + + paragraphs.push({ + bodyChildIndex: i, + paragraphIndex, + style, + text, + }); + paragraphIndex++; + } + + return { + path: filePath, + paragraphs, + stylesSeen: [...stylesSet].sort(), + counts: { + tables: countTables(children), + images: countImages(body), + bodyChildren: children.length, + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Legacy read functions (used by read_file handler / file handler) +// ═══════════════════════════════════════════════════════════════════════ + +/** Extract plain text from DOCX. */ export async function extractTextFromDocx(path: string): Promise { const zip = await loadDocx(path); const docFile = zip.file('word/document.xml'); - - if (!docFile) { - throw new Error('Invalid DOCX: missing word/document.xml'); - } + if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml'); const xmlStr = docFile.asText(); const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); const body = dom.getElementsByTagName('w:body').item(0); - - if (!body) { - throw new Error('Invalid DOCX: missing w:body'); - } + if (!body) throw new Error('Invalid DOCX: missing w:body'); const paragraphs: string[] = []; for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType !== 1) continue; // element nodes only + if (child.nodeType !== 1) continue; if ((child as Element).nodeName !== 'w:p') continue; - - const text = getTextFromParagraph(child as Element).trim(); - if (text) { - paragraphs.push(text); - } + const text = getParagraphText(child as Element).trim(); + if (text) paragraphs.push(text); } return paragraphs.join('\n\n'); } -/** - * Extract paragraphs from DOCX document - */ -export async function extractParagraphs(path: string): Promise { +/** Extract paragraphs from DOCX. */ +async function extractParagraphs(path: string): Promise { const zip = await loadDocx(path); const docFile = zip.file('word/document.xml'); - - if (!docFile) { - throw new Error('Invalid DOCX: missing word/document.xml'); - } + if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml'); const xmlStr = docFile.asText(); const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); const body = dom.getElementsByTagName('w:body').item(0); - - if (!body) { - throw new Error('Invalid DOCX: missing w:body'); - } + if (!body) throw new Error('Invalid DOCX: missing w:body'); const paragraphs: DocxParagraph[] = []; let index = 0; for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType !== 1) continue; // element nodes only + if (child.nodeType !== 1) continue; if ((child as Element).nodeName !== 'w:p') continue; - const text = getTextFromParagraph(child as Element).trim(); - paragraphs.push({ - index, - text, - hasText: text.length > 0 - }); + const text = getParagraphText(child as Element).trim(); + paragraphs.push({ index, text, hasText: text.length > 0 }); index++; } return paragraphs; } -/** - * Get DOCX metadata from core properties - */ +/** Get core properties from DOCX. */ async function getCoreProperties(zip: PizZip): Promise> { const corePropsFile = zip.file('docProps/core.xml'); - if (!corePropsFile) { - return {}; - } + if (!corePropsFile) return {}; const xmlStr = corePropsFile.asText(); const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); const getProperty = (name: string): string | undefined => { const elements = dom.getElementsByTagName(name); - if (elements.length > 0) { - return elements[0].textContent || undefined; - } + if (elements.length > 0) return elements[0].textContent || undefined; return undefined; }; @@ -112,63 +149,40 @@ async function getCoreProperties(zip: PizZip): Promise> { title: getProperty('dc:title'), author: getProperty('dc:creator'), subject: getProperty('dc:subject'), - creator: getProperty('cp:creator') + creator: getProperty('cp:creator'), }; } -/** - * Get comprehensive DOCX metadata - */ +/** Get comprehensive metadata. */ export async function getDocxMetadata(path: string): Promise { const zip = await loadDocx(path); const paragraphs = await extractParagraphs(path); - const coreProps = await getCoreProperties(zip); - - // Calculate word count (approximate) - const fullText = paragraphs.map(p => p.text).join(' '); - const wordCount = fullText.split(/\s+/).filter(w => w.length > 0).length; + const fullText = paragraphs.map((p) => p.text).join(' '); + const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length; - return { - ...coreProps, - paragraphCount: paragraphs.length, - wordCount - }; + return { ...coreProps, paragraphCount: paragraphs.length, wordCount }; } -/** - * Extract body XML from DOCX file - * Returns the body element as XML string for LLM modification - */ +/** Extract body XML. */ export async function extractBodyXml(path: string): Promise { const inputBuf = await fs.readFile(path); const zip = new PizZip(inputBuf); - const docFile = zip.file('word/document.xml'); - if (!docFile) { - throw new Error('Invalid DOCX: missing word/document.xml'); - } + if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml'); const xmlStr = docFile.asText(); const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); - - // Locate body const body = dom.getElementsByTagName('w:body').item(0); - if (!body) { - throw new Error('Invalid DOCX: missing w:body'); - } + if (!body) throw new Error('Invalid DOCX: missing w:body'); - // Serialize body element to XML string - const serializer = new XMLSerializer(); - return serializer.serializeToString(body); + return new XMLSerializer().serializeToString(body); } -/** - * Read DOCX file and return structured content - */ +/** Read DOCX file with optional pagination. */ export async function readDocx( path: string, - options?: { offset?: number; length?: number } + options?: { offset?: number; length?: number }, ): Promise<{ text: string; paragraphs: DocxParagraph[]; @@ -177,18 +191,12 @@ export async function readDocx( }> { const zip = await loadDocx(path); const docFile = zip.file('word/document.xml'); - - if (!docFile) { - throw new Error('Invalid DOCX: missing word/document.xml'); - } + if (!docFile) throw new Error('Invalid DOCX: missing word/document.xml'); const xmlStr = docFile.asText(); const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); const body = dom.getElementsByTagName('w:body').item(0); - - if (!body) { - throw new Error('Invalid DOCX: missing w:body'); - } + if (!body) throw new Error('Invalid DOCX: missing w:body'); const allParagraphs: DocxParagraph[] = []; let index = 0; @@ -197,16 +205,11 @@ export async function readDocx( if (child.nodeType !== 1) continue; if ((child as Element).nodeName !== 'w:p') continue; - const text = getTextFromParagraph(child as Element).trim(); - allParagraphs.push({ - index, - text, - hasText: text.length > 0 - }); + const text = getParagraphText(child as Element).trim(); + allParagraphs.push({ index, text, hasText: text.length > 0 }); index++; } - // Apply pagination if requested let paragraphs = allParagraphs; if (options?.offset !== undefined || options?.length !== undefined) { const offset = options.offset || 0; @@ -215,17 +218,8 @@ export async function readDocx( } const metadata = await getDocxMetadata(path); - const text = paragraphs.map(p => p.text).join('\n\n'); + const text = paragraphs.map((p) => p.text).join('\n\n'); + const bodyXml = new XMLSerializer().serializeToString(body); - // Extract body XML for LLM modification - const serializer = new XMLSerializer(); - const bodyXml = serializer.serializeToString(body); - - return { - text, - paragraphs, - metadata, - bodyXml - }; + return { text, paragraphs, metadata, bodyXml }; } - diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts index 67ecd6ce..448f3583 100644 --- a/src/tools/docx/types.ts +++ b/src/tools/docx/types.ts @@ -1,77 +1,142 @@ /** - * Type definitions for DOCX operations + * Type definitions for DOCX operations. + * Single source of truth for every type used across the DOCX module. */ -/** - * DOCX metadata extracted from document - */ +// ═══════════════════════════════════════════════════════════════════════ +// Core document metadata (legacy read path) +// ═══════════════════════════════════════════════════════════════════════ + export interface DocxMetadata { - /** Document title (from core properties) */ title?: string; - /** Document author (from core properties) */ author?: string; - /** Document subject (from core properties) */ subject?: string; - /** Document creator (from core properties) */ creator?: string; - /** Total number of paragraphs in document */ paragraphCount: number; - /** Total word count (approximate) */ wordCount: number; } -/** - * Represents a paragraph in a DOCX document - */ export interface DocxParagraph { - /** Paragraph index (0-based) */ index: number; - /** Plain text content of paragraph */ text: string; - /** Whether paragraph contains text */ hasText: boolean; } -/** - * Represents a text run (formatted text segment) in a DOCX document - */ export interface DocxRun { - /** Text content of the run */ text: string; - /** Whether run has bold formatting */ bold?: boolean; - /** Whether run has italic formatting */ italic?: boolean; - /** Font color (hex format, e.g., "FF0000") */ color?: string; - /** Font size (in half-points) */ fontSize?: number; - /** Font name */ fontName?: string; } -/** - * Modification operation for DOCX content - */ +// ═══════════════════════════════════════════════════════════════════════ +// Legacy modification operations (write_file / edit_block) +// ═══════════════════════════════════════════════════════════════════════ + export interface DocxModification { - /** Type of modification */ type: 'replace' | 'insert' | 'delete' | 'style'; - /** Target paragraph index (0-based) */ paragraphIndex?: number; - /** Text to find (for replace operations) */ findText?: string; - /** Text to replace with */ replaceText?: string; - /** Text to insert */ insertText?: string; - /** Style options for text */ style?: { - /** Font color (hex format) */ color?: string; - /** Bold formatting */ bold?: boolean; - /** Italic formatting */ italic?: boolean; }; } +// ═══════════════════════════════════════════════════════════════════════ +// Read outline (used by read_docx tool) +// ═══════════════════════════════════════════════════════════════════════ + +export interface ParagraphOutline { + bodyChildIndex: number; + paragraphIndex: number; + style: string | null; + text: string; +} + +export interface ReadDocxResult { + path: string; + paragraphs: ParagraphOutline[]; + stylesSeen: string[]; + counts: { + tables: number; + images: number; + bodyChildren: number; + }; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Write / patch result (used by write_docx tool) +// ═══════════════════════════════════════════════════════════════════════ + +export interface WriteDocxStats { + tablesBefore: number; + tablesAfter: number; + bodyChildrenBefore: number; + bodyChildrenAfter: number; + bodySignatureBefore: string; + bodySignatureAfter: string; +} + +export interface WriteDocxResult { + outputPath: string; + results: OpResult[]; + stats: WriteDocxStats; + warnings: string[]; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Validation snapshot +// ═══════════════════════════════════════════════════════════════════════ + +export interface BodySnapshot { + bodyChildCount: number; + tableCount: number; + signature: string; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Patch operations +// ═══════════════════════════════════════════════════════════════════════ + +export interface ReplaceParagraphTextExactOp { + type: 'replace_paragraph_text_exact'; + from: string; + to: string; +} + +export interface ReplaceParagraphAtBodyIndexOp { + type: 'replace_paragraph_at_body_index'; + bodyChildIndex: number; + to: string; +} + +export interface SetColorForStyleOp { + type: 'set_color_for_style'; + style: string; + color: string; +} + +export interface SetColorForParagraphExactOp { + type: 'set_color_for_paragraph_exact'; + text: string; + color: string; +} + +export type DocxOp = + | ReplaceParagraphTextExactOp + | ReplaceParagraphAtBodyIndexOp + | SetColorForStyleOp + | SetColorForParagraphExactOp; + +export interface OpResult { + op: DocxOp; + status: 'applied' | 'skipped'; + matched: number; + reason?: string; +} diff --git a/src/tools/docx/utils.ts b/src/tools/docx/utils.ts deleted file mode 100644 index 9cb04427..00000000 --- a/src/tools/docx/utils.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Utility functions for DOCX manipulation - */ - -/** - * Convert NodeList or HTMLCollection to array - */ -export function nodeListToArray(nl: NodeListOf | NodeList | HTMLCollectionOf | { length: number; item(index: number): T | null }): T[] { - const arr: T[] = []; - for (let i = 0; i < nl.length; i++) { - const item = nl.item(i); - if (item) { - arr.push(item as T); - } - } - return arr; -} - -/** - * Get all text from a paragraph by concatenating w:t nodes in document order - * Follows exact same pattern as reference implementation - */ -export function getTextFromParagraph(pNode: Element): string { - // Concatenate w:t text in document order - const tNodes = pNode.getElementsByTagName('w:t'); - let out = ''; - for (const t of nodeListToArray(tNodes)) { - out += t.textContent || ''; - } - return out; -} - -/** - * Set paragraph text with minimal changes (preserves formatting structure) - * Replaces text in the first w:t; clear remaining w:t nodes (minimal change) - * Follows exact same pattern as reference implementation - */ -export function setParagraphTextMinimal(pNode: Element, newText: string): void { - // Replace text in the first w:t; clear remaining w:t nodes (minimal change) - const tNodes = nodeListToArray(pNode.getElementsByTagName('w:t')); - if (tNodes.length === 0) return; - - tNodes[0].textContent = newText; - for (let i = 1; i < tNodes.length; i++) { - tNodes[i].textContent = ''; - } -} - -/** - * Ensure a run node has color formatting - * Follows exact same pattern as reference implementation - * Preserves all existing w:rPr children, only modifies/adds w:color - */ -export function ensureRunColorRed(runNode: Element, color: string = 'FF0000'): void { - const doc = runNode.ownerDocument; - if (!doc) return; - - // Find or create w:rPr under this w:r - let rPr: Element | null = null; - for (const child of nodeListToArray(runNode.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:rPr') { - rPr = child as Element; - break; - } - } - - if (!rPr) { - rPr = doc.createElement('w:rPr'); - // Insert rPr as the first element child of w:r (standard structure) - if (runNode.firstChild) { - runNode.insertBefore(rPr, runNode.firstChild); - } else { - runNode.appendChild(rPr); - } - } - - // Find existing w:color - let colorNode: Element | null = null; - for (const child of nodeListToArray(rPr.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:color') { - colorNode = child as Element; - break; - } - } - - if (!colorNode) { - colorNode = doc.createElement('w:color'); - rPr.appendChild(colorNode); - } - - // Set w:val attribute (namespace handling: xmldom will keep prefix) - colorNode.setAttribute('w:val', color); -} - -/** - * Apply color formatting to all runs in a paragraph - * Follows exact same pattern as reference implementation - */ -export function colorParagraphRuns(pNode: Element, color: string = 'FF0000'): void { - // For each w:r in the paragraph, add/set w:color to specified color - const rNodes = nodeListToArray(pNode.getElementsByTagName('w:r')); - for (const r of rNodes) { - ensureRunColorRed(r as Element, color); - } -} - -/** - * Apply style to all runs in a paragraph - * Follows exact same pattern as reference implementation - * Preserves all existing w:rPr children, only modifies/adds specified style properties - */ -export function styleParagraphRuns( - pNode: Element, - style: { color?: string; bold?: boolean; italic?: boolean } -): void { - const doc = pNode.ownerDocument; - if (!doc) return; - - // For each w:r in the paragraph, apply styles - const rNodes = nodeListToArray(pNode.getElementsByTagName('w:r')); - for (const r of rNodes) { - // Find or create w:rPr under this w:r (same pattern as ensureRunColorRed) - let rPr: Element | null = null; - for (const child of nodeListToArray(r.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:rPr') { - rPr = child as Element; - break; - } - } - - if (!rPr) { - rPr = doc.createElement('w:rPr'); - // Insert rPr as the first element child of w:r (standard structure) - if (r.firstChild) { - r.insertBefore(rPr, r.firstChild); - } else { - r.appendChild(rPr); - } - } - - // Apply color (same pattern as ensureRunColorRed) - if (style.color) { - let colorNode: Element | null = null; - for (const child of nodeListToArray(rPr.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:color') { - colorNode = child as Element; - break; - } - } - if (!colorNode) { - colorNode = doc.createElement('w:color'); - rPr.appendChild(colorNode); - } - // Set w:val attribute (namespace handling: xmldom will keep prefix) - colorNode.setAttribute('w:val', style.color); - } - - // Apply bold - if (style.bold !== undefined) { - let boldNode: Element | null = null; - for (const child of nodeListToArray(rPr.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:b') { - boldNode = child as Element; - break; - } - } - if (style.bold) { - if (!boldNode) { - boldNode = doc.createElement('w:b'); - rPr.appendChild(boldNode); - } - } else if (boldNode) { - rPr.removeChild(boldNode); - } - } - - // Apply italic - if (style.italic !== undefined) { - let italicNode: Element | null = null; - for (const child of nodeListToArray(rPr.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:i') { - italicNode = child as Element; - break; - } - } - if (style.italic) { - if (!italicNode) { - italicNode = doc.createElement('w:i'); - rPr.appendChild(italicNode); - } - } else if (italicNode) { - rPr.removeChild(italicNode); - } - } - } -} - diff --git a/src/tools/docx/validate.ts b/src/tools/docx/validate.ts new file mode 100644 index 00000000..b9dc3502 --- /dev/null +++ b/src/tools/docx/validate.ts @@ -0,0 +1,61 @@ +/** + * Invariant validation for DOCX write operations. + * + * Single Responsibility: capture a structural snapshot of w:body and + * compare before / after snapshots to guarantee no accidental breakage. + */ + +import { getBodyChildren, bodySignature, countTables } from './dom.js'; +import type { BodySnapshot } from './types.js'; + +// ─── Capture ──────────────────────────────────────────────────────── + +/** Take a snapshot of the body's structural invariants. */ +export function captureSnapshot(body: Element): BodySnapshot { + const children = getBodyChildren(body); + return { + bodyChildCount: children.length, + tableCount: countTables(children), + signature: bodySignature(children), + }; +} + +// ─── Validate ─────────────────────────────────────────────────────── + +/** + * Compare before / after snapshots. + * Throws a descriptive error if any invariant has been violated, + * preventing the output file from being written. + */ +export function validateInvariants( + before: BodySnapshot, + after: BodySnapshot, +): void { + const errors: string[] = []; + + if (before.bodyChildCount !== after.bodyChildCount) { + errors.push( + `Body child count changed: ${before.bodyChildCount} → ${after.bodyChildCount}`, + ); + } + + if (before.tableCount !== after.tableCount) { + errors.push( + `Table count changed: ${before.tableCount} → ${after.tableCount}`, + ); + } + + if (before.signature !== after.signature) { + errors.push( + `Body signature changed:\n before: ${before.signature}\n after: ${after.signature}`, + ); + } + + if (errors.length > 0) { + throw new Error( + 'DOCX structural validation failed — output NOT written.\n' + + errors.join('\n'), + ); + } +} + diff --git a/src/tools/docx/write.ts b/src/tools/docx/write.ts index 4063daa5..47da1db6 100644 --- a/src/tools/docx/write.ts +++ b/src/tools/docx/write.ts @@ -1,370 +1,84 @@ /** - * DOCX writing and modification utilities - * Modifies DOCX files while preserving formatting and styles + * writeDocxPatched - the patch-based "update" orchestrator. + * + * Single Responsibility: coordinate the full update pipeline: + * 1. Open DOCX ZIP + * 2. Parse word/document.xml + * 3. Snapshot before + * 4. Apply operations + * 5. Snapshot after + * 6. Validate invariants (throws -> output NOT written) + * 7. Serialize and save + * + * Each step delegates to a single-purpose module, keeping this file + * a pure orchestrator with no direct DOM/XML/ZIP logic. */ -import fs from 'fs/promises'; -import path from 'path'; -import os from 'os'; -import PizZip from 'pizzip'; -import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; -import type { DocxModification } from './types.js'; -import { nodeListToArray, getTextFromParagraph, setParagraphTextMinimal, styleParagraphRuns, colorParagraphRuns } from './utils.js'; +import { loadDocxZip, getDocumentXml, saveDocxZip } from './zip.js'; +import { parseXml, serializeXml, getBody } from './dom.js'; +import { captureSnapshot, validateInvariants } from './validate.js'; +import { applyOp } from './ops/index.js'; +import type { DocxOp, OpResult, WriteDocxStats, WriteDocxResult } from './types.js'; -/** - * Load DOCX file and return ZIP archive - */ -async function loadDocx(path: string): Promise { - const inputBuf = await fs.readFile(path); - return new PizZip(inputBuf); -} - -/** - * Get all paragraph elements from body in document order - */ -function getParagraphs(body: Element): Element[] { - const paragraphs: Element[] = []; - for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:p') { - paragraphs.push(child as Element); - } - } - return paragraphs; -} - -/** - * Modify DOCX content with specified operations - * Preserves all other files and components in the DOCX - */ -export async function modifyDocxContent( +export async function writeDocxPatched( inputPath: string, outputPath: string, - modifications: DocxModification[] -): Promise { - const inputBuf = await fs.readFile(inputPath); - const zip = new PizZip(inputBuf); + ops: DocxOp[], +): Promise { + // 1. Load ZIP + const zip = await loadDocxZip(inputPath); - // Preserve ALL files in the ZIP - we'll only modify word/document.xml - const docFile = zip.file('word/document.xml'); - if (!docFile) { - throw new Error('Invalid DOCX: missing word/document.xml'); - } + // 2. Parse document.xml + const xmlStr = getDocumentXml(zip); + const doc = parseXml(xmlStr); + const body = getBody(doc); - const xmlStr = docFile.asText(); - const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + // 3. Before-snapshot + const before = captureSnapshot(body); - // Locate body - const body = dom.getElementsByTagName('w:body').item(0); - if (!body) { - throw new Error('Invalid DOCX: missing w:body'); - } - - // Apply modifications in order - // Follow the reference pattern: iterate DIRECT children of body in order - for (const mod of modifications) { - switch (mod.type) { - case 'replace': { - // Iterate DIRECT children of body in order; only touch the matching paragraph - // Follows exact same pattern as reference implementation - if (mod.findText !== undefined) { - const findTextTrimmed = mod.findText.trim(); - for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType !== 1) continue; // element nodes only - if ((child as Element).nodeName !== 'w:p') continue; - - const txt = getTextFromParagraph(child as Element).trim(); - if (txt === findTextTrimmed) { - // Only modify text if replaceText is provided - if (mod.replaceText !== undefined) { - setParagraphTextMinimal(child as Element, mod.replaceText); - } - // Apply style if provided (preserves existing styles, only adds/modifies specified) - if (mod.style) { - if (mod.style.color) { - colorParagraphRuns(child as Element, mod.style.color); - } - if (mod.style.bold !== undefined || mod.style.italic !== undefined) { - styleParagraphRuns(child as Element, mod.style); - } - } - break; // Only replace first match - } - } - } - break; - } + // 4. Apply ops + const results: OpResult[] = []; + const warnings: string[] = []; - case 'insert': { - if (mod.paragraphIndex !== undefined && mod.insertText !== undefined) { - const doc = body.ownerDocument; - if (!doc) break; - - // Create new paragraph with minimal structure - const newP = doc.createElement('w:p'); - const newR = doc.createElement('w:r'); - const newT = doc.createElement('w:t'); - newT.textContent = mod.insertText; - newR.appendChild(newT); - newP.appendChild(newR); - - // Re-collect paragraphs to get current count - const paragraphs = getParagraphs(body); - const insertIndex = mod.paragraphIndex < 0 - ? paragraphs.length + mod.paragraphIndex + 1 - : mod.paragraphIndex; - - // Insert at correct position by iterating DIRECT children - if (insertIndex >= 0 && insertIndex <= paragraphs.length) { - if (insertIndex === paragraphs.length) { - // Append to end - body.appendChild(newP); - } else if (insertIndex < paragraphs.length) { - // Find the paragraph at insertIndex by iterating DIRECT children - let currentIndex = 0; - for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType !== 1) continue; - if ((child as Element).nodeName !== 'w:p') continue; - - if (currentIndex === insertIndex) { - body.insertBefore(newP, child); - break; - } - currentIndex++; - } - } - } - } - break; - } - - case 'delete': { - if (mod.paragraphIndex !== undefined) { - // Re-collect paragraphs to get current count - const paragraphs = getParagraphs(body); - const deleteIndex = mod.paragraphIndex < 0 - ? paragraphs.length + mod.paragraphIndex - : mod.paragraphIndex; - - // Find and delete by iterating DIRECT children - if (deleteIndex >= 0 && deleteIndex < paragraphs.length) { - let currentIndex = 0; - for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType !== 1) continue; - if ((child as Element).nodeName !== 'w:p') continue; - - if (currentIndex === deleteIndex) { - body.removeChild(child); - break; - } - currentIndex++; - } - } - } - break; - } - - case 'style': { - if (mod.paragraphIndex !== undefined && mod.style) { - // Re-collect paragraphs to get current count - const paragraphs = getParagraphs(body); - const styleIndex = mod.paragraphIndex < 0 - ? paragraphs.length + mod.paragraphIndex - : mod.paragraphIndex; + for (const op of ops) { + try { + const result = applyOp(body, op); + results.push(result); - // Find paragraph by iterating DIRECT children - if (styleIndex >= 0 && styleIndex < paragraphs.length) { - let currentIndex = 0; - for (const child of nodeListToArray(body.childNodes)) { - if (child.nodeType !== 1) continue; - if ((child as Element).nodeName !== 'w:p') continue; - - if (currentIndex === styleIndex) { - // Apply style to existing paragraph (preserves other styles) - if (mod.style.color) { - colorParagraphRuns(child as Element, mod.style.color); - } - if (mod.style.bold !== undefined || mod.style.italic !== undefined) { - styleParagraphRuns(child as Element, mod.style); - } - break; - } - currentIndex++; - } - } - } - break; + if (result.status === 'skipped') { + warnings.push(`Op ${op.type} skipped: ${result.reason ?? 'unknown'}`); } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Op ${op.type} failed: ${msg}`); + results.push({ + op, + status: 'skipped', + matched: 0, + reason: `error: ${msg}`, + }); } } - // Serialize the modified document.xml - const outXml = new XMLSerializer().serializeToString(dom); - - // Update ONLY word/document.xml in the ZIP - // All other files (styles, images, relationships, etc.) are preserved automatically - zip.file('word/document.xml', outXml); + // 5. After-snapshot + const after = captureSnapshot(body); - // Generate the output ZIP with all original files preserved - // Use same generation method as reference implementation - const outBuf = zip.generate({ type: 'nodebuffer' }); - - await fs.writeFile(outputPath, outBuf); -} - -/** - * Replace body XML in existing DOCX file - * This function copies the DOCX to a temp location, extracts document.xml to temp, - * updates the body, generates the DOCX, and cleans up - * This approach avoids "max compaction per block" errors - */ -export async function replaceBodyXml( - inputPath: string, - outputPath: string, - newBodyXml: string -): Promise { - // Create temp file paths - const tempDir = os.tmpdir(); - const tempDocxName = `docx_temp_${Date.now()}_${Math.random().toString(36).substring(7)}.docx`; - const tempDocxPath = path.join(tempDir, tempDocxName); - const tempXmlName = `docx_dom_${Date.now()}_${Math.random().toString(36).substring(7)}.xml`; - const tempXmlPath = path.join(tempDir, tempXmlName); + // 6. Validate - throws if structural invariants are broken + validateInvariants(before, after); - try { - // Step 1: Copy DOCX to temp location - const inputBuf = await fs.readFile(inputPath); - await fs.writeFile(tempDocxPath, inputBuf); + // 7. Serialize and save + const newXml = serializeXml(doc); + await saveDocxZip(zip, newXml, outputPath); - // Step 2: Load temp DOCX - const zip = new PizZip(inputBuf); + // 8. Build stats + const stats: WriteDocxStats = { + tablesBefore: before.tableCount, + tablesAfter: after.tableCount, + bodyChildrenBefore: before.bodyChildCount, + bodyChildrenAfter: after.bodyChildCount, + bodySignatureBefore: before.signature, + bodySignatureAfter: after.signature, + }; - // Step 3: Extract document.xml to temp file - const docFile = zip.file('word/document.xml'); - if (!docFile) { - throw new Error('Invalid DOCX: missing word/document.xml'); - } - - const xmlStr = docFile.asText(); - await fs.writeFile(tempXmlPath, xmlStr); - - // Step 4: Parse the temp XML file - const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); - - // Step 5: Locate body and replace with new body XML - const body = dom.getElementsByTagName('w:body').item(0); - if (!body) { - throw new Error('Invalid DOCX: missing w:body'); - } - - // Parse the new body XML - const newBodyDom = new DOMParser().parseFromString( - `${newBodyXml}`, - 'application/xml' - ); - const newBodyElement = newBodyDom.documentElement.firstChild as Element; - - if (!newBodyElement || newBodyElement.nodeName !== 'w:body') { - throw new Error('Invalid body XML: must start with '); - } - - // Replace the body: clear existing children and append new ones - // We need to import the new body's children into the original document - const doc = body.ownerDocument; - if (!doc) { - throw new Error('Document owner not found'); - } - - // Remove all existing children from body - while (body.firstChild) { - body.removeChild(body.firstChild); - } - - // Import and append all children from new body - for (const child of nodeListToArray(newBodyElement.childNodes)) { - const importedChild = doc.importNode(child, true); - body.appendChild(importedChild); - } - - // Step 6: Serialize the updated DOM - const outXml = new XMLSerializer().serializeToString(dom); - - // Step 7: Update document.xml in ZIP and generate output - zip.file('word/document.xml', outXml); - const outBuf = zip.generate({ type: 'nodebuffer' }); - await fs.writeFile(outputPath, outBuf); - } finally { - // Step 8: Clean up temp files - try { - await fs.unlink(tempDocxPath); - } catch { - // Ignore errors when cleaning up temp DOCX file - } - try { - await fs.unlink(tempXmlPath); - } catch { - // Ignore errors when cleaning up temp XML file - } - } -} - -/** - * Create a new DOCX file from text content - * Note: This creates a minimal DOCX structure - */ -export async function writeDocx( - outputPath: string, - content: string | DocxModification[] -): Promise { - if (typeof content === 'string') { - // Create minimal DOCX from text - const zip = new PizZip(); - - // Create minimal document.xml - const docXml = ` - - - ${content.split('\n\n').map(para => { - const escaped = para - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - return ` - - ${escaped} - - `; - }).join('\n')} - -`; - - zip.file('word/document.xml', docXml); - - // Create minimal [Content_Types].xml - const contentTypes = ` - - - - -`; - zip.file('[Content_Types].xml', contentTypes); - - // Create minimal _rels/.rels - const rels = ` - - -`; - zip.folder('_rels')?.file('.rels', rels); - - // Create minimal word/_rels/document.xml.rels - zip.folder('word')?.folder('_rels')?.file('document.xml.rels', ` - -`); - - const outBuf = zip.generate({ type: 'nodebuffer' }); - await fs.writeFile(outputPath, outBuf); - } else { - // Modifications require an existing file - throw new Error('Modifications require an existing DOCX file. Use modifyDocxContent() instead.'); - } -} + return { outputPath, results, stats, warnings }; +} \ No newline at end of file diff --git a/src/tools/docx/zip.ts b/src/tools/docx/zip.ts new file mode 100644 index 00000000..545eff44 --- /dev/null +++ b/src/tools/docx/zip.ts @@ -0,0 +1,44 @@ +/** + * DOCX ZIP I/O — Single Responsibility: file ↔ zip ↔ xml. + * + * Every other module depends on these three functions for disk I/O; + * none of them touch the file system directly. + */ + +import fs from 'fs/promises'; +import PizZip from 'pizzip'; + +/** + * Read a .docx file from disk and return a PizZip instance. + */ +export async function loadDocxZip(filePath: string): Promise { + const buf = await fs.readFile(filePath); + return new PizZip(buf); +} + +/** + * Extract the raw XML string from word/document.xml inside the zip. + * Throws if the entry is missing. + */ +export function getDocumentXml(zip: PizZip): string { + const entry = zip.file('word/document.xml'); + if (!entry) { + throw new Error('Invalid DOCX: missing word/document.xml'); + } + return entry.asText(); +} + +/** + * Replace word/document.xml in the zip with new XML, + * then write the whole archive to outputPath. + */ +export async function saveDocxZip( + zip: PizZip, + newDocumentXml: string, + outputPath: string, +): Promise { + zip.file('word/document.xml', newDocumentXml); + const buf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, buf); +} + diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index b3b7e525..9bf2ee48 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -103,66 +103,47 @@ export const WritePdfArgsSchema = z.object({ options: z.object({}).passthrough().optional(), // Allow passing options to md-to-pdf }); -// DOCX modification schemas - exported for reuse -export const DocxReplaceOperationSchema = z.object({ - type: z.literal('replace'), - findText: z.string(), - replaceText: z.string().optional(), - style: z.object({ - color: z.string().optional(), - bold: z.boolean().optional(), - italic: z.boolean().optional(), - }).optional(), +// ─── DOCX patch-based schemas (read_docx + write_docx tools) ───────── + +export const ReadDocxArgsSchema = z.object({ + path: z.string(), }); -export const DocxInsertOperationSchema = z.object({ - type: z.literal('insert'), - paragraphIndex: z.number(), - insertText: z.string(), +const ReplaceParagraphTextExactOpSchema = z.object({ + type: z.literal('replace_paragraph_text_exact'), + from: z.string(), + to: z.string(), }); -export const DocxDeleteOperationSchema = z.object({ - type: z.literal('delete'), - paragraphIndex: z.number(), +const ReplaceParagraphAtBodyIndexOpSchema = z.object({ + type: z.literal('replace_paragraph_at_body_index'), + bodyChildIndex: z.number(), + to: z.string(), +}); + +const SetColorForStyleOpSchema = z.object({ + type: z.literal('set_color_for_style'), + style: z.string(), + color: z.string(), }); -export const DocxStyleOperationSchema = z.object({ - type: z.literal('style'), - paragraphIndex: z.number(), - style: z.object({ - color: z.string().optional(), - bold: z.boolean().optional(), - italic: z.boolean().optional(), - }), +const SetColorForParagraphExactOpSchema = z.object({ + type: z.literal('set_color_for_paragraph_exact'), + text: z.string(), + color: z.string(), }); -export const DocxOperationSchema = z.union([ - DocxReplaceOperationSchema, - DocxInsertOperationSchema, - DocxDeleteOperationSchema, - DocxStyleOperationSchema, +const DocxOpSchema = z.discriminatedUnion('type', [ + ReplaceParagraphTextExactOpSchema, + ReplaceParagraphAtBodyIndexOpSchema, + SetColorForStyleOpSchema, + SetColorForParagraphExactOpSchema, ]); export const WriteDocxArgsSchema = z.object({ - path: z.string(), - // Preprocess content to handle JSON strings that should be parsed as arrays - content: z.preprocess( - (val) => { - // If it's a string that looks like JSON array, parse it - if (typeof val === 'string' && val.trim().startsWith('[')) { - try { - return JSON.parse(val); - } catch { - // If parsing fails, return as-is (might be text content) - return val; - } - } - // Otherwise return as-is - return val; - }, - z.union([z.string(), z.array(DocxOperationSchema)]) - ), - outputPath: z.string().optional(), + inputPath: z.string(), + outputPath: z.string(), + ops: z.array(DocxOpSchema), }); export const CreateDirectoryArgsSchema = z.object({ From 52cb92662f901d57bea233cdb92979e3da69cbaa Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:21:01 +0100 Subject: [PATCH 3/9] feat: add operations for paragraph editing --- src/server.ts | 22 ++++- .../ops/delete-paragraph-at-body-index.ts | 33 +++++++ .../docx/ops/header-replace-text-exact.ts | 73 +++++++++++++++ src/tools/docx/ops/index.ts | 33 ++++++- .../docx/ops/insert-paragraph-after-text.ts | 63 +++++++++++++ src/tools/docx/ops/replace-hyperlink-url.ts | 52 +++++++++++ .../ops/set-paragraph-style-at-body-index.ts | 67 ++++++++++++++ src/tools/docx/ops/table-set-cell-text.ts | 89 +++++++++++++++++++ src/tools/docx/types.ts | 56 +++++++++++- src/tools/docx/validate.ts | 31 +++++-- src/tools/docx/write.ts | 32 ++++--- src/tools/schemas.ts | 44 +++++++++ 12 files changed, 572 insertions(+), 23 deletions(-) create mode 100644 src/tools/docx/ops/delete-paragraph-at-body-index.ts create mode 100644 src/tools/docx/ops/header-replace-text-exact.ts create mode 100644 src/tools/docx/ops/insert-paragraph-after-text.ts create mode 100644 src/tools/docx/ops/replace-hyperlink-url.ts create mode 100644 src/tools/docx/ops/set-paragraph-style-at-body-index.ts create mode 100644 src/tools/docx/ops/table-set-cell-text.ts diff --git a/src/server.ts b/src/server.ts index 78f44203..1ccd76cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -506,11 +506,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { 4. set_color_for_paragraph_exact — Apply run-level text colour to the first paragraph matching exact trimmed text. { "type": "set_color_for_paragraph_exact", "text": "Some Title", "color": "FF0000" } + 5. set_paragraph_style_at_body_index — Set/change paragraph style at a bodyChildIndex. Skips if not a w:p. + { "type": "set_paragraph_style_at_body_index", "bodyChildIndex": 5, "style": "Heading1" } + + 6. insert_paragraph_after_text — Insert a new paragraph after the first paragraph matching exact text. Optional style. + { "type": "insert_paragraph_after_text", "after": "Introduction", "text": "New paragraph text", "style": "Normal" } + + 7. delete_paragraph_at_body_index — Remove the paragraph at a bodyChildIndex. Skips if not a w:p. + { "type": "delete_paragraph_at_body_index", "bodyChildIndex": 14 } + + 8. table_set_cell_text — Set text in a specific table cell by tableIndex (0-based among tables), row, col. + { "type": "table_set_cell_text", "tableIndex": 0, "row": 1, "col": 2, "text": "Updated" } + + 9. replace_hyperlink_url — Replace a hyperlink URL in the document relationships (.rels). + { "type": "replace_hyperlink_url", "oldUrl": "https://old.example.com", "newUrl": "https://new.example.com" } + + 10. header_replace_text_exact — Find and replace text in all document header XML files (header1.xml, header2.xml, …). + { "type": "header_replace_text_exact", "from": "Draft", "to": "Final" } + VALIDATION: After applying ops the tool validates that: - - w:body child count is unchanged + - w:body child count matches expected (accounting for inserts/deletes) - w:tbl count is unchanged - - body child sequence signature is unchanged (e.g. "p,tbl,p,p,sectPr") + - body child sequence signature is unchanged for non-structural ops If validation fails the output is NOT written and an error is returned. Only works within allowed directories. diff --git a/src/tools/docx/ops/delete-paragraph-at-body-index.ts b/src/tools/docx/ops/delete-paragraph-at-body-index.ts new file mode 100644 index 00000000..6a7197df --- /dev/null +++ b/src/tools/docx/ops/delete-paragraph-at-body-index.ts @@ -0,0 +1,33 @@ +/** + * Op: delete_paragraph_at_body_index + * + * Remove the w:p element at the given bodyChildIndex. + * Skips if the child is not a w:p. + * + * NOTE: This is a structural op — it decreases bodyChildCount by 1. + * The orchestrator must account for this when validating invariants. + */ + +import { getBodyChildren } from '../dom.js'; +import type { DeleteParagraphAtBodyIndexOp, OpResult } from '../types.js'; + +export function applyDeleteParagraphAtBodyIndex( + body: Element, + op: DeleteParagraphAtBodyIndexOp, +): OpResult { + const children = getBodyChildren(body); + const idx = op.bodyChildIndex; + + if (idx < 0 || idx >= children.length) { + return { op, status: 'skipped', matched: 0, reason: 'index_out_of_range' }; + } + + const child = children[idx]; + if (child.nodeName !== 'w:p') { + return { op, status: 'skipped', matched: 0, reason: 'not_a_paragraph' }; + } + + body.removeChild(child); + return { op, status: 'applied', matched: 1 }; +} + diff --git a/src/tools/docx/ops/header-replace-text-exact.ts b/src/tools/docx/ops/header-replace-text-exact.ts new file mode 100644 index 00000000..973798bf --- /dev/null +++ b/src/tools/docx/ops/header-replace-text-exact.ts @@ -0,0 +1,73 @@ +/** + * Op: header_replace_text_exact + * + * Find ALL header XML files (word/header1.xml, header2.xml, …) + * in the ZIP, locate the first paragraph matching exact trimmed text, + * and replace its text minimally. + * + * This op modifies header XML files inside the ZIP — not document.xml body. + * It receives the PizZip instance from the orchestrator. + */ + +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import { nodeListToArray, getParagraphText, setParagraphTextMinimal } from '../dom.js'; +import type { HeaderReplaceTextExactOp, OpResult } from '../types.js'; + +export function applyHeaderReplaceTextExact( + _body: Element, + op: HeaderReplaceTextExactOp, + zip: PizZip, +): OpResult { + const target = op.from.trim(); + let totalMatched = 0; + + // Iterate over all files in word/ looking for header*.xml + const files = zip.folder('word'); + if (!files) { + return { op, status: 'skipped', matched: 0, reason: 'no_word_folder' }; + } + + // PizZip file listing + const headerPattern = /^word\/header\d+\.xml$/; + const allFiles = Object.keys(zip.files); + const headerPaths = allFiles.filter((f) => headerPattern.test(f)); + + if (headerPaths.length === 0) { + return { op, status: 'skipped', matched: 0, reason: 'no_header_files' }; + } + + for (const headerPath of headerPaths) { + const entry = zip.file(headerPath); + if (!entry) continue; + + const xmlStr = entry.asText(); + const dom = new DOMParser().parseFromString(xmlStr, 'application/xml'); + + // Find all w:p elements in the header + const paragraphs = dom.getElementsByTagName('w:p'); + let modified = false; + + for (const p of nodeListToArray(paragraphs)) { + const pEl = p as Element; + if (getParagraphText(pEl).trim() === target) { + setParagraphTextMinimal(pEl, op.to); + totalMatched++; + modified = true; + break; // first match per header file + } + } + + if (modified) { + const newXml = new XMLSerializer().serializeToString(dom); + zip.file(headerPath, newXml); + } + } + + if (totalMatched === 0) { + return { op, status: 'skipped', matched: 0, reason: 'no_match_in_headers' }; + } + + return { op, status: 'applied', matched: totalMatched }; +} + diff --git a/src/tools/docx/ops/index.ts b/src/tools/docx/ops/index.ts index 01fcbb91..2feb06f0 100644 --- a/src/tools/docx/ops/index.ts +++ b/src/tools/docx/ops/index.ts @@ -5,14 +5,28 @@ * a new file + one extra case here; existing ops stay untouched. */ +import PizZip from 'pizzip'; import { applyReplaceParagraphTextExact } from './replace-paragraph-text-exact.js'; import { applyReplaceParagraphAtBodyIndex } from './replace-paragraph-at-body-index.js'; import { applySetColorForStyle } from './set-color-for-style.js'; import { applySetColorForParagraphExact } from './set-color-for-paragraph-exact.js'; +import { applySetParagraphStyleAtBodyIndex } from './set-paragraph-style-at-body-index.js'; +import { applyInsertParagraphAfterText } from './insert-paragraph-after-text.js'; +import { applyDeleteParagraphAtBodyIndex } from './delete-paragraph-at-body-index.js'; +import { applyTableSetCellText } from './table-set-cell-text.js'; +import { applyReplaceHyperlinkUrl } from './replace-hyperlink-url.js'; +import { applyHeaderReplaceTextExact } from './header-replace-text-exact.js'; import type { DocxOp, OpResult } from '../types.js'; -/** Apply a single operation to the w:body element. */ -export function applyOp(body: Element, op: DocxOp): OpResult { +/** + * Apply a single operation. + * + * @param body The w:body element (for DOM-based body ops) + * @param op The operation descriptor + * @param zip Optional PizZip instance — required for ops that modify + * files outside word/document.xml (e.g. hyperlinks, headers) + */ +export function applyOp(body: Element, op: DocxOp, zip?: PizZip): OpResult { switch (op.type) { case 'replace_paragraph_text_exact': return applyReplaceParagraphTextExact(body, op); @@ -22,6 +36,20 @@ export function applyOp(body: Element, op: DocxOp): OpResult { return applySetColorForStyle(body, op); case 'set_color_for_paragraph_exact': return applySetColorForParagraphExact(body, op); + case 'set_paragraph_style_at_body_index': + return applySetParagraphStyleAtBodyIndex(body, op); + case 'insert_paragraph_after_text': + return applyInsertParagraphAfterText(body, op); + case 'delete_paragraph_at_body_index': + return applyDeleteParagraphAtBodyIndex(body, op); + case 'table_set_cell_text': + return applyTableSetCellText(body, op); + case 'replace_hyperlink_url': + if (!zip) return { op, status: 'skipped', matched: 0, reason: 'zip_required_for_hyperlink_op' }; + return applyReplaceHyperlinkUrl(body, op, zip); + case 'header_replace_text_exact': + if (!zip) return { op, status: 'skipped', matched: 0, reason: 'zip_required_for_header_op' }; + return applyHeaderReplaceTextExact(body, op, zip); default: return { op, @@ -31,4 +59,3 @@ export function applyOp(body: Element, op: DocxOp): OpResult { }; } } - diff --git a/src/tools/docx/ops/insert-paragraph-after-text.ts b/src/tools/docx/ops/insert-paragraph-after-text.ts new file mode 100644 index 00000000..22d47d8d --- /dev/null +++ b/src/tools/docx/ops/insert-paragraph-after-text.ts @@ -0,0 +1,63 @@ +/** + * Op: insert_paragraph_after_text + * + * Find the FIRST paragraph whose trimmed text === `after`, then insert + * a new w:p immediately after it. Optionally applies a style to the + * new paragraph. + * + * NOTE: This is a structural op — it increases bodyChildCount by 1. + * The orchestrator must account for this when validating invariants. + */ + +import { getBodyChildren, getParagraphText, nodeListToArray } from '../dom.js'; +import type { InsertParagraphAfterTextOp, OpResult } from '../types.js'; + +export function applyInsertParagraphAfterText( + body: Element, + op: InsertParagraphAfterTextOp, +): OpResult { + const children = getBodyChildren(body); + const target = op.after.trim(); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeName !== 'w:p') continue; + + if (getParagraphText(child).trim() === target) { + const doc = body.ownerDocument; + if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; + + // Build new paragraph: text + const newP = doc.createElement('w:p'); + + // Optionally set style + if (op.style) { + const pPr = doc.createElement('w:pPr'); + const pStyle = doc.createElement('w:pStyle'); + pStyle.setAttribute('w:val', op.style); + pPr.appendChild(pStyle); + newP.appendChild(pPr); + } + + const newR = doc.createElement('w:r'); + const newT = doc.createElement('w:t'); + newT.setAttribute('xml:space', 'preserve'); + newT.textContent = op.text; + newR.appendChild(newT); + newP.appendChild(newR); + + // Insert after the matched child + const nextSibling = child.nextSibling; + if (nextSibling) { + body.insertBefore(newP, nextSibling); + } else { + body.appendChild(newP); + } + + return { op, status: 'applied', matched: 1 }; + } + } + + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; +} + diff --git a/src/tools/docx/ops/replace-hyperlink-url.ts b/src/tools/docx/ops/replace-hyperlink-url.ts new file mode 100644 index 00000000..4658d347 --- /dev/null +++ b/src/tools/docx/ops/replace-hyperlink-url.ts @@ -0,0 +1,52 @@ +/** + * Op: replace_hyperlink_url + * + * Find all hyperlink relationships in word/_rels/document.xml.rels + * whose Target matches `oldUrl` and replace with `newUrl`. + * + * This op modifies the .rels file inside the ZIP — not document.xml body. + * It receives the PizZip instance from the orchestrator. + */ + +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import { nodeListToArray } from '../dom.js'; +import type { ReplaceHyperlinkUrlOp, OpResult } from '../types.js'; + +export function applyReplaceHyperlinkUrl( + _body: Element, + op: ReplaceHyperlinkUrlOp, + zip: PizZip, +): OpResult { + const relsPath = 'word/_rels/document.xml.rels'; + const relsEntry = zip.file(relsPath); + + if (!relsEntry) { + return { op, status: 'skipped', matched: 0, reason: 'no_rels_file' }; + } + + const relsXml = relsEntry.asText(); + const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); + const relationships = relsDom.getElementsByTagName('Relationship'); + let matched = 0; + + for (const rel of nodeListToArray(relationships)) { + const relEl = rel as Element; + const target = relEl.getAttribute('Target'); + if (target === op.oldUrl) { + relEl.setAttribute('Target', op.newUrl); + matched++; + } + } + + if (matched === 0) { + return { op, status: 'skipped', matched: 0, reason: 'url_not_found' }; + } + + // Write modified .rels back to zip + const newRelsXml = new XMLSerializer().serializeToString(relsDom); + zip.file(relsPath, newRelsXml); + + return { op, status: 'applied', matched }; +} + diff --git a/src/tools/docx/ops/set-paragraph-style-at-body-index.ts b/src/tools/docx/ops/set-paragraph-style-at-body-index.ts new file mode 100644 index 00000000..9bb77ce5 --- /dev/null +++ b/src/tools/docx/ops/set-paragraph-style-at-body-index.ts @@ -0,0 +1,67 @@ +/** + * Op: set_paragraph_style_at_body_index + * + * Set (or replace) the paragraph style (w:pPr/w:pStyle) at a given + * bodyChildIndex. Skips if the child is not a w:p. + */ + +import { getBodyChildren, nodeListToArray } from '../dom.js'; +import type { SetParagraphStyleAtBodyIndexOp, OpResult } from '../types.js'; + +export function applySetParagraphStyleAtBodyIndex( + body: Element, + op: SetParagraphStyleAtBodyIndexOp, +): OpResult { + const children = getBodyChildren(body); + const idx = op.bodyChildIndex; + + if (idx < 0 || idx >= children.length) { + return { op, status: 'skipped', matched: 0, reason: 'index_out_of_range' }; + } + + const child = children[idx]; + if (child.nodeName !== 'w:p') { + return { op, status: 'skipped', matched: 0, reason: 'not_a_paragraph' }; + } + + const doc = child.ownerDocument; + if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; + + // Find or create w:pPr + let pPr: Element | null = null; + for (const n of nodeListToArray(child.childNodes)) { + if (n.nodeType === 1 && (n as Element).nodeName === 'w:pPr') { + pPr = n as Element; + break; + } + } + if (!pPr) { + pPr = doc.createElement('w:pPr'); + if (child.firstChild) { + child.insertBefore(pPr, child.firstChild); + } else { + child.appendChild(pPr); + } + } + + // Find or create w:pStyle inside pPr + let pStyle: Element | null = null; + for (const n of nodeListToArray(pPr.childNodes)) { + if (n.nodeType === 1 && (n as Element).nodeName === 'w:pStyle') { + pStyle = n as Element; + break; + } + } + if (!pStyle) { + pStyle = doc.createElement('w:pStyle'); + if (pPr.firstChild) { + pPr.insertBefore(pStyle, pPr.firstChild); + } else { + pPr.appendChild(pStyle); + } + } + + pStyle.setAttribute('w:val', op.style); + return { op, status: 'applied', matched: 1 }; +} + diff --git a/src/tools/docx/ops/table-set-cell-text.ts b/src/tools/docx/ops/table-set-cell-text.ts new file mode 100644 index 00000000..a73e911a --- /dev/null +++ b/src/tools/docx/ops/table-set-cell-text.ts @@ -0,0 +1,89 @@ +/** + * Op: table_set_cell_text + * + * Set the text content of a specific table cell. + * Targets by: tableIndex (0-based among w:tbl in body), row, col. + * Applies minimal text replacement inside the cell's first paragraph. + */ + +import { getBodyChildren, nodeListToArray, setParagraphTextMinimal } from '../dom.js'; +import type { TableSetCellTextOp, OpResult } from '../types.js'; + +export function applyTableSetCellText( + body: Element, + op: TableSetCellTextOp, +): OpResult { + const children = getBodyChildren(body); + + // Find the n-th w:tbl + let tableCount = 0; + let table: Element | null = null; + for (const child of children) { + if (child.nodeName === 'w:tbl') { + if (tableCount === op.tableIndex) { + table = child; + break; + } + tableCount++; + } + } + + if (!table) { + return { op, status: 'skipped', matched: 0, reason: 'table_not_found' }; + } + + // Find the n-th w:tr + const rows: Element[] = []; + for (const child of nodeListToArray(table.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:tr') { + rows.push(child as Element); + } + } + + if (op.row < 0 || op.row >= rows.length) { + return { op, status: 'skipped', matched: 0, reason: 'row_out_of_range' }; + } + + // Find the n-th w:tc in the row + const cells: Element[] = []; + for (const child of nodeListToArray(rows[op.row].childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:tc') { + cells.push(child as Element); + } + } + + if (op.col < 0 || op.col >= cells.length) { + return { op, status: 'skipped', matched: 0, reason: 'col_out_of_range' }; + } + + const cell = cells[op.col]; + + // Find first w:p inside the cell and apply minimal text replacement + for (const child of nodeListToArray(cell.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:p') { + const p = child as Element; + const tNodes = p.getElementsByTagName('w:t'); + + if (tNodes.length > 0) { + // Existing runs — use minimal replacement + setParagraphTextMinimal(p, op.text); + } else { + // Empty cell — create a run + const doc = cell.ownerDocument; + if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; + + const r = doc.createElement('w:r'); + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = op.text; + r.appendChild(t); + p.appendChild(r); + } + + return { op, status: 'applied', matched: 1 }; + } + } + + return { op, status: 'skipped', matched: 0, reason: 'no_paragraph_in_cell' }; +} + diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts index 448f3583..9e5f4725 100644 --- a/src/tools/docx/types.ts +++ b/src/tools/docx/types.ts @@ -101,7 +101,7 @@ export interface BodySnapshot { } // ═══════════════════════════════════════════════════════════════════════ -// Patch operations +// Patch operations — original 4 // ═══════════════════════════════════════════════════════════════════════ export interface ReplaceParagraphTextExactOp { @@ -128,11 +128,63 @@ export interface SetColorForParagraphExactOp { color: string; } +// ═══════════════════════════════════════════════════════════════════════ +// Patch operations — new 6 +// ═══════════════════════════════════════════════════════════════════════ + +export interface SetParagraphStyleAtBodyIndexOp { + type: 'set_paragraph_style_at_body_index'; + bodyChildIndex: number; + style: string; +} + +export interface InsertParagraphAfterTextOp { + type: 'insert_paragraph_after_text'; + after: string; + text: string; + style?: string; +} + +export interface DeleteParagraphAtBodyIndexOp { + type: 'delete_paragraph_at_body_index'; + bodyChildIndex: number; +} + +export interface TableSetCellTextOp { + type: 'table_set_cell_text'; + tableIndex: number; + row: number; + col: number; + text: string; +} + +export interface ReplaceHyperlinkUrlOp { + type: 'replace_hyperlink_url'; + oldUrl: string; + newUrl: string; +} + +export interface HeaderReplaceTextExactOp { + type: 'header_replace_text_exact'; + from: string; + to: string; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Discriminated union + result +// ═══════════════════════════════════════════════════════════════════════ + export type DocxOp = | ReplaceParagraphTextExactOp | ReplaceParagraphAtBodyIndexOp | SetColorForStyleOp - | SetColorForParagraphExactOp; + | SetColorForParagraphExactOp + | SetParagraphStyleAtBodyIndexOp + | InsertParagraphAfterTextOp + | DeleteParagraphAtBodyIndexOp + | TableSetCellTextOp + | ReplaceHyperlinkUrlOp + | HeaderReplaceTextExactOp; export interface OpResult { op: DocxOp; diff --git a/src/tools/docx/validate.ts b/src/tools/docx/validate.ts index b9dc3502..bcdbb614 100644 --- a/src/tools/docx/validate.ts +++ b/src/tools/docx/validate.ts @@ -8,7 +8,18 @@ import { getBodyChildren, bodySignature, countTables } from './dom.js'; import type { BodySnapshot } from './types.js'; -// ─── Capture ──────────────────────────────────────────────────────── +// ─── Options ───────────────────────────────────────────────────────── + +export interface ValidationOptions { + /** + * Expected change in w:body direct child count. + * Positive = inserts, negative = deletes. + * Default 0 (no structural changes expected). + */ + expectedChildDelta?: number; +} + +// ─── Capture ───────────────────────────────────────────────────────── /** Take a snapshot of the body's structural invariants. */ export function captureSnapshot(body: Element): BodySnapshot { @@ -20,22 +31,30 @@ export function captureSnapshot(body: Element): BodySnapshot { }; } -// ─── Validate ─────────────────────────────────────────────────────── +// ─── Validate ──────────────────────────────────────────────────────── /** * Compare before / after snapshots. * Throws a descriptive error if any invariant has been violated, * preventing the output file from being written. + * + * When `expectedChildDelta` is non-zero (structural ops like insert or + * delete), signature validation is skipped because the body structure + * is *expected* to change. Child count is still validated against + * the expected delta, and table count must remain unchanged. */ export function validateInvariants( before: BodySnapshot, after: BodySnapshot, + options?: ValidationOptions, ): void { + const delta = options?.expectedChildDelta ?? 0; + const expectedChildCount = before.bodyChildCount + delta; const errors: string[] = []; - if (before.bodyChildCount !== after.bodyChildCount) { + if (expectedChildCount !== after.bodyChildCount) { errors.push( - `Body child count changed: ${before.bodyChildCount} → ${after.bodyChildCount}`, + `Body child count mismatch: expected ${expectedChildCount} (before ${before.bodyChildCount} + delta ${delta}), got ${after.bodyChildCount}`, ); } @@ -45,7 +64,8 @@ export function validateInvariants( ); } - if (before.signature !== after.signature) { + // Only enforce signature stability when no structural ops changed the body + if (delta === 0 && before.signature !== after.signature) { errors.push( `Body signature changed:\n before: ${before.signature}\n after: ${after.signature}`, ); @@ -58,4 +78,3 @@ export function validateInvariants( ); } } - diff --git a/src/tools/docx/write.ts b/src/tools/docx/write.ts index 47da1db6..f4c229db 100644 --- a/src/tools/docx/write.ts +++ b/src/tools/docx/write.ts @@ -5,9 +5,9 @@ * 1. Open DOCX ZIP * 2. Parse word/document.xml * 3. Snapshot before - * 4. Apply operations + * 4. Apply operations (pass zip for ops that touch auxiliary files) * 5. Snapshot after - * 6. Validate invariants (throws -> output NOT written) + * 6. Validate invariants (accounting for structural deltas) * 7. Serialize and save * * Each step delegates to a single-purpose module, keeping this file @@ -20,6 +20,10 @@ import { captureSnapshot, validateInvariants } from './validate.js'; import { applyOp } from './ops/index.js'; import type { DocxOp, OpResult, WriteDocxStats, WriteDocxResult } from './types.js'; +/** Structural op types that add/remove body children. */ +const STRUCTURAL_INSERT_OPS = new Set(['insert_paragraph_after_text']); +const STRUCTURAL_DELETE_OPS = new Set(['delete_paragraph_at_body_index']); + export async function writeDocxPatched( inputPath: string, outputPath: string, @@ -36,13 +40,13 @@ export async function writeDocxPatched( // 3. Before-snapshot const before = captureSnapshot(body); - // 4. Apply ops + // 4. Apply ops — pass zip for ops that modify auxiliary files const results: OpResult[] = []; const warnings: string[] = []; for (const op of ops) { try { - const result = applyOp(body, op); + const result = applyOp(body, op, zip); results.push(result); if (result.status === 'skipped') { @@ -60,17 +64,25 @@ export async function writeDocxPatched( } } - // 5. After-snapshot + // 5. Compute expected structural delta from applied ops + let expectedChildDelta = 0; + for (const r of results) { + if (r.status !== 'applied') continue; + if (STRUCTURAL_INSERT_OPS.has(r.op.type)) expectedChildDelta += 1; + if (STRUCTURAL_DELETE_OPS.has(r.op.type)) expectedChildDelta -= 1; + } + + // 6. After-snapshot const after = captureSnapshot(body); - // 6. Validate - throws if structural invariants are broken - validateInvariants(before, after); + // 7. Validate — throws if structural invariants are broken + validateInvariants(before, after, { expectedChildDelta }); - // 7. Serialize and save + // 8. Serialize and save (document.xml + any zip-level changes) const newXml = serializeXml(doc); await saveDocxZip(zip, newXml, outputPath); - // 8. Build stats + // 9. Build stats const stats: WriteDocxStats = { tablesBefore: before.tableCount, tablesAfter: after.tableCount, @@ -81,4 +93,4 @@ export async function writeDocxPatched( }; return { outputPath, results, stats, warnings }; -} \ No newline at end of file +} diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 9bf2ee48..268d74af 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -133,11 +133,55 @@ const SetColorForParagraphExactOpSchema = z.object({ color: z.string(), }); +const SetParagraphStyleAtBodyIndexOpSchema = z.object({ + type: z.literal('set_paragraph_style_at_body_index'), + bodyChildIndex: z.number(), + style: z.string(), +}); + +const InsertParagraphAfterTextOpSchema = z.object({ + type: z.literal('insert_paragraph_after_text'), + after: z.string(), + text: z.string(), + style: z.string().optional(), +}); + +const DeleteParagraphAtBodyIndexOpSchema = z.object({ + type: z.literal('delete_paragraph_at_body_index'), + bodyChildIndex: z.number(), +}); + +const TableSetCellTextOpSchema = z.object({ + type: z.literal('table_set_cell_text'), + tableIndex: z.number(), + row: z.number(), + col: z.number(), + text: z.string(), +}); + +const ReplaceHyperlinkUrlOpSchema = z.object({ + type: z.literal('replace_hyperlink_url'), + oldUrl: z.string(), + newUrl: z.string(), +}); + +const HeaderReplaceTextExactOpSchema = z.object({ + type: z.literal('header_replace_text_exact'), + from: z.string(), + to: z.string(), +}); + const DocxOpSchema = z.discriminatedUnion('type', [ ReplaceParagraphTextExactOpSchema, ReplaceParagraphAtBodyIndexOpSchema, SetColorForStyleOpSchema, SetColorForParagraphExactOpSchema, + SetParagraphStyleAtBodyIndexOpSchema, + InsertParagraphAfterTextOpSchema, + DeleteParagraphAtBodyIndexOpSchema, + TableSetCellTextOpSchema, + ReplaceHyperlinkUrlOpSchema, + HeaderReplaceTextExactOpSchema, ]); export const WriteDocxArgsSchema = z.object({ From 7754b02ce3a817c3638c62de3da8500b4fb17d51 Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:22:19 +0100 Subject: [PATCH 4/9] feat: add operations for image and table insert --- src/server.ts | 14 +- src/tools/docx/ops/index.ts | 7 + src/tools/docx/ops/insert-image-after-text.ts | 273 ++++++++++++++++++ src/tools/docx/ops/insert-table-after-text.ts | 194 +++++++++++++ src/tools/docx/types.ts | 36 ++- src/tools/docx/validate.ts | 12 +- src/tools/docx/write.ts | 10 +- src/tools/schemas.ts | 22 ++ 8 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 src/tools/docx/ops/insert-image-after-text.ts create mode 100644 src/tools/docx/ops/insert-table-after-text.ts diff --git a/src/server.ts b/src/server.ts index 1ccd76cf..d2c5b46e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -524,10 +524,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { 10. header_replace_text_exact — Find and replace text in all document header XML files (header1.xml, header2.xml, …). { "type": "header_replace_text_exact", "from": "Draft", "to": "Final" } + 11. insert_table — Insert a new table before or after the first paragraph matching exact text. + Provide exactly ONE of "after" or "before" to set position. + Accepts optional headers (bold row), rows (array of string arrays), colWidths (twips), and style. + After: { "type": "insert_table", "after": "Sales Data", "headers": ["Product", "Q1", "Q2"], "rows": [["Widget", "100", "150"]] } + Before: { "type": "insert_table", "before": "Summary", "headers": ["Metric", "Value"], "rows": [["Revenue", "$1M"]] } + + 12. insert_image — Insert an image from disk before or after the first paragraph matching exact text. + Provide exactly ONE of "after" or "before" to set position. + Reads the image file, embeds it in the DOCX, and creates a proper drawing reference. + After: { "type": "insert_image", "after": "Company Logo", "imagePath": "/path/to/logo.png", "width": 400, "height": 200, "altText": "Logo" } + Before: { "type": "insert_image", "before": "Appendix", "imagePath": "/path/to/chart.png", "width": 600, "height": 400 } + VALIDATION: After applying ops the tool validates that: - w:body child count matches expected (accounting for inserts/deletes) - - w:tbl count is unchanged + - w:tbl count matches expected (accounting for table inserts) - body child sequence signature is unchanged for non-structural ops If validation fails the output is NOT written and an error is returned. diff --git a/src/tools/docx/ops/index.ts b/src/tools/docx/ops/index.ts index 2feb06f0..4b9ba30f 100644 --- a/src/tools/docx/ops/index.ts +++ b/src/tools/docx/ops/index.ts @@ -16,6 +16,8 @@ import { applyDeleteParagraphAtBodyIndex } from './delete-paragraph-at-body-inde import { applyTableSetCellText } from './table-set-cell-text.js'; import { applyReplaceHyperlinkUrl } from './replace-hyperlink-url.js'; import { applyHeaderReplaceTextExact } from './header-replace-text-exact.js'; +import { applyInsertTable } from './insert-table-after-text.js'; +import { applyInsertImage } from './insert-image-after-text.js'; import type { DocxOp, OpResult } from '../types.js'; /** @@ -50,6 +52,11 @@ export function applyOp(body: Element, op: DocxOp, zip?: PizZip): OpResult { case 'header_replace_text_exact': if (!zip) return { op, status: 'skipped', matched: 0, reason: 'zip_required_for_header_op' }; return applyHeaderReplaceTextExact(body, op, zip); + case 'insert_table': + return applyInsertTable(body, op); + case 'insert_image': + if (!zip) return { op, status: 'skipped', matched: 0, reason: 'zip_required_for_image_op' }; + return applyInsertImage(body, op, zip); default: return { op, diff --git a/src/tools/docx/ops/insert-image-after-text.ts b/src/tools/docx/ops/insert-image-after-text.ts new file mode 100644 index 00000000..8c966d23 --- /dev/null +++ b/src/tools/docx/ops/insert-image-after-text.ts @@ -0,0 +1,273 @@ +/** + * Op: insert_image + * + * Insert an image into the DOCX relative to a paragraph anchor. + * + * Supports two positioning modes: + * - `after` — insert immediately AFTER the first paragraph matching text + * - `before` — insert immediately BEFORE the first paragraph matching text + * + * Exactly one of `after` or `before` must be provided. + * + * The image is read from disk, added to word/media/ in the ZIP, + * and a w:drawing reference is created inside a new paragraph. + * + * Requires the PizZip instance because it modifies: + * - word/media/imageN.ext (binary blob) + * - word/_rels/document.xml.rels (relationship entry) + * - [Content_Types].xml (content-type override) + * + * This is a structural op — increases bodyChildCount by 1. + */ + +import fs from 'fs'; +import path from 'path'; +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import { getBodyChildren, getParagraphText, nodeListToArray } from '../dom.js'; +import type { InsertImageOp, OpResult } from '../types.js'; + +// ─── MIME / extension helpers ──────────────────────────────────────── + +const MIME_MAP: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +}; + +function getMimeType(ext: string): string { + return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream'; +} + +// ─── EMU conversion (English Metric Units) ────────────────────────── +// 1 inch = 914400 EMU, 1 px ≈ 9525 EMU (at 96 DPI) +const PX_TO_EMU = 9525; + +// ─── Relationship helpers ──────────────────────────────────────────── + +/** + * Add a relationship to word/_rels/document.xml.rels and return the rId. + */ +function addImageRelationship(zip: PizZip, mediaFileName: string): string { + const relsPath = 'word/_rels/document.xml.rels'; + let relsEntry = zip.file(relsPath); + + // Create .rels file if it doesn't exist + if (!relsEntry) { + const emptyRels = + '' + + ''; + zip.file(relsPath, emptyRels); + relsEntry = zip.file(relsPath)!; + } + + const relsXml = relsEntry.asText(); + const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); + const relationships = relsDom.getElementsByTagName('Relationship'); + + // Find max existing rId + let maxId = 0; + for (const rel of nodeListToArray(relationships)) { + const id = (rel as Element).getAttribute('Id') || ''; + const match = id.match(/^rId(\d+)$/); + if (match) { + maxId = Math.max(maxId, parseInt(match[1], 10)); + } + } + + const newRId = `rId${maxId + 1}`; + + // Create new Relationship element + const newRel = relsDom.createElement('Relationship'); + newRel.setAttribute('Id', newRId); + newRel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); + newRel.setAttribute('Target', `media/${mediaFileName}`); + + relsDom.documentElement.appendChild(newRel); + + // Write back + const newRelsXml = new XMLSerializer().serializeToString(relsDom); + zip.file(relsPath, newRelsXml); + + return newRId; +} + +/** + * Ensure the Content_Types.xml has a Default entry for the image extension. + */ +function ensureContentType(zip: PizZip, ext: string): void { + const ctPath = '[Content_Types].xml'; + const ctEntry = zip.file(ctPath); + if (!ctEntry) return; + + const ctXml = ctEntry.asText(); + const extNoDot = ext.replace(/^\./, ''); + + // Check if already present + if (ctXml.includes(`Extension="${extNoDot}"`)) return; + + const ctDom = new DOMParser().parseFromString(ctXml, 'application/xml'); + const types = ctDom.documentElement; + + const defaultEl = ctDom.createElement('Default'); + defaultEl.setAttribute('Extension', extNoDot); + defaultEl.setAttribute('ContentType', getMimeType(ext)); + types.appendChild(defaultEl); + + const newCtXml = new XMLSerializer().serializeToString(ctDom); + zip.file(ctPath, newCtXml); +} + +// ─── Drawing XML builder ───────────────────────────────────────────── + +function escapeXmlAttr(s: string): string { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +/** + * Build the inline w:drawing XML for an image reference. + */ +function buildDrawingXml( + rId: string, + widthEmu: number, + heightEmu: number, + altText: string, + fileName: string, +): string { + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); +} + +// ─── Op implementation ─────────────────────────────────────────────── + +export function applyInsertImage( + body: Element, + op: InsertImageOp, + zip: PizZip, +): OpResult { + // ── Validate anchor ───────────────────────────────────────────── + const anchorText = op.before ?? op.after; + const position: 'before' | 'after' = op.before ? 'before' : 'after'; + + if (!anchorText) { + return { op, status: 'skipped', matched: 0, reason: 'no_anchor: provide "after" or "before"' }; + } + + // ── Validate image file exists ────────────────────────────────── + const imgPath = op.imagePath; + if (!fs.existsSync(imgPath)) { + return { op, status: 'skipped', matched: 0, reason: `image_not_found: ${imgPath}` }; + } + + // ── Find target paragraph ─────────────────────────────────────── + const children = getBodyChildren(body); + const target = anchorText.trim(); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeName !== 'w:p') continue; + + if (getParagraphText(child).trim() === target) { + const doc = body.ownerDocument; + if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; + + // ── Read image ────────────────────────────────────────── + const imgBuffer = fs.readFileSync(imgPath); + const ext = path.extname(imgPath).toLowerCase(); + const baseName = path.basename(imgPath); + + // ── Find next available media filename ────────────────── + let mediaIndex = 1; + while (zip.file(`word/media/image${mediaIndex}${ext}`)) { + mediaIndex++; + } + const mediaFileName = `image${mediaIndex}${ext}`; + + // ── Add image to ZIP ──────────────────────────────────── + zip.file(`word/media/${mediaFileName}`, imgBuffer); + + // ── Add relationship ──────────────────────────────────── + const rId = addImageRelationship(zip, mediaFileName); + + // ── Ensure Content_Types entry ────────────────────────── + ensureContentType(zip, ext); + + // ── Compute dimensions (EMU) ──────────────────────────── + const widthPx = op.width ?? 300; + const heightPx = op.height ?? 200; + const widthEmu = widthPx * PX_TO_EMU; + const heightEmu = heightPx * PX_TO_EMU; + + // ── Build drawing XML and parse it ────────────────────── + const drawingXmlStr = buildDrawingXml( + rId, + widthEmu, + heightEmu, + op.altText ?? baseName, + mediaFileName, + ); + + // Parse the drawing XML fragment into a w:p element + const drawingFragment = new DOMParser().parseFromString( + `` + + `${drawingXmlStr}`, + 'application/xml', + ); + + const newP = doc.importNode(drawingFragment.documentElement, true); + + // ── Insert at the correct position ────────────────────── + if (position === 'before') { + // Insert BEFORE the matched paragraph + body.insertBefore(newP, child); + } else { + // Insert AFTER the matched paragraph + const nextSibling = child.nextSibling; + if (nextSibling) { + body.insertBefore(newP, nextSibling); + } else { + body.appendChild(newP); + } + } + + return { op, status: 'applied', matched: 1 }; + } + } + + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; +} diff --git a/src/tools/docx/ops/insert-table-after-text.ts b/src/tools/docx/ops/insert-table-after-text.ts new file mode 100644 index 00000000..113f2648 --- /dev/null +++ b/src/tools/docx/ops/insert-table-after-text.ts @@ -0,0 +1,194 @@ +/** + * Op: insert_table + * + * Insert a new w:tbl (table) relative to a paragraph anchor. + * + * Supports two positioning modes: + * - `after` — insert immediately AFTER the first paragraph matching text + * - `before` — insert immediately BEFORE the first paragraph matching text + * + * Exactly one of `after` or `before` must be provided. + * + * Accepts `headers` (optional) and `rows` as string arrays. + * Optionally accepts `colWidths` (array of numbers in twips). + * + * This is a structural op — increases bodyChildCount by 1. + * The orchestrator must account for this when validating invariants. + */ + +import { getBodyChildren, getParagraphText } from '../dom.js'; +import type { InsertTableOp, OpResult } from '../types.js'; + +// ─── XML builders (private) ────────────────────────────────────────── + +/** + * Build a single cell element. + * If `isHeader` is true, the run is bolded. + */ +function buildCell(doc: Document, text: string, isHeader: boolean, widthTwips?: number): Element { + const tc = doc.createElement('w:tc'); + + // Cell properties (width) + if (widthTwips) { + const tcPr = doc.createElement('w:tcPr'); + const tcW = doc.createElement('w:tcW'); + tcW.setAttribute('w:w', String(widthTwips)); + tcW.setAttribute('w:type', 'dxa'); + tcPr.appendChild(tcW); + tc.appendChild(tcPr); + } + + // Paragraph inside cell + const p = doc.createElement('w:p'); + const r = doc.createElement('w:r'); + + // Optional bold for header cells + if (isHeader) { + const rPr = doc.createElement('w:rPr'); + const b = doc.createElement('w:b'); + rPr.appendChild(b); + r.appendChild(rPr); + } + + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = text; + r.appendChild(t); + p.appendChild(r); + tc.appendChild(p); + + return tc; +} + +/** + * Build a row element. + */ +function buildRow( + doc: Document, + cells: string[], + isHeader: boolean, + colWidths?: number[], +): Element { + const tr = doc.createElement('w:tr'); + + for (let i = 0; i < cells.length; i++) { + const width = colWidths?.[i]; + tr.appendChild(buildCell(doc, cells[i], isHeader, width)); + } + + return tr; +} + +/** + * Build a complete element from the op specification. + */ +function buildTable(doc: Document, op: InsertTableOp): Element { + const tbl = doc.createElement('w:tbl'); + + // Table properties — bordered by default + const tblPr = doc.createElement('w:tblPr'); + + // Table style + if (op.style) { + const tblStyle = doc.createElement('w:tblStyle'); + tblStyle.setAttribute('w:val', op.style); + tblPr.appendChild(tblStyle); + } + + // Table width (auto by default) + const tblW = doc.createElement('w:tblW'); + tblW.setAttribute('w:w', '0'); + tblW.setAttribute('w:type', 'auto'); + tblPr.appendChild(tblW); + + // Table borders (single-line, 4 pt, black) + const tblBorders = doc.createElement('w:tblBorders'); + for (const side of ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] as const) { + const border = doc.createElement(`w:${side}`); + border.setAttribute('w:val', 'single'); + border.setAttribute('w:sz', '4'); + border.setAttribute('w:space', '0'); + border.setAttribute('w:color', '000000'); + tblBorders.appendChild(border); + } + tblPr.appendChild(tblBorders); + + tbl.appendChild(tblPr); + + // Table grid (column definitions) + const colCount = op.headers + ? op.headers.length + : op.rows.length > 0 + ? op.rows[0].length + : 0; + + if (colCount > 0) { + const tblGrid = doc.createElement('w:tblGrid'); + for (let c = 0; c < colCount; c++) { + const gridCol = doc.createElement('w:gridCol'); + const w = op.colWidths?.[c] ?? Math.floor(9000 / colCount); + gridCol.setAttribute('w:w', String(w)); + tblGrid.appendChild(gridCol); + } + tbl.appendChild(tblGrid); + } + + // Header row (optional) + if (op.headers && op.headers.length > 0) { + tbl.appendChild(buildRow(doc, op.headers, true, op.colWidths)); + } + + // Data rows + for (const row of op.rows) { + tbl.appendChild(buildRow(doc, row, false, op.colWidths)); + } + + return tbl; +} + +// ─── Op implementation ─────────────────────────────────────────────── + +export function applyInsertTable( + body: Element, + op: InsertTableOp, +): OpResult { + // Determine anchor text and position + const anchorText = op.before ?? op.after; + const position: 'before' | 'after' = op.before ? 'before' : 'after'; + + if (!anchorText) { + return { op, status: 'skipped', matched: 0, reason: 'no_anchor: provide "after" or "before"' }; + } + + const children = getBodyChildren(body); + const target = anchorText.trim(); + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeName !== 'w:p') continue; + + if (getParagraphText(child).trim() === target) { + const doc = body.ownerDocument; + if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; + + const tbl = buildTable(doc, op); + + if (position === 'before') { + // Insert BEFORE the matched paragraph + body.insertBefore(tbl, child); + } else { + // Insert AFTER the matched paragraph + const nextSibling = child.nextSibling; + if (nextSibling) { + body.insertBefore(tbl, nextSibling); + } else { + body.appendChild(tbl); + } + } + + return { op, status: 'applied', matched: 1 }; + } + } + + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; +} diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts index 9e5f4725..996a9e0b 100644 --- a/src/tools/docx/types.ts +++ b/src/tools/docx/types.ts @@ -170,6 +170,38 @@ export interface HeaderReplaceTextExactOp { to: string; } +export interface InsertTableOp { + type: 'insert_table'; + /** Exact trimmed text of the paragraph to insert AFTER. Mutually exclusive with `before`. */ + after?: string; + /** Exact trimmed text of the paragraph to insert BEFORE. Mutually exclusive with `after`. */ + before?: string; + /** Optional header row (bold cells) */ + headers?: string[]; + /** Data rows — each row is an array of cell strings */ + rows: string[][]; + /** Optional column widths in twips (1/20 pt). Defaults to auto. */ + colWidths?: number[]; + /** Optional table style id (e.g. 'TableGrid') */ + style?: string; +} + +export interface InsertImageOp { + type: 'insert_image'; + /** Exact trimmed text of the paragraph to insert AFTER. Mutually exclusive with `before`. */ + after?: string; + /** Exact trimmed text of the paragraph to insert BEFORE. Mutually exclusive with `after`. */ + before?: string; + /** Absolute or relative path to the image file */ + imagePath: string; + /** Image width in pixels (default 300) */ + width?: number; + /** Image height in pixels (default 200) */ + height?: number; + /** Alt text for accessibility */ + altText?: string; +} + // ═══════════════════════════════════════════════════════════════════════ // Discriminated union + result // ═══════════════════════════════════════════════════════════════════════ @@ -184,7 +216,9 @@ export type DocxOp = | DeleteParagraphAtBodyIndexOp | TableSetCellTextOp | ReplaceHyperlinkUrlOp - | HeaderReplaceTextExactOp; + | HeaderReplaceTextExactOp + | InsertTableOp + | InsertImageOp; export interface OpResult { op: DocxOp; diff --git a/src/tools/docx/validate.ts b/src/tools/docx/validate.ts index bcdbb614..28d02ff1 100644 --- a/src/tools/docx/validate.ts +++ b/src/tools/docx/validate.ts @@ -17,6 +17,12 @@ export interface ValidationOptions { * Default 0 (no structural changes expected). */ expectedChildDelta?: number; + + /** + * Expected change in w:tbl count. + * Default 0 (no table additions/removals expected). + */ + expectedTableDelta?: number; } // ─── Capture ───────────────────────────────────────────────────────── @@ -49,7 +55,9 @@ export function validateInvariants( options?: ValidationOptions, ): void { const delta = options?.expectedChildDelta ?? 0; + const tableDelta = options?.expectedTableDelta ?? 0; const expectedChildCount = before.bodyChildCount + delta; + const expectedTableCount = before.tableCount + tableDelta; const errors: string[] = []; if (expectedChildCount !== after.bodyChildCount) { @@ -58,9 +66,9 @@ export function validateInvariants( ); } - if (before.tableCount !== after.tableCount) { + if (expectedTableCount !== after.tableCount) { errors.push( - `Table count changed: ${before.tableCount} → ${after.tableCount}`, + `Table count mismatch: expected ${expectedTableCount} (before ${before.tableCount} + delta ${tableDelta}), got ${after.tableCount}`, ); } diff --git a/src/tools/docx/write.ts b/src/tools/docx/write.ts index f4c229db..622ad37b 100644 --- a/src/tools/docx/write.ts +++ b/src/tools/docx/write.ts @@ -21,7 +21,11 @@ import { applyOp } from './ops/index.js'; import type { DocxOp, OpResult, WriteDocxStats, WriteDocxResult } from './types.js'; /** Structural op types that add/remove body children. */ -const STRUCTURAL_INSERT_OPS = new Set(['insert_paragraph_after_text']); +const STRUCTURAL_INSERT_OPS = new Set([ + 'insert_paragraph_after_text', + 'insert_table', + 'insert_image', +]); const STRUCTURAL_DELETE_OPS = new Set(['delete_paragraph_at_body_index']); export async function writeDocxPatched( @@ -66,17 +70,19 @@ export async function writeDocxPatched( // 5. Compute expected structural delta from applied ops let expectedChildDelta = 0; + let expectedTableDelta = 0; for (const r of results) { if (r.status !== 'applied') continue; if (STRUCTURAL_INSERT_OPS.has(r.op.type)) expectedChildDelta += 1; if (STRUCTURAL_DELETE_OPS.has(r.op.type)) expectedChildDelta -= 1; + if (r.op.type === 'insert_table') expectedTableDelta += 1; } // 6. After-snapshot const after = captureSnapshot(body); // 7. Validate — throws if structural invariants are broken - validateInvariants(before, after, { expectedChildDelta }); + validateInvariants(before, after, { expectedChildDelta, expectedTableDelta }); // 8. Serialize and save (document.xml + any zip-level changes) const newXml = serializeXml(doc); diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 268d74af..bf9a7139 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -171,6 +171,26 @@ const HeaderReplaceTextExactOpSchema = z.object({ to: z.string(), }); +const InsertTableOpSchema = z.object({ + type: z.literal('insert_table'), + after: z.string().optional(), + before: z.string().optional(), + headers: z.array(z.string()).optional(), + rows: z.array(z.array(z.string())), + colWidths: z.array(z.number()).optional(), + style: z.string().optional(), +}); + +const InsertImageOpSchema = z.object({ + type: z.literal('insert_image'), + after: z.string().optional(), + before: z.string().optional(), + imagePath: z.string(), + width: z.number().optional(), + height: z.number().optional(), + altText: z.string().optional(), +}); + const DocxOpSchema = z.discriminatedUnion('type', [ ReplaceParagraphTextExactOpSchema, ReplaceParagraphAtBodyIndexOpSchema, @@ -182,6 +202,8 @@ const DocxOpSchema = z.discriminatedUnion('type', [ TableSetCellTextOpSchema, ReplaceHyperlinkUrlOpSchema, HeaderReplaceTextExactOpSchema, + InsertTableOpSchema, + InsertImageOpSchema, ]); export const WriteDocxArgsSchema = z.object({ From 16ea0e97329437f5c248aa631952dc8dd8ef6d3d Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:05:42 +0100 Subject: [PATCH 5/9] fix: implement new docx write function --- src/handlers/filesystem-handlers.ts | 32 +- src/server.ts | 43 +- src/tools/docx/create.ts | 707 ++++++++++++++++++ src/tools/docx/index.ts | 10 + .../docx/ops/insert-paragraph-after-text.ts | 24 + src/tools/docx/types.ts | 32 + src/tools/schemas.ts | 50 +- 7 files changed, 881 insertions(+), 17 deletions(-) create mode 100644 src/tools/docx/create.ts diff --git a/src/handlers/filesystem-handlers.ts b/src/handlers/filesystem-handlers.ts index b967ec48..cfcde7ab 100644 --- a/src/handlers/filesystem-handlers.ts +++ b/src/handlers/filesystem-handlers.ts @@ -426,17 +426,35 @@ export async function handleReadDocx(args: unknown): Promise { } /** - * Handle write_docx command — patch-based update + * Handle write_docx command — supports both new file creation and updates */ export async function handleWriteDocx(args: unknown): Promise { try { const parsed = WriteDocxArgsSchema.parse(args); - const { writeDocxPatched } = await import('../tools/docx/index.js'); - const result = await writeDocxPatched( - parsed.inputPath, - parsed.outputPath, - parsed.ops as any - ); + const { writeDocxPatched, createDocxNew } = await import('../tools/docx/index.js'); + + let result; + if (parsed.inputPath) { + // Update existing DOCX file (requires ops) + if (!parsed.ops) { + throw new Error('For updates (with inputPath), "ops" parameter is required'); + } + result = await writeDocxPatched( + parsed.inputPath, + parsed.outputPath, + parsed.ops as any + ); + } else { + // Create new DOCX file from scratch (requires content structure) + if (!parsed.content) { + throw new Error('For new files (no inputPath), "content" parameter is required'); + } + result = await createDocxNew( + parsed.outputPath, + parsed.content as any + ); + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; diff --git a/src/server.ts b/src/server.ts index d2c5b46e..cc791add 100644 --- a/src/server.ts +++ b/src/server.ts @@ -480,20 +480,47 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "write_docx", description: ` - Apply patch-based updates to an existing DOCX file. + Create new DOCX files or apply patch-based updates to existing DOCX files. - THIS IS THE ONLY TOOL FOR MODIFYING DOCX FILES. - Never overwrites the input — always writes to outputPath. + THIS IS THE ONLY TOOL FOR CREATING AND MODIFYING DOCX FILES. + Supports two modes: + 1. CREATE NEW FILE: Omit inputPath (or set to null) → creates professional DOCX from scratch with styles + 2. UPDATE EXISTING FILE: Provide inputPath → updates existing DOCX, preserves all styles/structure + + For NEW files: Creates a professional DOCX structure with: + - Complete styles.xml (Normal, Heading1-9, ListParagraph, TableGrid) + - Document defaults (Calibri font, proper spacing, professional colors) + - Complete ZIP structure (relationships, content types, settings) + - No file reading required — everything built from scratch + + For UPDATES: Never overwrites the input — always writes to outputPath. Preserves tables, images, numbering, headers/footers, styles, section breaks, and paragraph/table order. - ⚠️ MANDATORY WORKFLOW (exactly 2 tool calls — no more): + ⚠️ WORKFLOW FOR UPDATING EXISTING FILES (exactly 2 tool calls — no more): 1. read_docx(path) → get outline ONCE (do NOT call read_file or read_docx again) 2. write_docx(inputPath, outputPath, ops) → apply all changes in ONE call - DO NOT read the file multiple times. The outline from step 1 has everything you need. - To replicate/copy a DOCX, use write_docx with no ops (ops=[]). + ⚠️ WORKFLOW FOR CREATING NEW FILES (1 tool call): + 1. write_docx(outputPath, content) → omit inputPath, provide "content" structure (similar to readDocx output) + + The "content" parameter is a styled DOM structure with items array: + { + "content": { + "items": [ + { "type": "paragraph", "text": "My Title", "style": "Heading1" }, + { "type": "paragraph", "text": "Body text here", "style": "Normal" }, + { "type": "table", "headers": ["Col1", "Col2"], "rows": [["A", "B"], ["C", "D"]] }, + { "type": "image", "imagePath": "/path/to/img.png", "width": 400, "height": 300 } + ] + } + } + + DO NOT read the file multiple times when updating. The outline from step 1 has everything you need. + To replicate/copy a DOCX, use write_docx with inputPath and no ops (ops=[]). + + OPERATIONS (for UPDATES only — use "ops" parameter): + All operations work for updating existing files. For new files, use "content" structure instead. - OPERATIONS (combine multiple ops in a single call): 1. replace_paragraph_text_exact — Find paragraph by exact trimmed text, replace its text. { "type": "replace_paragraph_text_exact", "from": "Old Title", "to": "New Title" } @@ -510,7 +537,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { "type": "set_paragraph_style_at_body_index", "bodyChildIndex": 5, "style": "Heading1" } 6. insert_paragraph_after_text — Insert a new paragraph after the first paragraph matching exact text. Optional style. + Works in empty documents (appends if no match found). For new files, use empty string or any text as "after" to append. { "type": "insert_paragraph_after_text", "after": "Introduction", "text": "New paragraph text", "style": "Normal" } + { "type": "insert_paragraph_after_text", "after": "", "text": "First paragraph", "style": "Heading1" } // Works in empty doc 7. delete_paragraph_at_body_index — Remove the paragraph at a bodyChildIndex. Skips if not a w:p. { "type": "delete_paragraph_at_body_index", "bodyChildIndex": 14 } diff --git a/src/tools/docx/create.ts b/src/tools/docx/create.ts new file mode 100644 index 00000000..3d6d8ea0 --- /dev/null +++ b/src/tools/docx/create.ts @@ -0,0 +1,707 @@ +/** + * createDocxNew - Create a brand-new professional DOCX file from scratch. + * + * Single Responsibility: Build a complete, professional DOCX structure + * with proper styles, document defaults, and content from styled DOM structure. + * + * This function does NOT read any existing files - it creates everything + * from scratch with a professional structure. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import { parseXml, serializeXml, getBody } from './dom.js'; +import type { + DocxContentStructure, + DocxContentItem, + DocxContentParagraph, + DocxContentTable, + DocxContentImage, + WriteDocxStats, + WriteDocxResult, +} from './types.js'; + +/** + * Create a professional DOCX ZIP structure with: + * - Complete styles.xml (Normal, Heading1-9, etc.) + * - Document defaults (fonts, spacing, colors) + * - Proper relationships + * - Content types + * - Empty document body ready for content + */ +function createProfessionalDocxZip(): PizZip { + const zip = new PizZip(); + + // ─── [Content_Types].xml ────────────────────────────────────────── + zip.file( + '[Content_Types].xml', + ` + + + + + + + + + + +`, + ); + + // ─── _rels/.rels ────────────────────────────────────────────────── + zip.folder('_rels')?.file( + '.rels', + ` + + +`, + ); + + // ─── word/_rels/document.xml.rels ──────────────────────────────── + zip.folder('word')?.folder('_rels')?.file( + 'document.xml.rels', + ` + +`, + ); + + // ─── word/styles.xml ─────────────────────────────────────────────── + zip.file( + 'word/styles.xml', + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`, + ); + + // ─── word/document.xml ───────────────────────────────────────────── + zip.file( + 'word/document.xml', + ` + + + +`, + ); + + // ─── word/settings.xml ──────────────────────────────────────────── + zip.file( + 'word/settings.xml', + ` + + + + + +`, + ); + + // ─── word/webSettings.xml ──────────────────────────────────────── + zip.file( + 'word/webSettings.xml', + ` + + +`, + ); + + // ─── word/fontTable.xml ─────────────────────────────────────────── + zip.file( + 'word/fontTable.xml', + ` + + + + + + + + + + + + + + + + + + + + + + +`, + ); + + // ─── Create media folder ─────────────────────────────────────────── + zip.folder('word')?.folder('media'); + + return zip; +} + +// ─── Content builders ────────────────────────────────────────────────── + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeXmlAttr(s: string): string { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +/** + * Build a paragraph element from content structure. + */ +function buildParagraph(doc: Document, item: DocxContentParagraph): Element { + const p = doc.createElement('w:p'); + + // Set style if provided + if (item.style) { + const pPr = doc.createElement('w:pPr'); + const pStyle = doc.createElement('w:pStyle'); + pStyle.setAttribute('w:val', item.style); + pPr.appendChild(pStyle); + p.appendChild(pPr); + } + + // Add text run + const r = doc.createElement('w:r'); + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = item.text; + r.appendChild(t); + p.appendChild(r); + + return p; +} + +/** + * Build a table element from content structure. + */ +function buildTable(doc: Document, item: DocxContentTable): Element { + const tbl = doc.createElement('w:tbl'); + + // Table properties + const tblPr = doc.createElement('w:tblPr'); + if (item.style) { + const tblStyle = doc.createElement('w:tblStyle'); + tblStyle.setAttribute('w:val', item.style); + tblPr.appendChild(tblStyle); + } + const tblW = doc.createElement('w:tblW'); + tblW.setAttribute('w:w', '0'); + tblW.setAttribute('w:type', 'auto'); + tblPr.appendChild(tblW); + + // Table borders + const tblBorders = doc.createElement('w:tblBorders'); + for (const side of ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] as const) { + const border = doc.createElement(`w:${side}`); + border.setAttribute('w:val', 'single'); + border.setAttribute('w:sz', '4'); + border.setAttribute('w:space', '0'); + border.setAttribute('w:color', '000000'); + tblBorders.appendChild(border); + } + tblPr.appendChild(tblBorders); + tbl.appendChild(tblPr); + + // Table grid + const colCount = item.headers ? item.headers.length : item.rows.length > 0 ? item.rows[0].length : 0; + if (colCount > 0) { + const tblGrid = doc.createElement('w:tblGrid'); + for (let c = 0; c < colCount; c++) { + const gridCol = doc.createElement('w:gridCol'); + const w = item.colWidths?.[c] ?? Math.floor(9000 / colCount); + gridCol.setAttribute('w:w', String(w)); + tblGrid.appendChild(gridCol); + } + tbl.appendChild(tblGrid); + } + + // Helper to build a cell + const buildCell = (text: string, isHeader: boolean, widthTwips?: number): Element => { + const tc = doc.createElement('w:tc'); + if (widthTwips) { + const tcPr = doc.createElement('w:tcPr'); + const tcW = doc.createElement('w:tcW'); + tcW.setAttribute('w:w', String(widthTwips)); + tcW.setAttribute('w:type', 'dxa'); + tcPr.appendChild(tcW); + tc.appendChild(tcPr); + } + const p = doc.createElement('w:p'); + const r = doc.createElement('w:r'); + if (isHeader) { + const rPr = doc.createElement('w:rPr'); + const b = doc.createElement('w:b'); + rPr.appendChild(b); + r.appendChild(rPr); + } + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = text; + r.appendChild(t); + p.appendChild(r); + tc.appendChild(p); + return tc; + }; + + // Header row + if (item.headers && item.headers.length > 0) { + const tr = doc.createElement('w:tr'); + for (let i = 0; i < item.headers.length; i++) { + const width = item.colWidths?.[i]; + tr.appendChild(buildCell(item.headers[i], true, width)); + } + tbl.appendChild(tr); + } + + // Data rows + for (const row of item.rows) { + const tr = doc.createElement('w:tr'); + for (let i = 0; i < row.length; i++) { + const width = item.colWidths?.[i]; + tr.appendChild(buildCell(row[i], false, width)); + } + tbl.appendChild(tr); + } + + return tbl; +} + +/** + * Build image drawing XML and add to ZIP. + */ +async function buildImage( + doc: Document, + zip: PizZip, + item: DocxContentImage, +): Promise { + // Validate image exists + try { + await fs.access(item.imagePath); + } catch { + throw new Error(`Image file not found: ${item.imagePath}`); + } + + // Read image + const imgBuffer = await fs.readFile(item.imagePath); + const ext = path.extname(item.imagePath).toLowerCase(); + const baseName = path.basename(item.imagePath); + + // Find next available media filename + let mediaIndex = 1; + while (zip.file(`word/media/image${mediaIndex}${ext}`)) { + mediaIndex++; + } + const mediaFileName = `image${mediaIndex}${ext}`; + + // Add image to ZIP + zip.file(`word/media/${mediaFileName}`, imgBuffer); + + // Add relationship + const relsPath = 'word/_rels/document.xml.rels'; + const relsEntry = zip.file(relsPath); + if (!relsEntry) throw new Error('Missing document.xml.rels'); + + const relsXml = relsEntry.asText(); + const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); + const relationships = relsDom.getElementsByTagName('Relationship'); + + // Find max existing rId + let maxId = 0; + for (const rel of Array.from(relationships)) { + const id = (rel as Element).getAttribute('Id') || ''; + const match = id.match(/^rId(\d+)$/); + if (match) { + maxId = Math.max(maxId, parseInt(match[1], 10)); + } + } + + const newRId = `rId${maxId + 1}`; + const newRel = relsDom.createElement('Relationship'); + newRel.setAttribute('Id', newRId); + newRel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); + newRel.setAttribute('Target', `media/${mediaFileName}`); + relsDom.documentElement.appendChild(newRel); + + const newRelsXml = new XMLSerializer().serializeToString(relsDom); + zip.file(relsPath, newRelsXml); + + // Ensure Content_Types entry + const ctPath = '[Content_Types].xml'; + const ctEntry = zip.file(ctPath); + if (ctEntry) { + const ctXml = ctEntry.asText(); + const extNoDot = ext.replace(/^\./, ''); + if (!ctXml.includes(`Extension="${extNoDot}"`)) { + const ctDom = new DOMParser().parseFromString(ctXml, 'application/xml'); + const types = ctDom.documentElement; + const defaultEl = ctDom.createElement('Default'); + defaultEl.setAttribute('Extension', extNoDot); + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + }; + defaultEl.setAttribute('ContentType', mimeMap[ext] ?? 'application/octet-stream'); + types.appendChild(defaultEl); + const newCtXml = new XMLSerializer().serializeToString(ctDom); + zip.file(ctPath, newCtXml); + } + } + + // Compute dimensions (EMU) + const PX_TO_EMU = 9525; + const widthPx = item.width ?? 300; + const heightPx = item.height ?? 200; + const widthEmu = widthPx * PX_TO_EMU; + const heightEmu = heightPx * PX_TO_EMU; + + // Build drawing XML + const drawingXmlStr = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + + // Parse drawing XML into a paragraph + const drawingFragment = new DOMParser().parseFromString( + `` + + `${drawingXmlStr}`, + 'application/xml', + ); + + return doc.importNode(drawingFragment.documentElement, true) as Element; +} + +/** + * Create a new professional DOCX file from content structure. + * + * This function creates a complete DOCX structure from scratch with: + * - Professional styles (Normal, Heading1-9, ListParagraph, TableGrid) + * - Document defaults (Calibri font, proper spacing) + * - Complete ZIP structure + * + * Then builds content from the provided structure. + */ +export async function createDocxNew( + outputPath: string, + content: DocxContentStructure, +): Promise { + // 1. Create professional DOCX ZIP structure + const zip = createProfessionalDocxZip(); + + // 2. Parse empty document.xml + const xmlStr = zip.file('word/document.xml')!.asText(); + const doc = parseXml(xmlStr); + const body = getBody(doc); + + // 3. Build content from structure + let tableCount = 0; + for (const item of content.items) { + if (item.type === 'paragraph') { + const p = buildParagraph(doc, item); + body.appendChild(p); + } else if (item.type === 'table') { + const tbl = buildTable(doc, item); + body.appendChild(tbl); + tableCount++; + } else if (item.type === 'image') { + const imgP = await buildImage(doc, zip, item); + body.appendChild(imgP); + } + } + + // 4. Serialize and save + const newXml = serializeXml(doc); + zip.file('word/document.xml', newXml); + const buf = zip.generate({ type: 'nodebuffer' }); + await fs.writeFile(outputPath, buf); + + // 5. Build stats + const bodyChildCount = content.items.length; + const stats: WriteDocxStats = { + tablesBefore: 0, + tablesAfter: tableCount, + bodyChildrenBefore: 0, + bodyChildrenAfter: bodyChildCount, + bodySignatureBefore: '', + bodySignatureAfter: '', // Could compute if needed + }; + + return { + outputPath, + results: [], // No ops were applied + stats, + warnings: [], + }; +} diff --git a/src/tools/docx/index.ts b/src/tools/docx/index.ts index ab18c069..57c9b1ea 100644 --- a/src/tools/docx/index.ts +++ b/src/tools/docx/index.ts @@ -5,6 +5,16 @@ // Patch-based tools (read_docx / write_docx) export { readDocxOutline } from './read.js'; export { writeDocxPatched } from './write.js'; +export { createDocxNew } from './create.js'; + +// Types +export type { + DocxContentStructure, + DocxContentItem, + DocxContentParagraph, + DocxContentTable, + DocxContentImage, +} from './types.js'; // Legacy functions (used by read_file, write_file, edit_block handlers) export { readDocx, extractTextFromDocx, getDocxMetadata, extractBodyXml } from './read.js'; diff --git a/src/tools/docx/ops/insert-paragraph-after-text.ts b/src/tools/docx/ops/insert-paragraph-after-text.ts index 22d47d8d..c3e6bf33 100644 --- a/src/tools/docx/ops/insert-paragraph-after-text.ts +++ b/src/tools/docx/ops/insert-paragraph-after-text.ts @@ -19,6 +19,30 @@ export function applyInsertParagraphAfterText( const children = getBodyChildren(body); const target = op.after.trim(); + // Special case: if document is empty, append the paragraph + if (children.length === 0) { + const doc = body.ownerDocument; + if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; + + const newP = doc.createElement('w:p'); + if (op.style) { + const pPr = doc.createElement('w:pPr'); + const pStyle = doc.createElement('w:pStyle'); + pStyle.setAttribute('w:val', op.style); + pPr.appendChild(pStyle); + newP.appendChild(pPr); + } + const newR = doc.createElement('w:r'); + const newT = doc.createElement('w:t'); + newT.setAttribute('xml:space', 'preserve'); + newT.textContent = op.text; + newR.appendChild(newT); + newP.appendChild(newR); + body.appendChild(newP); + return { op, status: 'applied', matched: 0, reason: 'empty_document_append' }; + } + + // Normal case: find matching paragraph for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.nodeName !== 'w:p') continue; diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts index 996a9e0b..b721087a 100644 --- a/src/tools/docx/types.ts +++ b/src/tools/docx/types.ts @@ -226,3 +226,35 @@ export interface OpResult { matched: number; reason?: string; } + +// ═══════════════════════════════════════════════════════════════════════ +// Content structure for new DOCX creation (styled DOM-like) +// ═══════════════════════════════════════════════════════════════════════ + +export interface DocxContentParagraph { + type: 'paragraph'; + text: string; + style?: string | null; +} + +export interface DocxContentTable { + type: 'table'; + headers?: string[]; + rows: string[][]; + colWidths?: number[]; + style?: string; +} + +export interface DocxContentImage { + type: 'image'; + imagePath: string; + width?: number; + height?: number; + altText?: string; +} + +export type DocxContentItem = DocxContentParagraph | DocxContentTable | DocxContentImage; + +export interface DocxContentStructure { + items: DocxContentItem[]; +} diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index bf9a7139..d1ed15b3 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -206,11 +206,55 @@ const DocxOpSchema = z.discriminatedUnion('type', [ InsertImageOpSchema, ]); +const DocxContentParagraphSchema = z.object({ + type: z.literal('paragraph'), + text: z.string(), + style: z.string().nullable().optional(), +}); + +const DocxContentTableSchema = z.object({ + type: z.literal('table'), + headers: z.array(z.string()).optional(), + rows: z.array(z.array(z.string())), + colWidths: z.array(z.number()).optional(), + style: z.string().optional(), +}); + +const DocxContentImageSchema = z.object({ + type: z.literal('image'), + imagePath: z.string(), + width: z.number().optional(), + height: z.number().optional(), + altText: z.string().optional(), +}); + +const DocxContentItemSchema = z.discriminatedUnion('type', [ + DocxContentParagraphSchema, + DocxContentTableSchema, + DocxContentImageSchema, +]); + +const DocxContentStructureSchema = z.object({ + items: z.array(DocxContentItemSchema), +}); + export const WriteDocxArgsSchema = z.object({ - inputPath: z.string(), + inputPath: z.string().optional(), outputPath: z.string(), - ops: z.array(DocxOpSchema), -}); + ops: z.array(DocxOpSchema).optional(), + content: DocxContentStructureSchema.optional(), +}).refine( + (data) => { + // For new files (no inputPath): require content, ops optional + // For updates (has inputPath): require ops, content not allowed + if (!data.inputPath) { + return !!data.content; // New file must have content + } else { + return !!data.ops; // Update must have ops + } + }, + { message: 'For new files (no inputPath): provide "content". For updates (has inputPath): provide "ops".' } +); export const CreateDirectoryArgsSchema = z.object({ path: z.string(), From 7f6d4fbd877993bc3f429d4354f598ceb78a0763 Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:55:16 +0100 Subject: [PATCH 6/9] fix:refactor --- src/tools/docx/builders/image.ts | 113 +++++++ src/tools/docx/builders/index.ts | 13 + src/tools/docx/builders/paragraph.ts | 36 +++ src/tools/docx/builders/table.ts | 109 +++++++ src/tools/docx/builders/utils.ts | 21 ++ src/tools/docx/constants.ts | 74 +++++ src/tools/docx/create.ts | 278 +----------------- src/tools/docx/ops/insert-image-after-text.ts | 234 ++++----------- src/tools/docx/ops/insert-table-after-text.ts | 128 +------- src/tools/docx/relationships.ts | 93 ++++++ 10 files changed, 525 insertions(+), 574 deletions(-) create mode 100644 src/tools/docx/builders/image.ts create mode 100644 src/tools/docx/builders/index.ts create mode 100644 src/tools/docx/builders/paragraph.ts create mode 100644 src/tools/docx/builders/table.ts create mode 100644 src/tools/docx/builders/utils.ts create mode 100644 src/tools/docx/constants.ts create mode 100644 src/tools/docx/relationships.ts diff --git a/src/tools/docx/builders/image.ts b/src/tools/docx/builders/image.ts new file mode 100644 index 00000000..de8181d6 --- /dev/null +++ b/src/tools/docx/builders/image.ts @@ -0,0 +1,113 @@ +/** + * Image builder — creates w:drawing elements and manages image relationships. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import PizZip from 'pizzip'; +import { DOMParser } from '@xmldom/xmldom'; +import type { DocxContentImage, InsertImageOp } from '../types.js'; +import { addImageRelationship, ensureContentType } from '../relationships.js'; +import { escapeXmlAttr } from './utils.js'; +import { pixelsToEmu, DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, NAMESPACES } from '../constants.js'; + +/** + * Build an image element and add it to the ZIP archive. + * + * @param doc The XML document + * @param zip The DOCX ZIP archive + * @param spec The image specification (from content or operation) + * @returns A w:p element containing the image drawing + */ +export async function buildImageElement( + doc: Document, + zip: PizZip, + spec: DocxContentImage | InsertImageOp, +): Promise { + // Validate image exists + try { + await fs.access(spec.imagePath); + } catch { + throw new Error(`Image file not found: ${spec.imagePath}`); + } + + // Read image + const imgBuffer = await fs.readFile(spec.imagePath); + const ext = path.extname(spec.imagePath).toLowerCase(); + const baseName = path.basename(spec.imagePath); + + // Find next available media filename + let mediaIndex = 1; + while (zip.file(`word/media/image${mediaIndex}${ext}`)) { + mediaIndex++; + } + const mediaFileName = `image${mediaIndex}${ext}`; + + // Add image to ZIP + zip.file(`word/media/${mediaFileName}`, imgBuffer); + + // Add relationship + const rId = addImageRelationship(zip, mediaFileName); + + // Ensure Content_Types entry + ensureContentType(zip, ext); + + // Compute dimensions (EMU) + const widthPx = spec.width ?? DEFAULT_IMAGE_WIDTH; + const heightPx = spec.height ?? DEFAULT_IMAGE_HEIGHT; + const widthEmu = pixelsToEmu(widthPx); + const heightEmu = pixelsToEmu(heightPx); + + // Build drawing XML + const altText = spec.altText ?? baseName; + const drawingXmlStr = buildDrawingXml(rId, widthEmu, heightEmu, altText, mediaFileName); + + // Parse drawing XML into a paragraph + const drawingFragment = new DOMParser().parseFromString( + `` + + `${drawingXmlStr}`, + 'application/xml', + ); + + return doc.importNode(drawingFragment.documentElement, true) as Element; +} + +/** + * Build the inline w:drawing XML for an image reference. + */ +function buildDrawingXml( + rId: string, + widthEmu: number, + heightEmu: number, + altText: string, + fileName: string, +): string { + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); +} + diff --git a/src/tools/docx/builders/index.ts b/src/tools/docx/builders/index.ts new file mode 100644 index 00000000..cc346f4b --- /dev/null +++ b/src/tools/docx/builders/index.ts @@ -0,0 +1,13 @@ +/** + * DOCX element builders — Single Responsibility: build XML elements + * for paragraphs, tables, and images. + * + * These builders are shared between create.ts and ops/ modules to + * eliminate code duplication and ensure consistency. + */ + +export { buildParagraph } from './paragraph.js'; +export { buildTable } from './table.js'; +export { buildImageElement } from './image.js'; +export { escapeXml, escapeXmlAttr } from './utils.js'; + diff --git a/src/tools/docx/builders/paragraph.ts b/src/tools/docx/builders/paragraph.ts new file mode 100644 index 00000000..6e1d8d33 --- /dev/null +++ b/src/tools/docx/builders/paragraph.ts @@ -0,0 +1,36 @@ +/** + * Paragraph builder — creates w:p elements with optional styles. + */ + +import type { DocxContentParagraph } from '../types.js'; + +/** + * Build a paragraph element from content structure. + * + * @param doc The XML document + * @param item The paragraph content item + * @returns A w:p element + */ +export function buildParagraph(doc: Document, item: DocxContentParagraph): Element { + const p = doc.createElement('w:p'); + + // Set style if provided + if (item.style) { + const pPr = doc.createElement('w:pPr'); + const pStyle = doc.createElement('w:pStyle'); + pStyle.setAttribute('w:val', item.style); + pPr.appendChild(pStyle); + p.appendChild(pPr); + } + + // Add text run + const r = doc.createElement('w:r'); + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = item.text; + r.appendChild(t); + p.appendChild(r); + + return p; +} + diff --git a/src/tools/docx/builders/table.ts b/src/tools/docx/builders/table.ts new file mode 100644 index 00000000..0c0e1d68 --- /dev/null +++ b/src/tools/docx/builders/table.ts @@ -0,0 +1,109 @@ +/** + * Table builder — creates w:tbl elements with headers, rows, and styling. + */ + +import type { DocxContentTable, InsertTableOp } from '../types.js'; + +/** + * Build a table element from content structure or operation. + */ +export function buildTable( + doc: Document, + spec: DocxContentTable | InsertTableOp, +): Element { + const tbl = doc.createElement('w:tbl'); + + // Table properties + const tblPr = doc.createElement('w:tblPr'); + if (spec.style) { + const tblStyle = doc.createElement('w:tblStyle'); + tblStyle.setAttribute('w:val', spec.style); + tblPr.appendChild(tblStyle); + } + const tblW = doc.createElement('w:tblW'); + tblW.setAttribute('w:w', '0'); + tblW.setAttribute('w:type', 'auto'); + tblPr.appendChild(tblW); + + // Table borders + const tblBorders = doc.createElement('w:tblBorders'); + for (const side of ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] as const) { + const border = doc.createElement(`w:${side}`); + border.setAttribute('w:val', 'single'); + border.setAttribute('w:sz', '4'); + border.setAttribute('w:space', '0'); + border.setAttribute('w:color', '000000'); + tblBorders.appendChild(border); + } + tblPr.appendChild(tblBorders); + tbl.appendChild(tblPr); + + // Table grid + const colCount = spec.headers + ? spec.headers.length + : spec.rows.length > 0 + ? spec.rows[0].length + : 0; + + if (colCount > 0) { + const tblGrid = doc.createElement('w:tblGrid'); + for (let c = 0; c < colCount; c++) { + const gridCol = doc.createElement('w:gridCol'); + const w = spec.colWidths?.[c] ?? Math.floor(9000 / colCount); + gridCol.setAttribute('w:w', String(w)); + tblGrid.appendChild(gridCol); + } + tbl.appendChild(tblGrid); + } + + // Helper to build a cell + const buildCell = (text: string, isHeader: boolean, widthTwips?: number): Element => { + const tc = doc.createElement('w:tc'); + if (widthTwips) { + const tcPr = doc.createElement('w:tcPr'); + const tcW = doc.createElement('w:tcW'); + tcW.setAttribute('w:w', String(widthTwips)); + tcW.setAttribute('w:type', 'dxa'); + tcPr.appendChild(tcW); + tc.appendChild(tcPr); + } + const p = doc.createElement('w:p'); + const r = doc.createElement('w:r'); + if (isHeader) { + const rPr = doc.createElement('w:rPr'); + const b = doc.createElement('w:b'); + rPr.appendChild(b); + r.appendChild(rPr); + } + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = text; + r.appendChild(t); + p.appendChild(r); + tc.appendChild(p); + return tc; + }; + + // Header row + if (spec.headers && spec.headers.length > 0) { + const tr = doc.createElement('w:tr'); + for (let i = 0; i < spec.headers.length; i++) { + const width = spec.colWidths?.[i]; + tr.appendChild(buildCell(spec.headers[i], true, width)); + } + tbl.appendChild(tr); + } + + // Data rows + for (const row of spec.rows) { + const tr = doc.createElement('w:tr'); + for (let i = 0; i < row.length; i++) { + const width = spec.colWidths?.[i]; + tr.appendChild(buildCell(row[i], false, width)); + } + tbl.appendChild(tr); + } + + return tbl; +} + diff --git a/src/tools/docx/builders/utils.ts b/src/tools/docx/builders/utils.ts new file mode 100644 index 00000000..a691b165 --- /dev/null +++ b/src/tools/docx/builders/utils.ts @@ -0,0 +1,21 @@ +/** + * XML escaping utilities. + */ + +export function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function escapeXmlAttr(s: string): string { + return s + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + diff --git a/src/tools/docx/constants.ts b/src/tools/docx/constants.ts new file mode 100644 index 00000000..f91d80f2 --- /dev/null +++ b/src/tools/docx/constants.ts @@ -0,0 +1,74 @@ +/** + * DOCX constants — shared values used across the module. + */ + +// ═══════════════════════════════════════════════════════════════════════ +// Image MIME types +// ═══════════════════════════════════════════════════════════════════════ + +export const IMAGE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.tif': 'image/tiff', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +}; + +export function getMimeType(ext: string): string { + return IMAGE_MIME_TYPES[ext.toLowerCase()] ?? 'application/octet-stream'; +} + +// ═══════════════════════════════════════════════════════════════════════ +// EMU conversion (English Metric Units) +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Convert pixels to EMU (English Metric Units). + * 1 inch = 914400 EMU, 1 px ≈ 9525 EMU (at 96 DPI) + */ +export const PX_TO_EMU = 9525; + +export function pixelsToEmu(px: number): number { + return px * PX_TO_EMU; +} + +// ═══════════════════════════════════════════════════════════════════════ +// XML namespaces +// ═══════════════════════════════════════════════════════════════════════ + +export const NAMESPACES = { + W: 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + WP: 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing', + A: 'http://schemas.openxmlformats.org/drawingml/2006/main', + PIC: 'http://schemas.openxmlformats.org/drawingml/2006/picture', + R: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', + RELS: 'http://schemas.openxmlformats.org/package/2006/relationships', +} as const; + +// ═══════════════════════════════════════════════════════════════════════ +// Default values +// ═══════════════════════════════════════════════════════════════════════ + +export const DEFAULT_IMAGE_WIDTH = 300; +export const DEFAULT_IMAGE_HEIGHT = 200; + +// ═══════════════════════════════════════════════════════════════════════ +// File paths +// ═══════════════════════════════════════════════════════════════════════ + +export const DOCX_PATHS = { + CONTENT_TYPES: '[Content_Types].xml', + DOCUMENT_XML: 'word/document.xml', + DOCUMENT_RELS: 'word/_rels/document.xml.rels', + ROOT_RELS: '_rels/.rels', + STYLES_XML: 'word/styles.xml', + SETTINGS_XML: 'word/settings.xml', + WEB_SETTINGS_XML: 'word/webSettings.xml', + FONT_TABLE_XML: 'word/fontTable.xml', + MEDIA_FOLDER: 'word/media', +} as const; + diff --git a/src/tools/docx/create.ts b/src/tools/docx/create.ts index 3d6d8ea0..82b7296a 100644 --- a/src/tools/docx/create.ts +++ b/src/tools/docx/create.ts @@ -9,16 +9,11 @@ */ import fs from 'fs/promises'; -import path from 'path'; import PizZip from 'pizzip'; -import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; import { parseXml, serializeXml, getBody } from './dom.js'; +import { buildParagraph, buildTable, buildImageElement } from './builders/index.js'; import type { DocxContentStructure, - DocxContentItem, - DocxContentParagraph, - DocxContentTable, - DocxContentImage, WriteDocxStats, WriteDocxResult, } from './types.js'; @@ -374,274 +369,7 @@ function createProfessionalDocxZip(): PizZip { return zip; } -// ─── Content builders ────────────────────────────────────────────────── - -function escapeXml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function escapeXmlAttr(s: string): string { - return s - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); -} - -/** - * Build a paragraph element from content structure. - */ -function buildParagraph(doc: Document, item: DocxContentParagraph): Element { - const p = doc.createElement('w:p'); - - // Set style if provided - if (item.style) { - const pPr = doc.createElement('w:pPr'); - const pStyle = doc.createElement('w:pStyle'); - pStyle.setAttribute('w:val', item.style); - pPr.appendChild(pStyle); - p.appendChild(pPr); - } - - // Add text run - const r = doc.createElement('w:r'); - const t = doc.createElement('w:t'); - t.setAttribute('xml:space', 'preserve'); - t.textContent = item.text; - r.appendChild(t); - p.appendChild(r); - - return p; -} - -/** - * Build a table element from content structure. - */ -function buildTable(doc: Document, item: DocxContentTable): Element { - const tbl = doc.createElement('w:tbl'); - - // Table properties - const tblPr = doc.createElement('w:tblPr'); - if (item.style) { - const tblStyle = doc.createElement('w:tblStyle'); - tblStyle.setAttribute('w:val', item.style); - tblPr.appendChild(tblStyle); - } - const tblW = doc.createElement('w:tblW'); - tblW.setAttribute('w:w', '0'); - tblW.setAttribute('w:type', 'auto'); - tblPr.appendChild(tblW); - - // Table borders - const tblBorders = doc.createElement('w:tblBorders'); - for (const side of ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] as const) { - const border = doc.createElement(`w:${side}`); - border.setAttribute('w:val', 'single'); - border.setAttribute('w:sz', '4'); - border.setAttribute('w:space', '0'); - border.setAttribute('w:color', '000000'); - tblBorders.appendChild(border); - } - tblPr.appendChild(tblBorders); - tbl.appendChild(tblPr); - - // Table grid - const colCount = item.headers ? item.headers.length : item.rows.length > 0 ? item.rows[0].length : 0; - if (colCount > 0) { - const tblGrid = doc.createElement('w:tblGrid'); - for (let c = 0; c < colCount; c++) { - const gridCol = doc.createElement('w:gridCol'); - const w = item.colWidths?.[c] ?? Math.floor(9000 / colCount); - gridCol.setAttribute('w:w', String(w)); - tblGrid.appendChild(gridCol); - } - tbl.appendChild(tblGrid); - } - - // Helper to build a cell - const buildCell = (text: string, isHeader: boolean, widthTwips?: number): Element => { - const tc = doc.createElement('w:tc'); - if (widthTwips) { - const tcPr = doc.createElement('w:tcPr'); - const tcW = doc.createElement('w:tcW'); - tcW.setAttribute('w:w', String(widthTwips)); - tcW.setAttribute('w:type', 'dxa'); - tcPr.appendChild(tcW); - tc.appendChild(tcPr); - } - const p = doc.createElement('w:p'); - const r = doc.createElement('w:r'); - if (isHeader) { - const rPr = doc.createElement('w:rPr'); - const b = doc.createElement('w:b'); - rPr.appendChild(b); - r.appendChild(rPr); - } - const t = doc.createElement('w:t'); - t.setAttribute('xml:space', 'preserve'); - t.textContent = text; - r.appendChild(t); - p.appendChild(r); - tc.appendChild(p); - return tc; - }; - - // Header row - if (item.headers && item.headers.length > 0) { - const tr = doc.createElement('w:tr'); - for (let i = 0; i < item.headers.length; i++) { - const width = item.colWidths?.[i]; - tr.appendChild(buildCell(item.headers[i], true, width)); - } - tbl.appendChild(tr); - } - - // Data rows - for (const row of item.rows) { - const tr = doc.createElement('w:tr'); - for (let i = 0; i < row.length; i++) { - const width = item.colWidths?.[i]; - tr.appendChild(buildCell(row[i], false, width)); - } - tbl.appendChild(tr); - } - - return tbl; -} - -/** - * Build image drawing XML and add to ZIP. - */ -async function buildImage( - doc: Document, - zip: PizZip, - item: DocxContentImage, -): Promise { - // Validate image exists - try { - await fs.access(item.imagePath); - } catch { - throw new Error(`Image file not found: ${item.imagePath}`); - } - - // Read image - const imgBuffer = await fs.readFile(item.imagePath); - const ext = path.extname(item.imagePath).toLowerCase(); - const baseName = path.basename(item.imagePath); - - // Find next available media filename - let mediaIndex = 1; - while (zip.file(`word/media/image${mediaIndex}${ext}`)) { - mediaIndex++; - } - const mediaFileName = `image${mediaIndex}${ext}`; - - // Add image to ZIP - zip.file(`word/media/${mediaFileName}`, imgBuffer); - - // Add relationship - const relsPath = 'word/_rels/document.xml.rels'; - const relsEntry = zip.file(relsPath); - if (!relsEntry) throw new Error('Missing document.xml.rels'); - - const relsXml = relsEntry.asText(); - const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); - const relationships = relsDom.getElementsByTagName('Relationship'); - - // Find max existing rId - let maxId = 0; - for (const rel of Array.from(relationships)) { - const id = (rel as Element).getAttribute('Id') || ''; - const match = id.match(/^rId(\d+)$/); - if (match) { - maxId = Math.max(maxId, parseInt(match[1], 10)); - } - } - - const newRId = `rId${maxId + 1}`; - const newRel = relsDom.createElement('Relationship'); - newRel.setAttribute('Id', newRId); - newRel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); - newRel.setAttribute('Target', `media/${mediaFileName}`); - relsDom.documentElement.appendChild(newRel); - - const newRelsXml = new XMLSerializer().serializeToString(relsDom); - zip.file(relsPath, newRelsXml); - - // Ensure Content_Types entry - const ctPath = '[Content_Types].xml'; - const ctEntry = zip.file(ctPath); - if (ctEntry) { - const ctXml = ctEntry.asText(); - const extNoDot = ext.replace(/^\./, ''); - if (!ctXml.includes(`Extension="${extNoDot}"`)) { - const ctDom = new DOMParser().parseFromString(ctXml, 'application/xml'); - const types = ctDom.documentElement; - const defaultEl = ctDom.createElement('Default'); - defaultEl.setAttribute('Extension', extNoDot); - const mimeMap: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.bmp': 'image/bmp', - }; - defaultEl.setAttribute('ContentType', mimeMap[ext] ?? 'application/octet-stream'); - types.appendChild(defaultEl); - const newCtXml = new XMLSerializer().serializeToString(ctDom); - zip.file(ctPath, newCtXml); - } - } - - // Compute dimensions (EMU) - const PX_TO_EMU = 9525; - const widthPx = item.width ?? 300; - const heightPx = item.height ?? 200; - const widthEmu = widthPx * PX_TO_EMU; - const heightEmu = heightPx * PX_TO_EMU; - - // Build drawing XML - const drawingXmlStr = - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - ``; - - // Parse drawing XML into a paragraph - const drawingFragment = new DOMParser().parseFromString( - `` + - `${drawingXmlStr}`, - 'application/xml', - ); - - return doc.importNode(drawingFragment.documentElement, true) as Element; -} +// ─── Content builders are now in ./builders/index.js ─────────────────── /** * Create a new professional DOCX file from content structure. @@ -676,7 +404,7 @@ export async function createDocxNew( body.appendChild(tbl); tableCount++; } else if (item.type === 'image') { - const imgP = await buildImage(doc, zip, item); + const imgP = await buildImageElement(doc, zip, item); body.appendChild(imgP); } } diff --git a/src/tools/docx/ops/insert-image-after-text.ts b/src/tools/docx/ops/insert-image-after-text.ts index 8c966d23..02491457 100644 --- a/src/tools/docx/ops/insert-image-after-text.ts +++ b/src/tools/docx/ops/insert-image-after-text.ts @@ -23,156 +23,13 @@ import fs from 'fs'; import path from 'path'; import PizZip from 'pizzip'; -import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; -import { getBodyChildren, getParagraphText, nodeListToArray } from '../dom.js'; +import { DOMParser } from '@xmldom/xmldom'; +import { getBodyChildren, getParagraphText } from '../dom.js'; +import { addImageRelationship, ensureContentType } from '../relationships.js'; +import { escapeXmlAttr } from '../builders/utils.js'; +import { pixelsToEmu, DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT, NAMESPACES } from '../constants.js'; import type { InsertImageOp, OpResult } from '../types.js'; -// ─── MIME / extension helpers ──────────────────────────────────────── - -const MIME_MAP: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.bmp': 'image/bmp', - '.tiff': 'image/tiff', - '.tif': 'image/tiff', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', -}; - -function getMimeType(ext: string): string { - return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream'; -} - -// ─── EMU conversion (English Metric Units) ────────────────────────── -// 1 inch = 914400 EMU, 1 px ≈ 9525 EMU (at 96 DPI) -const PX_TO_EMU = 9525; - -// ─── Relationship helpers ──────────────────────────────────────────── - -/** - * Add a relationship to word/_rels/document.xml.rels and return the rId. - */ -function addImageRelationship(zip: PizZip, mediaFileName: string): string { - const relsPath = 'word/_rels/document.xml.rels'; - let relsEntry = zip.file(relsPath); - - // Create .rels file if it doesn't exist - if (!relsEntry) { - const emptyRels = - '' + - ''; - zip.file(relsPath, emptyRels); - relsEntry = zip.file(relsPath)!; - } - - const relsXml = relsEntry.asText(); - const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); - const relationships = relsDom.getElementsByTagName('Relationship'); - - // Find max existing rId - let maxId = 0; - for (const rel of nodeListToArray(relationships)) { - const id = (rel as Element).getAttribute('Id') || ''; - const match = id.match(/^rId(\d+)$/); - if (match) { - maxId = Math.max(maxId, parseInt(match[1], 10)); - } - } - - const newRId = `rId${maxId + 1}`; - - // Create new Relationship element - const newRel = relsDom.createElement('Relationship'); - newRel.setAttribute('Id', newRId); - newRel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); - newRel.setAttribute('Target', `media/${mediaFileName}`); - - relsDom.documentElement.appendChild(newRel); - - // Write back - const newRelsXml = new XMLSerializer().serializeToString(relsDom); - zip.file(relsPath, newRelsXml); - - return newRId; -} - -/** - * Ensure the Content_Types.xml has a Default entry for the image extension. - */ -function ensureContentType(zip: PizZip, ext: string): void { - const ctPath = '[Content_Types].xml'; - const ctEntry = zip.file(ctPath); - if (!ctEntry) return; - - const ctXml = ctEntry.asText(); - const extNoDot = ext.replace(/^\./, ''); - - // Check if already present - if (ctXml.includes(`Extension="${extNoDot}"`)) return; - - const ctDom = new DOMParser().parseFromString(ctXml, 'application/xml'); - const types = ctDom.documentElement; - - const defaultEl = ctDom.createElement('Default'); - defaultEl.setAttribute('Extension', extNoDot); - defaultEl.setAttribute('ContentType', getMimeType(ext)); - types.appendChild(defaultEl); - - const newCtXml = new XMLSerializer().serializeToString(ctDom); - zip.file(ctPath, newCtXml); -} - -// ─── Drawing XML builder ───────────────────────────────────────────── - -function escapeXmlAttr(s: string): string { - return s - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); -} - -/** - * Build the inline w:drawing XML for an image reference. - */ -function buildDrawingXml( - rId: string, - widthEmu: number, - heightEmu: number, - altText: string, - fileName: string, -): string { - return ( - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `` - ); -} - // ─── Op implementation ─────────────────────────────────────────────── export function applyInsertImage( @@ -206,62 +63,57 @@ export function applyInsertImage( const doc = body.ownerDocument; if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; - // ── Read image ────────────────────────────────────────── + // ── Read image (sync) ──────────────────────────────────── const imgBuffer = fs.readFileSync(imgPath); const ext = path.extname(imgPath).toLowerCase(); const baseName = path.basename(imgPath); - // ── Find next available media filename ────────────────── + // ── Find next available media filename ──────────────────── let mediaIndex = 1; while (zip.file(`word/media/image${mediaIndex}${ext}`)) { mediaIndex++; } const mediaFileName = `image${mediaIndex}${ext}`; - // ── Add image to ZIP ──────────────────────────────────── + // ── Add image to ZIP ────────────────────────────────────── zip.file(`word/media/${mediaFileName}`, imgBuffer); - // ── Add relationship ──────────────────────────────────── + // ── Add relationship ───────────────────────────────────── const rId = addImageRelationship(zip, mediaFileName); - // ── Ensure Content_Types entry ────────────────────────── + // ── Ensure Content_Types entry ─────────────────────────── ensureContentType(zip, ext); - // ── Compute dimensions (EMU) ──────────────────────────── - const widthPx = op.width ?? 300; - const heightPx = op.height ?? 200; - const widthEmu = widthPx * PX_TO_EMU; - const heightEmu = heightPx * PX_TO_EMU; + // ── Compute dimensions (EMU) ───────────────────────────── + const widthPx = op.width ?? DEFAULT_IMAGE_WIDTH; + const heightPx = op.height ?? DEFAULT_IMAGE_HEIGHT; + const widthEmu = pixelsToEmu(widthPx); + const heightEmu = pixelsToEmu(heightPx); - // ── Build drawing XML and parse it ────────────────────── - const drawingXmlStr = buildDrawingXml( - rId, - widthEmu, - heightEmu, - op.altText ?? baseName, - mediaFileName, - ); + // ── Build drawing XML ──────────────────────────────────── + const altText = op.altText ?? baseName; + const drawingXmlStr = buildDrawingXml(rId, widthEmu, heightEmu, altText, mediaFileName); - // Parse the drawing XML fragment into a w:p element + // ── Parse drawing XML into a paragraph ─────────────────── const drawingFragment = new DOMParser().parseFromString( - `` + + `` + `${drawingXmlStr}`, 'application/xml', ); - const newP = doc.importNode(drawingFragment.documentElement, true); + const imgP = doc.importNode(drawingFragment.documentElement, true) as Element; // ── Insert at the correct position ────────────────────── if (position === 'before') { // Insert BEFORE the matched paragraph - body.insertBefore(newP, child); + body.insertBefore(imgP, child); } else { // Insert AFTER the matched paragraph const nextSibling = child.nextSibling; if (nextSibling) { - body.insertBefore(newP, nextSibling); + body.insertBefore(imgP, nextSibling); } else { - body.appendChild(newP); + body.appendChild(imgP); } } @@ -271,3 +123,41 @@ export function applyInsertImage( return { op, status: 'skipped', matched: 0, reason: 'no_match' }; } + +// ─── Drawing XML builder (shared with builders/image.ts) ──────────────── + +function buildDrawingXml( + rId: string, + widthEmu: number, + heightEmu: number, + altText: string, + fileName: string, +): string { + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); +} diff --git a/src/tools/docx/ops/insert-table-after-text.ts b/src/tools/docx/ops/insert-table-after-text.ts index 113f2648..fa1a2bd7 100644 --- a/src/tools/docx/ops/insert-table-after-text.ts +++ b/src/tools/docx/ops/insert-table-after-text.ts @@ -17,135 +17,9 @@ */ import { getBodyChildren, getParagraphText } from '../dom.js'; +import { buildTable } from '../builders/index.js'; import type { InsertTableOp, OpResult } from '../types.js'; -// ─── XML builders (private) ────────────────────────────────────────── - -/** - * Build a single cell element. - * If `isHeader` is true, the run is bolded. - */ -function buildCell(doc: Document, text: string, isHeader: boolean, widthTwips?: number): Element { - const tc = doc.createElement('w:tc'); - - // Cell properties (width) - if (widthTwips) { - const tcPr = doc.createElement('w:tcPr'); - const tcW = doc.createElement('w:tcW'); - tcW.setAttribute('w:w', String(widthTwips)); - tcW.setAttribute('w:type', 'dxa'); - tcPr.appendChild(tcW); - tc.appendChild(tcPr); - } - - // Paragraph inside cell - const p = doc.createElement('w:p'); - const r = doc.createElement('w:r'); - - // Optional bold for header cells - if (isHeader) { - const rPr = doc.createElement('w:rPr'); - const b = doc.createElement('w:b'); - rPr.appendChild(b); - r.appendChild(rPr); - } - - const t = doc.createElement('w:t'); - t.setAttribute('xml:space', 'preserve'); - t.textContent = text; - r.appendChild(t); - p.appendChild(r); - tc.appendChild(p); - - return tc; -} - -/** - * Build a row element. - */ -function buildRow( - doc: Document, - cells: string[], - isHeader: boolean, - colWidths?: number[], -): Element { - const tr = doc.createElement('w:tr'); - - for (let i = 0; i < cells.length; i++) { - const width = colWidths?.[i]; - tr.appendChild(buildCell(doc, cells[i], isHeader, width)); - } - - return tr; -} - -/** - * Build a complete element from the op specification. - */ -function buildTable(doc: Document, op: InsertTableOp): Element { - const tbl = doc.createElement('w:tbl'); - - // Table properties — bordered by default - const tblPr = doc.createElement('w:tblPr'); - - // Table style - if (op.style) { - const tblStyle = doc.createElement('w:tblStyle'); - tblStyle.setAttribute('w:val', op.style); - tblPr.appendChild(tblStyle); - } - - // Table width (auto by default) - const tblW = doc.createElement('w:tblW'); - tblW.setAttribute('w:w', '0'); - tblW.setAttribute('w:type', 'auto'); - tblPr.appendChild(tblW); - - // Table borders (single-line, 4 pt, black) - const tblBorders = doc.createElement('w:tblBorders'); - for (const side of ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'] as const) { - const border = doc.createElement(`w:${side}`); - border.setAttribute('w:val', 'single'); - border.setAttribute('w:sz', '4'); - border.setAttribute('w:space', '0'); - border.setAttribute('w:color', '000000'); - tblBorders.appendChild(border); - } - tblPr.appendChild(tblBorders); - - tbl.appendChild(tblPr); - - // Table grid (column definitions) - const colCount = op.headers - ? op.headers.length - : op.rows.length > 0 - ? op.rows[0].length - : 0; - - if (colCount > 0) { - const tblGrid = doc.createElement('w:tblGrid'); - for (let c = 0; c < colCount; c++) { - const gridCol = doc.createElement('w:gridCol'); - const w = op.colWidths?.[c] ?? Math.floor(9000 / colCount); - gridCol.setAttribute('w:w', String(w)); - tblGrid.appendChild(gridCol); - } - tbl.appendChild(tblGrid); - } - - // Header row (optional) - if (op.headers && op.headers.length > 0) { - tbl.appendChild(buildRow(doc, op.headers, true, op.colWidths)); - } - - // Data rows - for (const row of op.rows) { - tbl.appendChild(buildRow(doc, row, false, op.colWidths)); - } - - return tbl; -} - // ─── Op implementation ─────────────────────────────────────────────── export function applyInsertTable( diff --git a/src/tools/docx/relationships.ts b/src/tools/docx/relationships.ts new file mode 100644 index 00000000..ada93b5d --- /dev/null +++ b/src/tools/docx/relationships.ts @@ -0,0 +1,93 @@ +/** + * DOCX relationship management — Single Responsibility: manage relationships + * in word/_rels/document.xml.rels and Content_Types.xml. + * + * Used for adding images, hyperlinks, and other external resources. + */ + +import PizZip from 'pizzip'; +import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; +import { nodeListToArray } from './dom.js'; +import { getMimeType } from './constants.js'; +import { DOCX_PATHS, NAMESPACES } from './constants.js'; + +/** + * Add an image relationship to word/_rels/document.xml.rels and return the rId. + * + * @param zip The DOCX ZIP archive + * @param mediaFileName The filename in word/media/ (e.g., "image1.png") + * @returns The relationship ID (e.g., "rId1") + */ +export function addImageRelationship(zip: PizZip, mediaFileName: string): string { + const relsPath = DOCX_PATHS.DOCUMENT_RELS; + let relsEntry = zip.file(relsPath); + + // Create .rels file if it doesn't exist + if (!relsEntry) { + const emptyRels = + '' + + ``; + zip.file(relsPath, emptyRels); + relsEntry = zip.file(relsPath)!; + } + + const relsXml = relsEntry.asText(); + const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); + const relationships = relsDom.getElementsByTagName('Relationship'); + + // Find max existing rId + let maxId = 0; + for (const rel of nodeListToArray(relationships)) { + const id = (rel as Element).getAttribute('Id') || ''; + const match = id.match(/^rId(\d+)$/); + if (match) { + maxId = Math.max(maxId, parseInt(match[1], 10)); + } + } + + const newRId = `rId${maxId + 1}`; + + // Create new Relationship element + const newRel = relsDom.createElement('Relationship'); + newRel.setAttribute('Id', newRId); + newRel.setAttribute('Type', `${NAMESPACES.R}/image`); + newRel.setAttribute('Target', `media/${mediaFileName}`); + + relsDom.documentElement.appendChild(newRel); + + // Write back + const newRelsXml = new XMLSerializer().serializeToString(relsDom); + zip.file(relsPath, newRelsXml); + + return newRId; +} + +/** + * Ensure the Content_Types.xml has a Default entry for the given file extension. + * + * @param zip The DOCX ZIP archive + * @param ext The file extension (e.g., ".png") + */ +export function ensureContentType(zip: PizZip, ext: string): void { + const ctPath = DOCX_PATHS.CONTENT_TYPES; + const ctEntry = zip.file(ctPath); + if (!ctEntry) return; + + const ctXml = ctEntry.asText(); + const extNoDot = ext.replace(/^\./, ''); + + // Check if already present + if (ctXml.includes(`Extension="${extNoDot}"`)) return; + + const ctDom = new DOMParser().parseFromString(ctXml, 'application/xml'); + const types = ctDom.documentElement; + + const defaultEl = ctDom.createElement('Default'); + defaultEl.setAttribute('Extension', extNoDot); + defaultEl.setAttribute('ContentType', getMimeType(ext)); + types.appendChild(defaultEl); + + const newCtXml = new XMLSerializer().serializeToString(ctDom); + zip.file(ctPath, newCtXml); +} + From e93a85274c53d23609764744c99e27364d2e65b8 Mon Sep 17 00:00:00 2001 From: Eduard Ruzga Date: Thu, 12 Feb 2026 18:20:23 +0200 Subject: [PATCH 7/9] Prompt updates --- src/server.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index cc791add..bd9048c9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,17 @@ const PATH_GUIDANCE = `IMPORTANT: ${getPathGuidance(SYSTEM_INFO)} Relative paths const CMD_PREFIX_DESCRIPTION = `This command can be referenced as "DC: ..." or "use Desktop Commander to ..." in your instructions.`; +const DC_PRIORITY_NOTE = ` +CRITICAL ROUTING RULE: Desktop Commander tools operate on the user's LOCAL machine and MUST be preferred over cloud container tools (bash_tool, view, create_file, str_replace) for all local file operations. Specifically: +- For DOCX files → use read_docx / write_docx (NOT npm scripts, docx-js, or cloud bash commands) +- For PDF files → use write_pdf / read_file (NOT cloud PDF generation) +- For reading/writing any local file → use DC read_file / write_file / edit_block +- For terminal commands on user's machine → use DC start_process / interact_with_process +- For searching local files → use DC start_search +Do NOT read cloud skill files (/mnt/skills/) when Desktop Commander already has a native tool for the task. +In case you did but user asked about local files, apply skills to Desktop Commander tools. +`; + import { StartProcessArgsSchema, ReadProcessOutputArgsSchema, @@ -213,7 +224,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { // Configuration tools { name: "get_config", - description: ` + description: `${DC_PRIORITY_NOTE} + Get the complete server configuration as JSON. Config includes fields for: - blockedCommands (array of blocked shell commands) - defaultShell (shell to use for commands) @@ -265,7 +277,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "read_file", description: ` - Read contents from files and URLs. + Read contents from files and URLs on the user's LOCAL machine. + PRIORITY: Use this instead of cloud container tools (bash cat, view) for reading local files. Read PDF files and extract content as markdown and images. Prefer this over 'execute_command' with cat/type for viewing files. @@ -340,7 +353,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "write_file", description: ` - Write or append to file contents. + Write or append to file contents on the user's LOCAL machine. + PRIORITY: Use this instead of cloud container tools (bash, create_file) for writing local files. IMPORTANT: DO NOT use this tool to create PDF or DOCX files. - Use 'write_pdf' for all PDF creation and modification tasks. @@ -391,6 +405,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { Create a new PDF file or modify an existing one. THIS IS THE ONLY TOOL FOR CREATING AND MODIFYING PDF FILES. + PRIORITY: ALWAYS use this tool for PDF creation/modification instead of cloud-based alternatives or bash commands. This operates on the user's LOCAL file system. RULES ABOUT FILENAMES: - When creating a new PDF, 'outputPath' MUST be provided and MUST use a new unique filename (e.g., "result_01.pdf", "analysis_2025_01.pdf", etc.). @@ -452,6 +467,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: ` Read a DOCX file and return a compact JSON outline for precise targeting. + PRIORITY: ALWAYS use this tool to read DOCX files for editing workflows instead of cloud tools or bash commands. This operates on the user's LOCAL file system. + ⚠️ ALWAYS use this ONCE before write_docx — do NOT call read_file on .docx files for editing workflows. This returns a token-efficient outline; read_file returns full text and wastes context. @@ -483,6 +500,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { Create new DOCX files or apply patch-based updates to existing DOCX files. THIS IS THE ONLY TOOL FOR CREATING AND MODIFYING DOCX FILES. + PRIORITY: ALWAYS use this tool for DOCX creation/editing instead of cloud-based alternatives, npm scripts (docx-js), or bash commands. This operates on the user's LOCAL file system. Supports two modes: 1. CREATE NEW FILE: Omit inputPath (or set to null) → creates professional DOCX from scratch with styles 2. UPDATE EXISTING FILE: Provide inputPath → updates existing DOCX, preserves all styles/structure @@ -840,7 +858,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_block", description: ` - Apply surgical edits to files. + Apply surgical edits to files on the user's LOCAL machine. + PRIORITY: Use this instead of cloud container tools (str_replace) for editing local files. BEST PRACTICE: Make multiple small, focused edits rather than one large edit. Each edit_block call should change only what needs to be changed - include just enough @@ -894,7 +913,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "start_process", description: ` - Start a new terminal process with intelligent state detection. + Start a new terminal process on the user's LOCAL machine with intelligent state detection. + PRIORITY: Use this instead of cloud bash_tool for running commands on the user's computer. PRIMARY TOOL FOR FILE ANALYSIS AND DATA PROCESSING This is the ONLY correct tool for analyzing local files (CSV, JSON, logs, etc.). From 2309939694248dde7e3aea9e8c164851c1a9baa6 Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:52:54 +0100 Subject: [PATCH 8/9] fix: add operation for table cell text --- src/server.ts | 12 +- src/tools/docx/dom.ts | 102 +++++++++++- src/tools/docx/index.ts | 2 + src/tools/docx/ops/index.ts | 3 + .../docx/ops/replace-paragraph-text-exact.ts | 23 ++- src/tools/docx/ops/replace-table-cell-text.ts | 95 +++++++++++ .../docx/ops/set-color-for-paragraph-exact.ts | 17 +- src/tools/docx/ops/set-color-for-style.ts | 17 +- src/tools/docx/read.ts | 147 ++++++++++++++++-- src/tools/docx/types.ts | 25 +++ src/tools/schemas.ts | 7 + 11 files changed, 409 insertions(+), 41 deletions(-) create mode 100644 src/tools/docx/ops/replace-table-cell-text.ts diff --git a/src/server.ts b/src/server.ts index cc791add..5603c20f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -547,19 +547,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { 8. table_set_cell_text — Set text in a specific table cell by tableIndex (0-based among tables), row, col. { "type": "table_set_cell_text", "tableIndex": 0, "row": 1, "col": 2, "text": "Updated" } - 9. replace_hyperlink_url — Replace a hyperlink URL in the document relationships (.rels). + 9. replace_table_cell_text — Find a table cell by its full text content (all paragraphs joined) and replace it. + Useful when you've read table content using readDocxOutline and want to replace specific cell values by their text. + { "type": "replace_table_cell_text", "from": "Old cell text", "to": "New cell text" } + + 10. replace_hyperlink_url — Replace a hyperlink URL in the document relationships (.rels). { "type": "replace_hyperlink_url", "oldUrl": "https://old.example.com", "newUrl": "https://new.example.com" } - 10. header_replace_text_exact — Find and replace text in all document header XML files (header1.xml, header2.xml, …). + 11. header_replace_text_exact — Find and replace text in all document header XML files (header1.xml, header2.xml, …). { "type": "header_replace_text_exact", "from": "Draft", "to": "Final" } - 11. insert_table — Insert a new table before or after the first paragraph matching exact text. + 12. insert_table — Insert a new table before or after the first paragraph matching exact text. Provide exactly ONE of "after" or "before" to set position. Accepts optional headers (bold row), rows (array of string arrays), colWidths (twips), and style. After: { "type": "insert_table", "after": "Sales Data", "headers": ["Product", "Q1", "Q2"], "rows": [["Widget", "100", "150"]] } Before: { "type": "insert_table", "before": "Summary", "headers": ["Metric", "Value"], "rows": [["Revenue", "$1M"]] } - 12. insert_image — Insert an image from disk before or after the first paragraph matching exact text. + 13. insert_image — Insert an image from disk before or after the first paragraph matching exact text. Provide exactly ONE of "after" or "before" to set position. Reads the image file, embeds it in the DOCX, and creates a proper drawing reference. After: { "type": "insert_image", "after": "Company Logo", "imagePath": "/path/to/logo.png", "width": 400, "height": 200, "altText": "Logo" } diff --git a/src/tools/docx/dom.ts b/src/tools/docx/dom.ts index 3e883af2..590c2e3a 100644 --- a/src/tools/docx/dom.ts +++ b/src/tools/docx/dom.ts @@ -113,6 +113,107 @@ export function getParagraphStyle(p: Element): string | null { return null; } +// ═══════════════════════════════════════════════════════════════════════ +// Table content extraction +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Extract all text content from a table cell (w:tc). + * Returns the concatenated text from all paragraphs in the cell. + */ +export function getCellText(tc: Element): string { + const paragraphs = tc.getElementsByTagName('w:p'); + const texts: string[] = []; + for (let i = 0; i < paragraphs.length; i++) { + const p = paragraphs.item(i); + if (p) { + const text = getParagraphText(p as Element).trim(); + if (text) texts.push(text); + } + } + return texts.join(' '); // Join multiple paragraphs in cell with space +} + +/** + * Extract all rows from a table (w:tbl). + * Returns an array of rows, where each row is an array of cell text strings. + * First row is treated as header if it exists. + */ +export function getTableContent(tbl: Element): { headers?: string[]; rows: string[][] } { + const rows: Element[] = []; + for (const child of nodeListToArray(tbl.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:tr') { + rows.push(child as Element); + } + } + + if (rows.length === 0) { + return { rows: [] }; + } + + // Extract cells from each row + const tableRows: string[][] = []; + for (const row of rows) { + const cells: string[] = []; + for (const child of nodeListToArray(row.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:tc') { + cells.push(getCellText(child as Element)); + } + } + if (cells.length > 0) { + tableRows.push(cells); + } + } + + // First row might be header - check if it has bold formatting + // For simplicity, we'll treat first row as potential header + // User can determine this based on style or content + if (tableRows.length > 0) { + const firstRow = tableRows[0]; + const restRows = tableRows.slice(1); + return { + headers: firstRow.length > 0 ? firstRow : undefined, + rows: restRows.length > 0 ? restRows : [], + }; + } + + return { rows: tableRows }; +} + +/** + * Get table style from w:tblPr/w:tblStyle/@w:val, or null if absent. + */ +export function getTableStyle(tbl: Element): string | null { + const tblPr = tbl.getElementsByTagName('w:tblPr').item(0); + if (!tblPr) return null; + + const tblStyle = tblPr.getElementsByTagName('w:tblStyle').item(0); + if (!tblStyle) return null; + + return tblStyle.getAttribute('w:val'); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Image reference extraction +// ═══════════════════════════════════════════════════════════════════════ + +/** + * Extract image reference from a w:drawing element. + * Returns the relationship ID (rId) and media file path if found. + */ +export function getImageReference(drawing: Element): { rId: string | null; mediaPath: string | null } { + // Find a:blip/@r:embed to get the relationship ID + const blip = drawing.getElementsByTagName('a:blip').item(0); + if (!blip) return { rId: null, mediaPath: null }; + + const rId = blip.getAttribute('r:embed'); + if (!rId) return { rId: null, mediaPath: null }; + + // Media path will be resolved from relationships file + // For now, return the rId - the caller will resolve it from rels + return { rId, mediaPath: null }; +} + // ═══════════════════════════════════════════════════════════════════════ // Minimal text replacement // ═══════════════════════════════════════════════════════════════════════ @@ -262,4 +363,3 @@ function toggleElement( parent.removeChild(existing); } } - diff --git a/src/tools/docx/index.ts b/src/tools/docx/index.ts index 57c9b1ea..6ff779d5 100644 --- a/src/tools/docx/index.ts +++ b/src/tools/docx/index.ts @@ -27,6 +27,8 @@ export type { DocxRun, DocxModification, ParagraphOutline, + TableOutline, + ImageOutline, ReadDocxResult, WriteDocxStats, WriteDocxResult, diff --git a/src/tools/docx/ops/index.ts b/src/tools/docx/ops/index.ts index 4b9ba30f..f7ed732a 100644 --- a/src/tools/docx/ops/index.ts +++ b/src/tools/docx/ops/index.ts @@ -14,6 +14,7 @@ import { applySetParagraphStyleAtBodyIndex } from './set-paragraph-style-at-body import { applyInsertParagraphAfterText } from './insert-paragraph-after-text.js'; import { applyDeleteParagraphAtBodyIndex } from './delete-paragraph-at-body-index.js'; import { applyTableSetCellText } from './table-set-cell-text.js'; +import { applyReplaceTableCellText } from './replace-table-cell-text.js'; import { applyReplaceHyperlinkUrl } from './replace-hyperlink-url.js'; import { applyHeaderReplaceTextExact } from './header-replace-text-exact.js'; import { applyInsertTable } from './insert-table-after-text.js'; @@ -46,6 +47,8 @@ export function applyOp(body: Element, op: DocxOp, zip?: PizZip): OpResult { return applyDeleteParagraphAtBodyIndex(body, op); case 'table_set_cell_text': return applyTableSetCellText(body, op); + case 'replace_table_cell_text': + return applyReplaceTableCellText(body, op); case 'replace_hyperlink_url': if (!zip) return { op, status: 'skipped', matched: 0, reason: 'zip_required_for_hyperlink_op' }; return applyReplaceHyperlinkUrl(body, op, zip); diff --git a/src/tools/docx/ops/replace-paragraph-text-exact.ts b/src/tools/docx/ops/replace-paragraph-text-exact.ts index 9616cad9..f96ac045 100644 --- a/src/tools/docx/ops/replace-paragraph-text-exact.ts +++ b/src/tools/docx/ops/replace-paragraph-text-exact.ts @@ -1,26 +1,35 @@ /** * Op: replace_paragraph_text_exact * - * Find FIRST paragraph whose trimmed text === `from`. + * Find FIRST paragraph whose trimmed text === `from` **anywhere in the body**, + * including paragraphs inside table cells, content controls, etc. + * * Replace only the first w:t with `to`; clear other w:t nodes. * Does NOT remove/recreate runs or paragraph properties. + * + * Note: For table cells with multiple paragraphs, use `replace_table_cell_text` + * instead, which matches the full cell text (all paragraphs joined). */ -import { getBodyChildren, getParagraphText, setParagraphTextMinimal } from '../dom.js'; +import { getParagraphText, setParagraphTextMinimal, nodeListToArray } from '../dom.js'; import type { ReplaceParagraphTextExactOp, OpResult } from '../types.js'; export function applyReplaceParagraphTextExact( body: Element, op: ReplaceParagraphTextExactOp, ): OpResult { - const children = getBodyChildren(body); const target = op.from.trim(); - for (const child of children) { - if (child.nodeName !== 'w:p') continue; + // Traverse **all** paragraphs in the body, not just direct body children. + // This includes paragraphs inside table cells, content controls, etc. + const paragraphs = body.getElementsByTagName('w:p'); + + for (let i = 0; i < paragraphs.length; i++) { + const p = paragraphs.item(i) as Element; + const paragraphText = getParagraphText(p).trim(); - if (getParagraphText(child).trim() === target) { - setParagraphTextMinimal(child, op.to); + if (paragraphText === target) { + setParagraphTextMinimal(p, op.to); return { op, status: 'applied', matched: 1 }; } } diff --git a/src/tools/docx/ops/replace-table-cell-text.ts b/src/tools/docx/ops/replace-table-cell-text.ts new file mode 100644 index 00000000..2ae518cc --- /dev/null +++ b/src/tools/docx/ops/replace-table-cell-text.ts @@ -0,0 +1,95 @@ +/** + * Op: replace_table_cell_text + * + * Find a table cell whose full text content (all paragraphs joined) matches `from`, + * and replace it with `to`. + * + * This operation searches through all tables in the document and finds the first + * cell whose text matches. It replaces the text in the first paragraph of that cell. + * + * This is useful when you've read table content using readDocxOutline and want to + * replace specific cell values by their text content. + */ + +import { getBodyChildren, nodeListToArray, getCellText, setParagraphTextMinimal } from '../dom.js'; +import type { ReplaceTableCellTextOp, OpResult } from '../types.js'; + +export function applyReplaceTableCellText( + body: Element, + op: ReplaceTableCellTextOp, +): OpResult { + const children = getBodyChildren(body); + const target = op.from.trim(); + + // Find all tables + const tables: Element[] = []; + for (const child of children) { + if (child.nodeName === 'w:tbl') { + tables.push(child); + } + } + + // Search through all tables + for (const table of tables) { + // Get all rows + const rows: Element[] = []; + for (const child of nodeListToArray(table.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:tr') { + rows.push(child as Element); + } + } + + // Search through all cells in all rows + for (const row of rows) { + const cells: Element[] = []; + for (const child of nodeListToArray(row.childNodes)) { + if (child.nodeType === 1 && (child as Element).nodeName === 'w:tc') { + cells.push(child as Element); + } + } + + for (const cell of cells) { + // Get full cell text (all paragraphs joined) + const cellText = getCellText(cell).trim(); + + if (cellText === target) { + // Find first paragraph in cell and replace its text + const paragraphs = cell.getElementsByTagName('w:p'); + if (paragraphs.length > 0) { + const firstP = paragraphs.item(0) as Element; + setParagraphTextMinimal(firstP, op.to); + + // Clear other paragraphs in the cell (optional - keeps cell structure) + for (let i = 1; i < paragraphs.length; i++) { + const p = paragraphs.item(i) as Element; + const tNodes = p.getElementsByTagName('w:t'); + for (let j = 0; j < tNodes.length; j++) { + tNodes.item(j)!.textContent = ''; + } + } + + return { op, status: 'applied', matched: 1 }; + } else { + // Cell has no paragraphs - create one + const doc = cell.ownerDocument; + if (!doc) continue; + + const p = doc.createElement('w:p'); + const r = doc.createElement('w:r'); + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = op.to; + r.appendChild(t); + p.appendChild(r); + cell.appendChild(p); + + return { op, status: 'applied', matched: 1 }; + } + } + } + } + } + + return { op, status: 'skipped', matched: 0, reason: 'no_match' }; +} + diff --git a/src/tools/docx/ops/set-color-for-paragraph-exact.ts b/src/tools/docx/ops/set-color-for-paragraph-exact.ts index cfd4e940..394f974e 100644 --- a/src/tools/docx/ops/set-color-for-paragraph-exact.ts +++ b/src/tools/docx/ops/set-color-for-paragraph-exact.ts @@ -1,25 +1,28 @@ /** * Op: set_color_for_paragraph_exact * - * Find FIRST paragraph whose trimmed text === `text`. + * Find FIRST paragraph whose trimmed text === `text` **anywhere in the body**, + * including paragraphs inside tables and other containers. * Apply run-level colour to every w:r in that paragraph. */ -import { getBodyChildren, getParagraphText, ensureRunColor } from '../dom.js'; +import { getParagraphText, ensureRunColor } from '../dom.js'; import type { SetColorForParagraphExactOp, OpResult } from '../types.js'; export function applySetColorForParagraphExact( body: Element, op: SetColorForParagraphExactOp, ): OpResult { - const children = getBodyChildren(body); const target = op.text.trim(); - for (const child of children) { - if (child.nodeName !== 'w:p') continue; - if (getParagraphText(child).trim() !== target) continue; + // Traverse **all** paragraphs in the body, not just direct children. + const paragraphs = body.getElementsByTagName('w:p'); - const runs = child.getElementsByTagName('w:r'); + for (let i = 0; i < paragraphs.length; i++) { + const p = paragraphs.item(i) as Element; + if (getParagraphText(p).trim() !== target) continue; + + const runs = p.getElementsByTagName('w:r'); for (let i = 0; i < runs.length; i++) { ensureRunColor(runs.item(i) as Element, op.color); } diff --git a/src/tools/docx/ops/set-color-for-style.ts b/src/tools/docx/ops/set-color-for-style.ts index a0a3b8fe..2ad00e71 100644 --- a/src/tools/docx/ops/set-color-for-style.ts +++ b/src/tools/docx/ops/set-color-for-style.ts @@ -3,24 +3,29 @@ * * For every paragraph whose w:pPr/w:pStyle/@w:val === style, * set run-level colour on every w:r in that paragraph. + * + * This now includes paragraphs inside tables and other containers, + * not just direct w:p children of w:body. + * * Does NOT modify word/styles.xml — only in-document run formatting. */ -import { getBodyChildren, getParagraphStyle, ensureRunColor } from '../dom.js'; +import { getParagraphStyle, ensureRunColor } from '../dom.js'; import type { SetColorForStyleOp, OpResult } from '../types.js'; export function applySetColorForStyle( body: Element, op: SetColorForStyleOp, ): OpResult { - const children = getBodyChildren(body); + // Traverse **all** paragraphs in the body. + const paragraphs = body.getElementsByTagName('w:p'); let matched = 0; - for (const child of children) { - if (child.nodeName !== 'w:p') continue; - if (getParagraphStyle(child) !== op.style) continue; + for (let i = 0; i < paragraphs.length; i++) { + const p = paragraphs.item(i) as Element; + if (getParagraphStyle(p) !== op.style) continue; - const runs = child.getElementsByTagName('w:r'); + const runs = p.getElementsByTagName('w:r'); for (let i = 0; i < runs.length; i++) { ensureRunColor(runs.item(i) as Element, op.color); } diff --git a/src/tools/docx/read.ts b/src/tools/docx/read.ts index 681574f1..5d0f19d2 100644 --- a/src/tools/docx/read.ts +++ b/src/tools/docx/read.ts @@ -6,7 +6,14 @@ import fs from 'fs/promises'; import PizZip from 'pizzip'; import { DOMParser, XMLSerializer } from '@xmldom/xmldom'; -import type { DocxMetadata, DocxParagraph, ParagraphOutline, ReadDocxResult } from './types.js'; +import type { + DocxMetadata, + DocxParagraph, + ParagraphOutline, + TableOutline, + ImageOutline, + ReadDocxResult, +} from './types.js'; import { nodeListToArray, getParagraphText, @@ -15,6 +22,9 @@ import { getBodyChildren, countTables, countImages, + getTableContent, + getTableStyle, + getImageReference, } from './dom.js'; // ═══════════════════════════════════════════════════════════════════════ @@ -30,10 +40,66 @@ async function loadDocx(path: string): Promise { // readDocxOutline — compact JSON outline (used by read_docx tool) // ═══════════════════════════════════════════════════════════════════════ +/** + * Extract image relationship mappings from word/_rels/document.xml.rels. + * Returns a map of rId -> mediaPath (e.g., "rId1" -> "word/media/image1.png"). + */ +function extractImageRelationships(zip: PizZip): Map { + const relsPath = 'word/_rels/document.xml.rels'; + const relsFile = zip.file(relsPath); + if (!relsFile) return new Map(); + + const relsXml = relsFile.asText(); + const relsDom = new DOMParser().parseFromString(relsXml, 'application/xml'); + const relationships = relsDom.getElementsByTagName('Relationship'); + + const imageMap = new Map(); + for (const rel of nodeListToArray(relationships)) { + const relEl = rel as Element; + const type = relEl.getAttribute('Type'); + const id = relEl.getAttribute('Id'); + const target = relEl.getAttribute('Target'); + + // Check if it's an image relationship + if ( + type && + type.includes('/image') && + id && + target && + target.startsWith('media/') + ) { + imageMap.set(id, `word/${target}`); + } + } + + return imageMap; +} + +/** + * Extract alt text from wp:docPr/@descr or pic:cNvPr/@descr in a drawing element. + */ +function getImageAltText(drawing: Element): string | undefined { + // Try wp:docPr/@descr first + const docPr = drawing.getElementsByTagName('wp:docPr').item(0); + if (docPr) { + const descr = docPr.getAttribute('descr'); + if (descr) return descr; + } + + // Fall back to pic:cNvPr/@descr + const cNvPr = drawing.getElementsByTagName('pic:cNvPr').item(0); + if (cNvPr) { + const descr = cNvPr.getAttribute('descr'); + if (descr) return descr; + } + + return undefined; +} + /** * Return a token-efficient outline of a DOCX file. - * Every paragraph gets a bodyChildIndex (among ALL w:body children) - * plus a paragraphIndex (counting only w:p), style id, and text. + * Extracts paragraphs, tables (with full cell content), and images (references only, not binary). + * Every element gets a bodyChildIndex (among ALL w:body children). */ export async function readDocxOutline(filePath: string): Promise { const zip = await loadDocx(filePath); @@ -45,31 +111,80 @@ export async function readDocxOutline(filePath: string): Promise const body = getBody(dom); const children = getBodyChildren(body); + // Extract image relationships (rId -> mediaPath) + const imageRelationships = extractImageRelationships(zip); + const paragraphs: ParagraphOutline[] = []; + const tables: TableOutline[] = []; + const images: ImageOutline[] = []; const stylesSet = new Set(); + let paragraphIndex = 0; + let tableIndex = 0; + let imageIndex = 0; for (let i = 0; i < children.length; i++) { const child = children[i]; - if (child.nodeName !== 'w:p') continue; - - const text = getParagraphText(child).trim(); - const style = getParagraphStyle(child); - - if (style) stylesSet.add(style); - paragraphs.push({ - bodyChildIndex: i, - paragraphIndex, - style, - text, - }); - paragraphIndex++; + if (child.nodeName === 'w:p') { + // Extract paragraph + const text = getParagraphText(child).trim(); + const style = getParagraphStyle(child); + + if (style) stylesSet.add(style); + + paragraphs.push({ + bodyChildIndex: i, + paragraphIndex, + style, + text, + }); + paragraphIndex++; + + // Check if paragraph contains an image (w:drawing) + const drawings = child.getElementsByTagName('w:drawing'); + for (let d = 0; d < drawings.length; d++) { + const drawing = drawings.item(d) as Element; + const imgRef = getImageReference(drawing); + + if (imgRef.rId) { + const mediaPath = imageRelationships.get(imgRef.rId); + if (mediaPath) { + const altText = getImageAltText(drawing); + images.push({ + bodyChildIndex: i, + imageIndex, + mediaPath, + rId: imgRef.rId, + altText, + }); + imageIndex++; + } + } + } + } else if (child.nodeName === 'w:tbl') { + // Extract table content + const tableContent = getTableContent(child); + const style = getTableStyle(child); + + if (style) stylesSet.add(style); + + tables.push({ + bodyChildIndex: i, + tableIndex, + style, + headers: tableContent.headers, + rows: tableContent.rows, + }); + tableIndex++; + } } return { path: filePath, paragraphs, + tables, + images, stylesSeen: [...stylesSet].sort(), counts: { tables: countTables(children), diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts index b721087a..1b068010 100644 --- a/src/tools/docx/types.ts +++ b/src/tools/docx/types.ts @@ -59,9 +59,27 @@ export interface ParagraphOutline { text: string; } +export interface TableOutline { + bodyChildIndex: number; + tableIndex: number; + style: string | null; + headers?: string[]; + rows: string[][]; +} + +export interface ImageOutline { + bodyChildIndex: number; + imageIndex: number; + mediaPath: string; // e.g., "word/media/image1.png" + rId: string; // Relationship ID, e.g., "rId1" + altText?: string; +} + export interface ReadDocxResult { path: string; paragraphs: ParagraphOutline[]; + tables: TableOutline[]; + images: ImageOutline[]; stylesSeen: string[]; counts: { tables: number; @@ -158,6 +176,12 @@ export interface TableSetCellTextOp { text: string; } +export interface ReplaceTableCellTextOp { + type: 'replace_table_cell_text'; + from: string; + to: string; +} + export interface ReplaceHyperlinkUrlOp { type: 'replace_hyperlink_url'; oldUrl: string; @@ -215,6 +239,7 @@ export type DocxOp = | InsertParagraphAfterTextOp | DeleteParagraphAtBodyIndexOp | TableSetCellTextOp + | ReplaceTableCellTextOp | ReplaceHyperlinkUrlOp | HeaderReplaceTextExactOp | InsertTableOp diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index d1ed15b3..a33add89 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -159,6 +159,12 @@ const TableSetCellTextOpSchema = z.object({ text: z.string(), }); +const ReplaceTableCellTextOpSchema = z.object({ + type: z.literal('replace_table_cell_text'), + from: z.string(), + to: z.string(), +}); + const ReplaceHyperlinkUrlOpSchema = z.object({ type: z.literal('replace_hyperlink_url'), oldUrl: z.string(), @@ -200,6 +206,7 @@ const DocxOpSchema = z.discriminatedUnion('type', [ InsertParagraphAfterTextOpSchema, DeleteParagraphAtBodyIndexOpSchema, TableSetCellTextOpSchema, + ReplaceTableCellTextOpSchema, ReplaceHyperlinkUrlOpSchema, HeaderReplaceTextExactOpSchema, InsertTableOpSchema, From 97c11895c0fae8a3bbb9db0b4e0d2e21ea348575 Mon Sep 17 00:00:00 2001 From: vichazard <177494868+vichazard@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:32:39 +0100 Subject: [PATCH 9/9] feat: update operation for editing of table cell and SDT --- src/server.ts | 22 ++- src/tools/docx/builders/table.ts | 88 ++++++++--- src/tools/docx/dom.ts | 142 +++++++++++++++++- src/tools/docx/index.ts | 1 + .../docx/ops/replace-paragraph-text-exact.ts | 20 ++- src/tools/docx/ops/replace-table-cell-text.ts | 84 ++++++----- src/tools/docx/ops/table-set-cell-text.ts | 52 ++----- src/tools/docx/read.ts | 34 ++++- src/tools/docx/types.ts | 13 +- src/tools/schemas.ts | 10 +- 10 files changed, 353 insertions(+), 113 deletions(-) diff --git a/src/server.ts b/src/server.ts index 3826b17f..0a294576 100644 --- a/src/server.ts +++ b/src/server.ts @@ -527,11 +527,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { "items": [ { "type": "paragraph", "text": "My Title", "style": "Heading1" }, { "type": "paragraph", "text": "Body text here", "style": "Normal" }, - { "type": "table", "headers": ["Col1", "Col2"], "rows": [["A", "B"], ["C", "D"]] }, + { + "type": "table", + "headers": ["Col1", "Col2"], + "rows": [ + ["A", "B"], + ["C", "D"], + // Cells can have multiple paragraphs with different styles: + [ + "Simple cell text", + [ + { "type": "paragraph", "text": "First para", "style": "Heading2" }, + { "type": "paragraph", "text": "Second para", "style": "Normal" } + ] + ] + ] + }, { "type": "image", "imagePath": "/path/to/img.png", "width": 400, "height": 300 } ] } } + + TABLE CELLS: Each cell can be either: + - A string: creates one paragraph with that text + - An array of paragraph objects: creates multiple paragraphs, each with its own style + This allows cells with multiple paragraphs having different font sizes, colors, styles, etc. DO NOT read the file multiple times when updating. The outline from step 1 has everything you need. To replicate/copy a DOCX, use write_docx with inputPath and no ops (ops=[]). diff --git a/src/tools/docx/builders/table.ts b/src/tools/docx/builders/table.ts index 0c0e1d68..54278805 100644 --- a/src/tools/docx/builders/table.ts +++ b/src/tools/docx/builders/table.ts @@ -1,11 +1,14 @@ /** * Table builder — creates w:tbl elements with headers, rows, and styling. + * Supports multiple paragraphs per cell with different styles. */ -import type { DocxContentTable, InsertTableOp } from '../types.js'; +import type { DocxContentTable, InsertTableOp, DocxContentParagraph } from '../types.js'; +import { buildParagraph } from './paragraph.js'; /** * Build a table element from content structure or operation. + * Supports cells with multiple paragraphs, each with its own style. */ export function buildTable( doc: Document, @@ -38,13 +41,14 @@ export function buildTable( tblPr.appendChild(tblBorders); tbl.appendChild(tblPr); - // Table grid + // Determine column count const colCount = spec.headers ? spec.headers.length : spec.rows.length > 0 ? spec.rows[0].length : 0; + // Table grid if (colCount > 0) { const tblGrid = doc.createElement('w:tblGrid'); for (let c = 0; c < colCount; c++) { @@ -56,9 +60,20 @@ export function buildTable( tbl.appendChild(tblGrid); } - // Helper to build a cell - const buildCell = (text: string, isHeader: boolean, widthTwips?: number): Element => { + /** + * Build a cell from content. + * Content can be: + * - A string: creates one paragraph with that text + * - An array of DocxContentParagraph: creates multiple paragraphs, each with its own style + */ + const buildCell = ( + content: string | DocxContentParagraph[], + isHeader: boolean, + widthTwips?: number, + ): Element => { const tc = doc.createElement('w:tc'); + + // Cell properties (width) if (widthTwips) { const tcPr = doc.createElement('w:tcPr'); const tcW = doc.createElement('w:tcW'); @@ -67,20 +82,58 @@ export function buildTable( tcPr.appendChild(tcW); tc.appendChild(tcPr); } - const p = doc.createElement('w:p'); - const r = doc.createElement('w:r'); - if (isHeader) { - const rPr = doc.createElement('w:rPr'); - const b = doc.createElement('w:b'); - rPr.appendChild(b); - r.appendChild(rPr); + + // Handle content: string or array of paragraphs + if (typeof content === 'string') { + // Simple case: single paragraph + const p = doc.createElement('w:p'); + const r = doc.createElement('w:r'); + + // Header cells get bold + if (isHeader) { + const rPr = doc.createElement('w:rPr'); + const b = doc.createElement('w:b'); + rPr.appendChild(b); + r.appendChild(rPr); + } + + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = content; + r.appendChild(t); + p.appendChild(r); + tc.appendChild(p); + } else { + // Complex case: multiple paragraphs with different styles + for (const paraSpec of content) { + const p = buildParagraph(doc, paraSpec); + + // If header and first paragraph, ensure bold on runs + if (isHeader) { + const runs = p.getElementsByTagName('w:r'); + for (let i = 0; i < runs.length; i++) { + const run = runs.item(i) as Element; + let rPr = run.getElementsByTagName('w:rPr').item(0); + if (!rPr) { + rPr = doc.createElement('w:rPr'); + if (run.firstChild) { + run.insertBefore(rPr, run.firstChild); + } else { + run.appendChild(rPr); + } + } + // Add bold if not already present + if (!rPr.getElementsByTagName('w:b').length) { + const b = doc.createElement('w:b'); + rPr.appendChild(b); + } + } + } + + tc.appendChild(p); + } } - const t = doc.createElement('w:t'); - t.setAttribute('xml:space', 'preserve'); - t.textContent = text; - r.appendChild(t); - p.appendChild(r); - tc.appendChild(p); + return tc; }; @@ -106,4 +159,3 @@ export function buildTable( return tbl; } - diff --git a/src/tools/docx/dom.ts b/src/tools/docx/dom.ts index 590c2e3a..f5b57443 100644 --- a/src/tools/docx/dom.ts +++ b/src/tools/docx/dom.ts @@ -63,6 +63,51 @@ export function getBodyChildren(body: Element): Element[] { return out; } +/** + * Return ALL top‑level tables that are logically in the body, including those + * wrapped in structured document tags (w:sdt / w:sdtContent). + * + * Previous logic only saw tables that were direct children of . That + * meant tables inside SDTs were invisible to table operations and readDocxOutline. + * This helper walks the body tree and collects any that appears as a + * *first‑class* block (we do not recurse into tables themselves, so nested + * tables are not double‑counted). + */ +export function getAllBodyTables(body: Element): Element[] { + const result: Element[] = []; + + function collectFromNode(node: Element): void { + const name = node.nodeName; + + if (name === 'w:tbl') { + result.push(node); + return; // don't recurse into tables to avoid nested counting + } + + // If this is a structured document tag, look into its content container. + if (name === 'w:sdt') { + const sdtContent = findDirectChild(node, 'w:sdtContent'); + if (sdtContent) { + for (const child of nodeListToArray(sdtContent.childNodes)) { + if (child.nodeType === 1) collectFromNode(child as Element); + } + } + return; + } + + // Generic container: recurse into element children. + for (const child of nodeListToArray(node.childNodes)) { + if (child.nodeType === 1) collectFromNode(child as Element); + } + } + + for (const child of getBodyChildren(body)) { + collectFromNode(child); + } + + return result; +} + // ═══════════════════════════════════════════════════════════════════════ // Body signature // ═══════════════════════════════════════════════════════════════════════ @@ -222,7 +267,10 @@ export function getImageReference(drawing: Element): { rId: string | null; media * Replace the text of a paragraph with minimal DOM changes. * Sets the FIRST w:t to `text`, clears every subsequent w:t. * Sets xml:space="preserve" so leading/trailing spaces survive. - * Does NOT recreate runs or remove paragraph properties. + * Does NOT remove/recreate runs or remove paragraph properties. + * + * WARNING: This function does NOT preserve multiple runs with different styles. + * Use setParagraphTextPreservingStyles() for cells with multiple styled runs. */ export function setParagraphTextMinimal(p: Element, text: string): void { const tNodes = p.getElementsByTagName('w:t'); @@ -237,6 +285,98 @@ export function setParagraphTextMinimal(p: Element, text: string): void { } } +/** + * Replace paragraph text while preserving all run styles. + * + * This function preserves the structure of all runs (w:r) and their + * properties (w:rPr), distributing the new text across existing runs. + * + * Strategy: + * 1. Collect all runs with their properties + * 2. Distribute new text across runs (preserving run count and styles) + * 3. If new text is longer, extend the last run + * 4. If new text is shorter, clear excess runs but keep their structure + */ +export function setParagraphTextPreservingStyles(p: Element, text: string): void { + const doc = p.ownerDocument; + if (!doc) return; + + // Work on ALL descendants, not just direct children. + // This covers runs inside hyperlinks, smart tags, etc. + const tNodes = p.getElementsByTagName('w:t'); + + if (tNodes.length === 0) { + // No text nodes exist - create a minimal run + text. + const r = doc.createElement('w:r'); + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = text; + r.appendChild(t); + p.appendChild(r); + return; + } + + // First text node gets the NEW text. + const first = tNodes.item(0) as Element; + first.textContent = text; + first.setAttribute('xml:space', 'preserve'); + + // All other text nodes are cleared, but their runs (and w:rPr) remain, + // so formatting structures are preserved while old text disappears. + for (let i = 1; i < tNodes.length; i++) { + const t = tNodes.item(i); + if (t) t.textContent = ''; + } +} + +/** + * Replace cell text while preserving ALL paragraphs and their styles. + * + * This function works at the cell level: + * - Preserves ALL paragraphs in the cell (doesn't remove any) + * - Updates text in the first paragraph while preserving its styles + * - Keeps all other paragraphs intact with their original text and styles + * + * This ensures that cells with multiple paragraphs (each with different + * styles, font sizes, etc.) maintain their structure after text replacement. + * + * Example: If a cell has: + * - Paragraph 1: "LAWN AND LANDSCAPE" (Heading1 style, large font, red color) + * - Paragraph 2: "Take your weekends back..." (Normal style, smaller font, black color) + * + * Replacing with "EARTH AND MOUNTAIN" will: + * - Update paragraph 1 to "EARTH AND MOUNTAIN" (preserving Heading1 style, large font, red color) + * - Keep paragraph 2 completely intact with its original text and style + */ +export function setCellTextPreservingStyles(tc: Element, text: string): void { + // Convert NodeList to array to avoid live NodeList issues + const paragraphs = nodeListToArray(tc.getElementsByTagName('w:p')) as Element[]; + + if (paragraphs.length === 0) { + // Cell has no paragraphs - create one + const doc = tc.ownerDocument; + if (!doc) return; + + const p = doc.createElement('w:p'); + const r = doc.createElement('w:r'); + const t = doc.createElement('w:t'); + t.setAttribute('xml:space', 'preserve'); + t.textContent = text; + r.appendChild(t); + p.appendChild(r); + tc.appendChild(p); + return; + } + + // Update first paragraph with new text, preserving all its styles + // This function preserves all runs and their properties (colors, bold, italic, etc.) + setParagraphTextPreservingStyles(paragraphs[0], text); + + // CRITICAL: All other paragraphs remain completely untouched + // They keep their original text, styles, runs, and all formatting + // This ensures multi-paragraph cells maintain their full structure +} + // ═══════════════════════════════════════════════════════════════════════ // Run-level formatting helpers // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/tools/docx/index.ts b/src/tools/docx/index.ts index 6ff779d5..fb8d361e 100644 --- a/src/tools/docx/index.ts +++ b/src/tools/docx/index.ts @@ -14,6 +14,7 @@ export type { DocxContentParagraph, DocxContentTable, DocxContentImage, + DocxTableCellContent, } from './types.js'; // Legacy functions (used by read_file, write_file, edit_block handlers) diff --git a/src/tools/docx/ops/replace-paragraph-text-exact.ts b/src/tools/docx/ops/replace-paragraph-text-exact.ts index f96ac045..878091ea 100644 --- a/src/tools/docx/ops/replace-paragraph-text-exact.ts +++ b/src/tools/docx/ops/replace-paragraph-text-exact.ts @@ -4,14 +4,21 @@ * Find FIRST paragraph whose trimmed text === `from` **anywhere in the body**, * including paragraphs inside table cells, content controls, etc. * - * Replace only the first w:t with `to`; clear other w:t nodes. - * Does NOT remove/recreate runs or paragraph properties. + * Replacement behavior: + * - Replaces the matched paragraph's text while preserving all its run styles + * - Preserves all other paragraphs in the same cell (if the paragraph is in a table cell) + * - Preserves paragraph properties (w:pPr) and run properties (w:rPr) * - * Note: For table cells with multiple paragraphs, use `replace_table_cell_text` - * instead, which matches the full cell text (all paragraphs joined). + * This is useful when you want to replace a specific paragraph by its exact text, + * especially in table cells where you want to replace one paragraph while keeping + * others intact. For example, replacing "LAWN AND LANDSCAPE" with "EARTH AND MOUNTAIN" + * in a cell that also contains a subtitle paragraph will preserve the subtitle. + * + * Note: For replacing entire cell content (matching by full cell text), use + * `replace_table_cell_text` instead. */ -import { getParagraphText, setParagraphTextMinimal, nodeListToArray } from '../dom.js'; +import { getParagraphText, setParagraphTextPreservingStyles, nodeListToArray } from '../dom.js'; import type { ReplaceParagraphTextExactOp, OpResult } from '../types.js'; export function applyReplaceParagraphTextExact( @@ -29,7 +36,8 @@ export function applyReplaceParagraphTextExact( const paragraphText = getParagraphText(p).trim(); if (paragraphText === target) { - setParagraphTextMinimal(p, op.to); + // Preserve all run styles (colors, bold, italic, etc.) when replacing + setParagraphTextPreservingStyles(p, op.to); return { op, status: 'applied', matched: 1 }; } } diff --git a/src/tools/docx/ops/replace-table-cell-text.ts b/src/tools/docx/ops/replace-table-cell-text.ts index 2ae518cc..6079b992 100644 --- a/src/tools/docx/ops/replace-table-cell-text.ts +++ b/src/tools/docx/ops/replace-table-cell-text.ts @@ -1,33 +1,38 @@ /** * Op: replace_table_cell_text * - * Find a table cell whose full text content (all paragraphs joined) matches `from`, - * and replace it with `to`. + * Goal: Replace the "logical value" of a cell while preserving layout and styles. * - * This operation searches through all tables in the document and finds the first - * cell whose text matches. It replaces the text in the first paragraph of that cell. + * Matching strategy (tried in order): + * 1. Match by full cell text (all paragraphs joined with spaces) + * 2. Match by first paragraph text only * - * This is useful when you've read table content using readDocxOutline and want to - * replace specific cell values by their text content. + * When the caller passes FULL cell text in `from` / `to` (common for LLMs), we + * interpret the change like this: + * + * from: " " + * to: " " + * + * i.e. only the *title* (first paragraph) changed, the rest of the cell content + * stayed the same. We detect the unchanged suffix and compute NEW_TITLE by + * stripping that suffix from `to`. Then we only change the first paragraph text, + * keeping all other paragraphs (subtitle, etc.) exactly as they were. + * + * If we cannot safely detect that pattern, we fall back to treating `from`/`to` + * as simple first‑paragraph values. */ -import { getBodyChildren, nodeListToArray, getCellText, setParagraphTextMinimal } from '../dom.js'; +import { getAllBodyTables, nodeListToArray, getCellText, getParagraphText, setCellTextPreservingStyles } from '../dom.js'; import type { ReplaceTableCellTextOp, OpResult } from '../types.js'; export function applyReplaceTableCellText( body: Element, op: ReplaceTableCellTextOp, ): OpResult { - const children = getBodyChildren(body); const target = op.from.trim(); - // Find all tables - const tables: Element[] = []; - for (const child of children) { - if (child.nodeName === 'w:tbl') { - tables.push(child); - } - } + // Find all logical tables in the body, including those inside SDTs. + const tables = getAllBodyTables(body); // Search through all tables for (const table of tables) { @@ -49,40 +54,43 @@ export function applyReplaceTableCellText( } for (const cell of cells) { - // Get full cell text (all paragraphs joined) const cellText = getCellText(cell).trim(); + // Strategy 1: full cell text match — try to detect a "title-only" change if (cellText === target) { - // Find first paragraph in cell and replace its text const paragraphs = cell.getElementsByTagName('w:p'); if (paragraphs.length > 0) { const firstP = paragraphs.item(0) as Element; - setParagraphTextMinimal(firstP, op.to); + const firstPText = getParagraphText(firstP).trim(); - // Clear other paragraphs in the cell (optional - keeps cell structure) - for (let i = 1; i < paragraphs.length; i++) { - const p = paragraphs.item(i) as Element; - const tNodes = p.getElementsByTagName('w:t'); - for (let j = 0; j < tNodes.length; j++) { - tNodes.item(j)!.textContent = ''; - } + // Cell's "suffix" is everything after the first paragraph text + const suffixFrom = cellText.slice(firstPText.length).trimStart(); + + const toTrimmed = op.to.trim(); + let newFirstText = toTrimmed; + + if (suffixFrom.length > 0 && toTrimmed.endsWith(suffixFrom)) { + // Common LLM pattern: + // from: " " + // to: " " + // Extract "" by removing the unchanged suffix. + newFirstText = toTrimmed + .slice(0, toTrimmed.length - suffixFrom.length) + .trimEnd(); } + setCellTextPreservingStyles(cell, newFirstText); return { op, status: 'applied', matched: 1 }; - } else { - // Cell has no paragraphs - create one - const doc = cell.ownerDocument; - if (!doc) continue; - - const p = doc.createElement('w:p'); - const r = doc.createElement('w:r'); - const t = doc.createElement('w:t'); - t.setAttribute('xml:space', 'preserve'); - t.textContent = op.to; - r.appendChild(t); - p.appendChild(r); - cell.appendChild(p); + } + } + // Strategy 2: match by first paragraph text only + const paragraphs = cell.getElementsByTagName('w:p'); + if (paragraphs.length > 0) { + const firstP = paragraphs.item(0) as Element; + const firstParagraphText = getParagraphText(firstP).trim(); + if (firstParagraphText === target) { + setCellTextPreservingStyles(cell, op.to); return { op, status: 'applied', matched: 1 }; } } diff --git a/src/tools/docx/ops/table-set-cell-text.ts b/src/tools/docx/ops/table-set-cell-text.ts index a73e911a..ac57569c 100644 --- a/src/tools/docx/ops/table-set-cell-text.ts +++ b/src/tools/docx/ops/table-set-cell-text.ts @@ -6,32 +6,22 @@ * Applies minimal text replacement inside the cell's first paragraph. */ -import { getBodyChildren, nodeListToArray, setParagraphTextMinimal } from '../dom.js'; +import { getAllBodyTables, nodeListToArray, setCellTextPreservingStyles } from '../dom.js'; import type { TableSetCellTextOp, OpResult } from '../types.js'; export function applyTableSetCellText( body: Element, op: TableSetCellTextOp, ): OpResult { - const children = getBodyChildren(body); + // Find the n‑th logical table in the body, including tables inside SDTs. + const tables = getAllBodyTables(body); - // Find the n-th w:tbl - let tableCount = 0; - let table: Element | null = null; - for (const child of children) { - if (child.nodeName === 'w:tbl') { - if (tableCount === op.tableIndex) { - table = child; - break; - } - tableCount++; - } - } - - if (!table) { + if (op.tableIndex < 0 || op.tableIndex >= tables.length) { return { op, status: 'skipped', matched: 0, reason: 'table_not_found' }; } + const table = tables[op.tableIndex]; + // Find the n-th w:tr const rows: Element[] = []; for (const child of nodeListToArray(table.childNodes)) { @@ -58,32 +48,8 @@ export function applyTableSetCellText( const cell = cells[op.col]; - // Find first w:p inside the cell and apply minimal text replacement - for (const child of nodeListToArray(cell.childNodes)) { - if (child.nodeType === 1 && (child as Element).nodeName === 'w:p') { - const p = child as Element; - const tNodes = p.getElementsByTagName('w:t'); - - if (tNodes.length > 0) { - // Existing runs — use minimal replacement - setParagraphTextMinimal(p, op.text); - } else { - // Empty cell — create a run - const doc = cell.ownerDocument; - if (!doc) return { op, status: 'skipped', matched: 0, reason: 'no_owner_document' }; - - const r = doc.createElement('w:r'); - const t = doc.createElement('w:t'); - t.setAttribute('xml:space', 'preserve'); - t.textContent = op.text; - r.appendChild(t); - p.appendChild(r); - } - - return { op, status: 'applied', matched: 1 }; - } - } - - return { op, status: 'skipped', matched: 0, reason: 'no_paragraph_in_cell' }; + // Replace cell text while preserving ALL styles (colors, bold, italic, etc.) + setCellTextPreservingStyles(cell, op.text); + return { op, status: 'applied', matched: 1 }; } diff --git a/src/tools/docx/read.ts b/src/tools/docx/read.ts index 5d0f19d2..677e76f5 100644 --- a/src/tools/docx/read.ts +++ b/src/tools/docx/read.ts @@ -20,6 +20,7 @@ import { getParagraphStyle, getBody, getBodyChildren, + getAllBodyTables, countTables, countImages, getTableContent, @@ -163,7 +164,7 @@ export async function readDocxOutline(filePath: string): Promise } } } else if (child.nodeName === 'w:tbl') { - // Extract table content + // Extract table content (direct table in body) const tableContent = getTableContent(child); const style = getTableStyle(child); @@ -177,6 +178,33 @@ export async function readDocxOutline(filePath: string): Promise rows: tableContent.rows, }); tableIndex++; + } else if (child.nodeName === 'w:sdt') { + // Structured document tag: look inside w:sdtContent for tables that + // are logically at this body position. + const sdtContent = child.getElementsByTagName('w:sdtContent').item(0); + if (sdtContent) { + for (const sdtChild of nodeListToArray(sdtContent.childNodes)) { + if ( + sdtChild.nodeType === 1 && + (sdtChild as Element).nodeName === 'w:tbl' + ) { + const tbl = sdtChild as Element; + const tableContent = getTableContent(tbl); + const style = getTableStyle(tbl); + + if (style) stylesSet.add(style); + + tables.push({ + bodyChildIndex: i, + tableIndex, + style, + headers: tableContent.headers, + rows: tableContent.rows, + }); + tableIndex++; + } + } + } } } @@ -187,7 +215,9 @@ export async function readDocxOutline(filePath: string): Promise images, stylesSeen: [...stylesSet].sort(), counts: { - tables: countTables(children), + // Table count should reflect all logical tables, including those + // wrapped in SDTs, so we reuse the same helper used by ops. + tables: getAllBodyTables(body).length, images: countImages(body), bodyChildren: children.length, }, diff --git a/src/tools/docx/types.ts b/src/tools/docx/types.ts index 1b068010..b7188b8b 100644 --- a/src/tools/docx/types.ts +++ b/src/tools/docx/types.ts @@ -262,10 +262,19 @@ export interface DocxContentParagraph { style?: string | null; } +/** + * Cell content can be: + * - A string (simple case, creates one paragraph) + * - An array of paragraphs (allows multiple paragraphs with different styles per cell) + */ +export type DocxTableCellContent = string | DocxContentParagraph[]; + export interface DocxContentTable { type: 'table'; - headers?: string[]; - rows: string[][]; + /** Header cells - can be strings or arrays of paragraphs */ + headers?: DocxTableCellContent[]; + /** Data rows - each cell can be a string or array of paragraphs */ + rows: DocxTableCellContent[][]; colWidths?: number[]; style?: string; } diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index a33add89..f730ede2 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -219,10 +219,16 @@ const DocxContentParagraphSchema = z.object({ style: z.string().nullable().optional(), }); +// Cell content: string (simple) or array of paragraphs (multiple styles) +const DocxTableCellContentSchema = z.union([ + z.string(), + z.array(DocxContentParagraphSchema), +]); + const DocxContentTableSchema = z.object({ type: z.literal('table'), - headers: z.array(z.string()).optional(), - rows: z.array(z.array(z.string())), + headers: z.array(DocxTableCellContentSchema).optional(), + rows: z.array(z.array(DocxTableCellContentSchema)), colWidths: z.array(z.number()).optional(), style: z.string().optional(), });