docuhand 0.1.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. docuhand-0.1.0.dev1/.gitignore +218 -0
  2. docuhand-0.1.0.dev1/LICENSE +21 -0
  3. docuhand-0.1.0.dev1/PKG-INFO +14 -0
  4. docuhand-0.1.0.dev1/README.md +2 -0
  5. docuhand-0.1.0.dev1/docs/pitfalls.md +207 -0
  6. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_01.doc +0 -0
  7. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_01.docx +0 -0
  8. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_02.docx +0 -0
  9. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_03.doc +0 -0
  10. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_03.docx +0 -0
  11. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_04.docx +0 -0
  12. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_05.doc +0 -0
  13. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_05.docx +0 -0
  14. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_06.docx +0 -0
  15. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_07.doc +0 -0
  16. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_07.docx +0 -0
  17. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_08.docx +0 -0
  18. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_09.doc +0 -0
  19. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_09.docx +0 -0
  20. docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_10.docx +0 -0
  21. docuhand-0.1.0.dev1/examples/demo-files/DocuHand_Demo.doc +0 -0
  22. docuhand-0.1.0.dev1/examples/demo-files/DocuHand_Demo.docx +0 -0
  23. docuhand-0.1.0.dev1/examples/hermes-config.json +8 -0
  24. docuhand-0.1.0.dev1/pyproject.toml +40 -0
  25. docuhand-0.1.0.dev1/scripts/check_word.ps1 +8 -0
  26. docuhand-0.1.0.dev1/scripts/make_demo_docs.py +67 -0
  27. docuhand-0.1.0.dev1/scripts/make_mixed_dir.py +55 -0
  28. docuhand-0.1.0.dev1/src/docuhand/__init__.py +3 -0
  29. docuhand-0.1.0.dev1/src/docuhand/__main__.py +4 -0
  30. docuhand-0.1.0.dev1/src/docuhand/_compat.py +28 -0
  31. docuhand-0.1.0.dev1/src/docuhand/engine/__init__.py +38 -0
  32. docuhand-0.1.0.dev1/src/docuhand/engine/com_thread.py +88 -0
  33. docuhand-0.1.0.dev1/src/docuhand/engine/com_utils.py +56 -0
  34. docuhand-0.1.0.dev1/src/docuhand/engine/container_guard.py +166 -0
  35. docuhand-0.1.0.dev1/src/docuhand/engine/convert_plan.py +43 -0
  36. docuhand-0.1.0.dev1/src/docuhand/engine/edit_plan.py +71 -0
  37. docuhand-0.1.0.dev1/src/docuhand/engine/extraction.py +175 -0
  38. docuhand-0.1.0.dev1/src/docuhand/engine/live_edit.py +202 -0
  39. docuhand-0.1.0.dev1/src/docuhand/engine/merge_plan.py +58 -0
  40. docuhand-0.1.0.dev1/src/docuhand/engine/office_app.py +828 -0
  41. docuhand-0.1.0.dev1/src/docuhand/engine/pdf_plan.py +49 -0
  42. docuhand-0.1.0.dev1/src/docuhand/engine/template_plan.py +107 -0
  43. docuhand-0.1.0.dev1/src/docuhand/engine/templating.py +316 -0
  44. docuhand-0.1.0.dev1/src/docuhand/engine/wd_constants.py +23 -0
  45. docuhand-0.1.0.dev1/src/docuhand/errors.py +202 -0
  46. docuhand-0.1.0.dev1/src/docuhand/safety/__init__.py +10 -0
  47. docuhand-0.1.0.dev1/src/docuhand/safety/allowlist.py +41 -0
  48. docuhand-0.1.0.dev1/src/docuhand/safety/audit.py +52 -0
  49. docuhand-0.1.0.dev1/src/docuhand/safety/policy.py +29 -0
  50. docuhand-0.1.0.dev1/src/docuhand/server.py +221 -0
  51. docuhand-0.1.0.dev1/src/docuhand/tools/__init__.py +7 -0
  52. docuhand-0.1.0.dev1/src/docuhand/tools/convert.py +228 -0
  53. docuhand-0.1.0.dev1/src/docuhand/tools/edit_open.py +92 -0
  54. docuhand-0.1.0.dev1/src/docuhand/tools/export_pdf.py +92 -0
  55. docuhand-0.1.0.dev1/src/docuhand/tools/extract.py +63 -0
  56. docuhand-0.1.0.dev1/src/docuhand/tools/fill_template.py +115 -0
  57. docuhand-0.1.0.dev1/src/docuhand/tools/inspect.py +57 -0
  58. docuhand-0.1.0.dev1/src/docuhand/tools/merge.py +143 -0
  59. docuhand-0.1.0.dev1/tests/__init__.py +0 -0
  60. docuhand-0.1.0.dev1/tests/conftest.py +33 -0
  61. docuhand-0.1.0.dev1/tests/integration/__init__.py +0 -0
  62. docuhand-0.1.0.dev1/tests/integration/test_convert_e2e.py +229 -0
  63. docuhand-0.1.0.dev1/tests/integration/test_e2e_mcp.py +94 -0
  64. docuhand-0.1.0.dev1/tests/integration/test_tools_e2e.py +271 -0
  65. docuhand-0.1.0.dev1/tests/unit/__init__.py +0 -0
  66. docuhand-0.1.0.dev1/tests/unit/helpers_cfb.py +123 -0
  67. docuhand-0.1.0.dev1/tests/unit/test_container_guard.py +124 -0
  68. docuhand-0.1.0.dev1/tests/unit/test_convert_plan.py +84 -0
  69. docuhand-0.1.0.dev1/tests/unit/test_detect_encryption.py +97 -0
  70. docuhand-0.1.0.dev1/tests/unit/test_new_tools.py +148 -0
  71. docuhand-0.1.0.dev1/tests/unit/test_pure.py +95 -0
  72. docuhand-0.1.0.dev1/uv.lock +726 -0
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frank Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: docuhand
3
+ Version: 0.1.0.dev1
4
+ Summary: Give your AI agent hands to operate real Word & WPS documents on Windows — an MCP server driving real Office apps via COM.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: mcp<2,>=1.2
9
+ Requires-Dist: olefile>=0.47
10
+ Requires-Dist: pywin32<312,>=306
11
+ Description-Content-Type: text/markdown
12
+
13
+ # docuhand
14
+ Give your AI agent hands to operate real Word &amp; WPS documents on Windows
@@ -0,0 +1,2 @@
1
+ # docuhand
2
+ Give your AI agent hands to operate real Word &amp; WPS documents on Windows
@@ -0,0 +1,207 @@
1
+ # Battle-tested pitfalls
2
+
3
+ Every entry here cost real debugging time on real Chinese-Windows machines.
4
+ You get them pre-solved. (This file grows with every release — it is the
5
+ record of production use, not marketing copy.)
6
+
7
+ ## 1. Built-in style NAMES are locale-dependent; style CONSTANTS are not
8
+
9
+ `doc.Styles("Heading 1")` throws `com_error` on a Chinese-UI Word — the
10
+ style is called `标题 1` there. Use the locale-independent numeric
11
+ constants instead: `wdStyleHeading1 = -2`, `wdStyleNormal = -1`.
12
+ **Rule: drive Word by number, never by display string** (the same reason
13
+ `ComputeStatistics(2)` beats `"wdStatisticPages"`).
14
+
15
+ ## 2. Quitting a driven Word instance can die with RPC_S_CALL_FAILED
16
+
17
+ A scripted `word.Quit()` may raise `(-2147023170, 'The remote procedure
18
+ call failed')` even when every prior call succeeded and the document is
19
+ already safely on disk. Do not let this kill your teardown: treat Quit as
20
+ best-effort (or swallow in `atexit`) and never retry document work against
21
+ an engine that RPC-dropped you — relaunch instead. This is the same crash
22
+ class that motivates DocuHand's per-call fault isolation and
23
+ Word→KWPS failover.
24
+
25
+ ## 3. Drive COM from one dedicated STA thread
26
+
27
+ Calling COM objects from an asyncio event loop thread explodes with
28
+ `RPC_E_WRONG_THREAD`; calling from "a thread per operation" explodes with
29
+ a dozen orphaned WINWORD.EXE processes. One long-lived STA thread with a
30
+ serial work queue fixes both. On timeout, *abandon* the whole thread
31
+ (daemon) rather than trying to interrupt a stuck COM call — and reset any
32
+ engine state that lived on that thread.
33
+
34
+ ## 4. Host MCP clients filter your subprocess environment
35
+
36
+ Hermes (and other MCP hosts) pass stdio servers a **filtered** environment:
37
+ only baseline vars (`PATH`, `SYSTEMROOT`, `APPDATA`, `TEMP`, ...) plus what
38
+ you declare in the server's `env` config. COM survives this fine — but if
39
+ your server needs anything else (e.g. an allowlist env var), it must be
40
+ declared in the MCP server config, not assumed.
41
+
42
+ ## 5. GBK stdio is the invisible bomb on Chinese Windows
43
+
44
+ Python defaults stdout/stderr to the GBK codec there. One un-encodable
45
+ character in a JSON-RPC response (a Chinese path, a curly quote) raises
46
+ `UnicodeEncodeError` and kills the MCP transport. Reconfigure
47
+ `sys.stdout/stderr` to UTF-8 *before the first protocol write* — see
48
+ `docuhand/_compat.py`. Every MCP server on a CJK Windows machine needs
49
+ this; most don't know it yet.
50
+
51
+ ## 6. pythonw.exe makes a cleaner stdio MCP server on Windows
52
+
53
+ Launching the server with `pythonw.exe` (windowless Python) avoids a
54
+ console window flashing per host restart, and stdio transport works
55
+ unchanged — verified with a real MCP client round-trip. Keep `python.exe`
56
+ for your own CLI debugging.
57
+
58
+ ## 7. Lock detection needs three signals, not one
59
+
60
+ A file open in Word/WPS shows: write-handle denial + a `~$xxxx` owner
61
+ lockfile + (sometimes) a read-only attribute. But a read-only *attribute*
62
+ alone also denies write handles — so `is_locked` must exclude the
63
+ attribute case before calling it "open elsewhere", and the owner-lockfile
64
+ name is truncated to 8+3 legacy (`report.doc` → `~$port.doc`).
65
+
66
+ ## 8. A poisoned PYTHONPATH explodes only at the compiled layer — and popping
67
+ ## the env var in conftest is not enough
68
+
69
+ Dev boxes with a global `PYTHONPATH` (this one injects the Hermes install)
70
+ look healthy until two venvs both ship the same package: the foreign
71
+ site-packages sits **ahead of the project venv in `sys.path`**, pure-Python
72
+ imports silently shadow (mcp, pydantic), and then `pydantic_core` dies with
73
+ `ModuleNotFoundError` on a binary `.pyd` version mismatch — far away from
74
+ the actual mistake.
75
+
76
+ Two traps that cost an afternoon:
77
+
78
+ - `os.environ.pop("PYTHONPATH")` **cannot save the parent pytest process**:
79
+ the paths were already expanded into `sys.path` at interpreter startup.
80
+ The env var and the `sys.path` entries must be scrubbed separately.
81
+ - `StdioServerParameters(env=...)` does not help either — the import dies
82
+ in the *pytest parent*, before any child server is spawned.
83
+
84
+ Fix (`tests/conftest.py`): pop the env var (children then inherit a clean
85
+ environment) AND filter every PYTHONPATH entry out of `sys.path` (the
86
+ parent then imports its own venv). Both, once, before any test module
87
+ imports `mcp`.
88
+
89
+ ## 9. A corrupt file can pop a MODAL dialog through COM — headless is a lie
90
+ ### (and every in-COM defense fails somewhere)
91
+
92
+ Feeding a structurally broken file to `Documents.Open` via COM can raise a
93
+ **modal "file is corrupt" dialog** on the user's desktop — even though your
94
+ app is headless and `DisplayAlerts = 0`. Empirical scorecard on a fake-zip
95
+ `.docx`:
96
+
97
+ | defense | result |
98
+ |---|---|
99
+ | `DisplayAlerts = 0` | dialog still appears |
100
+ | `Application.FileValidation = 1` (wdFileValidationSkip) | **88 s hang** on WPS — the skip path itself stalls |
101
+ | `Open(..., PasswordDocument="wrong-pw")` | 3 s clean error on Word; on **WPS the dynamic dispatch explodes with `AttributeError` on the unknown kwarg and the app object dies** (next call: RPC_S_SERVER_UNAVAILABLE) |
102
+ | **validate the container in pure Python BEFORE COM** (`engine/container_guard.py`) | **modal is physically impossible — the engine never sees the trash** |
103
+
104
+ Only the last one is portable across Word and WPS, and it is the only one
105
+ that cannot regress with a new Office version. When a "password" error
106
+ does arrive, trust it only if the container could actually be encrypted
107
+ (check the OLE2 FIB fEncrypted bit at offset 0x0A); otherwise it is Word
108
+ misjudging corruption as encryption — report OPEN_FAILED, not
109
+ "ask the user for the password".
110
+
111
+ **Rule: `Documents.Open` is a window into the user's desktop. Anything
112
+ you can decide without the engine, decide without the engine.**
113
+
114
+ ## 10. `Word.Application` may not be Word — ProgID hijacking on WPS machines
115
+
116
+ On a machine with WPS Office but no MS Word, the `Word.Application` ProgID
117
+ can be **registered by WPS** ( compat takeover). Every COM probe then
118
+ "succeeds with Word" while `'Kingsoft WPS'` sits in the `com_error`
119
+ source. Two consequences:
120
+
121
+ - Never trust `engine == "word"` for reporting unless you verified the
122
+ binary, not just the ProgID.
123
+ - Machine-wide (`HKLM`) registry probing misses per-user WPS installs:
124
+ check `HKCU\Software\Classes\<ProgID>` too, or your "is WPS available?"
125
+ test says no while WPS works fine.
126
+
127
+ ## 11. git-bash heredocs feeding `python -` can hang forever
128
+
129
+ `uv run python <<'EOF' ... EOF` from git-bash/MSYS on Windows may deliver
130
+ stdin in a way `python -` never sees EOF for — the process hangs past its
131
+ own logic with zero output (and stdout redirected to a file shows nothing
132
+ because of block buffering). Write the script to a real file and run it;
133
+ add `flush=True` to progress prints. Same box: PowerShell's `$_` is eaten
134
+ by git-bash too — put PowerShell logic in a `.ps1` file instead.
135
+
136
+ ## 12. WPS's `Documents.Open` rejects RELATIVE paths with a bare failure
137
+
138
+ Hand WPS a relative path (`examples\\dir\\a.doc`) while your server's cwd
139
+ is perfectly correct and it fails with `文档打开失败` (error 3010) — the
140
+ same call with an absolute path succeeds. Word tolerates relative paths;
141
+ WPS does not. Rule: **`Path(...).expanduser().resolve()` every path at the
142
+ engine boundary**, before it can reach COM. The server's cwd is an
143
+ implementation detail the engine must never depend on.
144
+
145
+ ## 13. Old Office compat: a missing COM method raises AttributeError, not
146
+ ## TypeError
147
+
148
+ `SaveAs2` did not exist before Word 2010. On a Word 2007 machine, late
149
+ binding fails at **attribute access** with `AttributeError` — the call
150
+ never happens, so a `TypeError`-shaped fallback net never fires and every
151
+ conversion dies with OPEN_FAILED. Whenever you use a "modern" COM method
152
+ with an old-engine fallback (`SaveAs2→SaveAs`, `ContentControls`→nothing,
153
+ `OpenNoRepairDialog`→`Open`), the fallback must catch **both**
154
+ `AttributeError` (method absent) and the arg-mismatch family (method
155
+ present but rejects the signature). The user's fleet: government machines
156
+ routinely still run Win7 + Office 2007/2010 — treat 2007 as a supported
157
+ floor until the field data says otherwise.
158
+
159
+ ## 14. `Find.Execute(Replace:=wdReplaceAll)` returns True and replaces
160
+ ## NOTHING on WPS-as-Word
161
+
162
+ On this engine family (WPS 12 masquerading as `Word.Application`,
163
+ reporting `Name = "Microsoft Word"`, `Version = 12.0`), the one-call
164
+ `Find.Execute(FindText=..., ReplaceWith=..., Replace=2)` completes
165
+ **successfully** (returns True) yet the text is unchanged. No error, no
166
+ dialog — the Replacement is silently dropped. The portable recipe (probe-
167
+ verified on both the hijacked Word ProgID and real `KWPS.Application`):
168
+
169
+ ```python
170
+ # per hit: FRESH story range + fresh Find, find-only, then set range text
171
+ while True:
172
+ f = doc.Content.Find # fresh Range + Find every iteration
173
+ f.ClearFormatting()
174
+ f.Forward = True
175
+ f.Wrap = 1 # wdFindContinue
176
+ if not f.Execute(token): # find ONLY — never pass Replace here
177
+ break
178
+ f.Parent.Text = value # Parent IS the matched range
179
+ ```
180
+
181
+ Subtleties that each cost a probe cycle:
182
+ - Reusing one `Find` object across hits stops after the **first**
183
+ replacement (its internal range state is consumed by the hit).
184
+ - A fresh `Find` from a *shared* Range object gets 2 of 3 — only a fresh
185
+ `doc.Content` per iteration is reliable.
186
+ - `Wrap=0` (wdFindStop) under-counts; `Wrap=1` (continue) finds all.
187
+ - A value that contains its own token would loop forever — cap iterations
188
+ and never interpolate data into `FindText`.
189
+ - Same family trait: an un-saved doc's `Close()` can kill the whole app
190
+ instance (RPC_S_SERVER_UNAVAILABLE on the next property access) when the
191
+ instance is headless with zero documents. Keep a sentinel doc open
192
+ across multi-step scripts, `doc.Save()` before `Close(0)` when the doc
193
+ was just created, and treat `Quit` as best-effort (pitfall #2).
194
+
195
+ ## 15. `Cell.Text` does not exist on WPS-as-Word — read cells via
196
+ ## `Cell.Range.Text`
197
+
198
+ Reading table cells with `table.Cell(r, c).Text` — the snippet every
199
+ tutorial shows — raises `AttributeError: Cell.Text` on this engine
200
+ family. The portable read is one indirection deeper:
201
+ `table.Cell(r, c).Range.Text`, which returns the cell content with a
202
+ `\r\x07` end-of-cell marker — strip `\x07` and `\r` (markdown-free cells
203
+ matter for JSON output). Verified against a round-tripped legacy `.doc`:
204
+ 2×3 grid with CJK + decimal values reads back byte-perfect. Also: two
205
+ `DispatchEx` launches inside one process can hard-segfault the second
206
+ WPS instance (access violation in win32com) — probe scripts must reuse
207
+ one app instance with a sentinel document.
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "docuhand": {
4
+ "command": "D:/Repositories/docuhand/.venv/Scripts/pythonw.exe",
5
+ "args": ["-m", "docuhand"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "docuhand"
3
+ version = "0.1.0.dev1"
4
+ description = "Give your AI agent hands to operate real Word & WPS documents on Windows — an MCP server driving real Office apps via COM."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ dependencies = [
9
+ # MCP SDK: stay on 1.x — 2.x removed streamablehttp_client and broke
10
+ # HTTP MCP clients (lesson learned in the Hermes venv, 2026-09-10).
11
+ "mcp>=1.2,<2",
12
+ # pywin32: pinned <312 — 312+ breaks MCP tool discovery in the Hermes
13
+ # venv on this machine; 311 is fine for COM automation.
14
+ "olefile>=0.47",
15
+ "pywin32>=306,<312",
16
+ ]
17
+
18
+ [project.scripts]
19
+ docuhand = "docuhand.server:main"
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest>=8"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/docuhand"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ # A poisoned global PYTHONPATH (this dev box injects the Hermes venv) makes
34
+ # a foreign venv's packages shadow our own via sys.path; tests/conftest.py
35
+ # scrubs env + sys.path before any test imports mcp. The `com` marker keeps
36
+ # COM integration tests out of the default run (`-m com` opts in).
37
+ addopts = '-m "not com"'
38
+ markers = [
39
+ "com: integration tests that drive real Word/WPS via COM (run locally with -m com, never in CI)",
40
+ ]
@@ -0,0 +1,8 @@
1
+ $procs = Get-Process WINWORD -ErrorAction SilentlyContinue
2
+ if ($procs) {
3
+ $procs | ForEach-Object { Write-Output ("ORPHAN {0}" -f $_.Id) }
4
+ $procs | Stop-Process -Force
5
+ Write-Output "KILLED"
6
+ } else {
7
+ Write-Output "no WINWORD running"
8
+ }
@@ -0,0 +1,67 @@
1
+ """Generate synthetic demo documents (no real/personal data) via real Word COM.
2
+
3
+ Creates examples/demo-files/DocuHand_Demo.{doc,docx} — the files the
4
+ end-to-end demo and integration tests run against.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pythoncom
13
+ import win32com.client
14
+
15
+ DEMO_DIR = Path(__file__).resolve().parents[1] / "examples" / "demo-files"
16
+ WD_FORMAT_DOC = 0
17
+ WD_FORMAT_DOCX = 16
18
+ WD_DO_NOT_SAVE = 0
19
+ # Built-in style CONSTANTS are locale-independent; style *names* are not
20
+ # ("Heading 1" breaks on Chinese-UI Word — it's "标题 1" there).
21
+ WD_STYLE_NORMAL = -1
22
+ WD_STYLE_HEADING1 = -2
23
+ WD_STYLE_HEADING2 = -3
24
+
25
+ TEXT = [
26
+ (WD_STYLE_HEADING1, "DocuHand Demo Document"),
27
+ (WD_STYLE_NORMAL, "This file is synthetic demo data generated for the DocuHand end-to-end test. It contains no real or personal information."),
28
+ (WD_STYLE_HEADING2, "Section 1: Format stress"),
29
+ (WD_STYLE_NORMAL, "A legacy .doc binary like this one is unreadable to XML-based libraries. DocuHand drives the real application instead."),
30
+ (WD_STYLE_NORMAL, "Chinese text sample: 文档体检 / 格式检测 / 锁定状态 — CJK content must survive every round trip."),
31
+ (WD_STYLE_HEADING2, "Section 2: Structure"),
32
+ (WD_STYLE_NORMAL, "The document carries a bookmark, a field code and multiple paragraphs so inspect_document has something real to report."),
33
+ ]
34
+
35
+
36
+ def main() -> int:
37
+ DEMO_DIR.mkdir(parents=True, exist_ok=True)
38
+ pythoncom.CoInitialize()
39
+ word = win32com.client.DispatchEx("Word.Application")
40
+ word.Visible = False
41
+ word.DisplayAlerts = 0
42
+ try:
43
+ doc = word.Documents.Add()
44
+ sel = word.Selection
45
+ for style, text in TEXT:
46
+ sel.Style = doc.Styles(style)
47
+ sel.TypeText(text)
48
+ sel.TypeParagraph()
49
+ doc.Bookmarks.Add("DemoBookmark", doc.Paragraphs(2).Range)
50
+ doc.Fields.Add(doc.Paragraphs(1).Range, 33) # 33 = wdFieldFileName
51
+
52
+ docx_path = DEMO_DIR / "DocuHand_Demo.docx"
53
+ doc.SaveAs2(str(docx_path), FileFormat=WD_FORMAT_DOCX)
54
+ print(f"[OK] {docx_path}")
55
+
56
+ doc.SaveAs2(str(DEMO_DIR / "DocuHand_Demo.doc"), FileFormat=WD_FORMAT_DOC)
57
+ print(f"[OK] {DEMO_DIR / 'DocuHand_Demo.doc'}")
58
+
59
+ doc.Close(WD_DO_NOT_SAVE)
60
+ finally:
61
+ word.Quit(WD_DO_NOT_SAVE)
62
+ pythoncom.CoUninitialize()
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())