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.
- docuhand-0.1.0.dev1/.gitignore +218 -0
- docuhand-0.1.0.dev1/LICENSE +21 -0
- docuhand-0.1.0.dev1/PKG-INFO +14 -0
- docuhand-0.1.0.dev1/README.md +2 -0
- docuhand-0.1.0.dev1/docs/pitfalls.md +207 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_01.doc +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_01.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_02.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_03.doc +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_03.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_04.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_05.doc +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_05.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_06.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_07.doc +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_07.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_08.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_09.doc +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_09.docx +0 -0
- docuhand-0.1.0.dev1/examples/convert-demo/mixed_demo_10.docx +0 -0
- docuhand-0.1.0.dev1/examples/demo-files/DocuHand_Demo.doc +0 -0
- docuhand-0.1.0.dev1/examples/demo-files/DocuHand_Demo.docx +0 -0
- docuhand-0.1.0.dev1/examples/hermes-config.json +8 -0
- docuhand-0.1.0.dev1/pyproject.toml +40 -0
- docuhand-0.1.0.dev1/scripts/check_word.ps1 +8 -0
- docuhand-0.1.0.dev1/scripts/make_demo_docs.py +67 -0
- docuhand-0.1.0.dev1/scripts/make_mixed_dir.py +55 -0
- docuhand-0.1.0.dev1/src/docuhand/__init__.py +3 -0
- docuhand-0.1.0.dev1/src/docuhand/__main__.py +4 -0
- docuhand-0.1.0.dev1/src/docuhand/_compat.py +28 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/__init__.py +38 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/com_thread.py +88 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/com_utils.py +56 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/container_guard.py +166 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/convert_plan.py +43 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/edit_plan.py +71 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/extraction.py +175 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/live_edit.py +202 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/merge_plan.py +58 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/office_app.py +828 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/pdf_plan.py +49 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/template_plan.py +107 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/templating.py +316 -0
- docuhand-0.1.0.dev1/src/docuhand/engine/wd_constants.py +23 -0
- docuhand-0.1.0.dev1/src/docuhand/errors.py +202 -0
- docuhand-0.1.0.dev1/src/docuhand/safety/__init__.py +10 -0
- docuhand-0.1.0.dev1/src/docuhand/safety/allowlist.py +41 -0
- docuhand-0.1.0.dev1/src/docuhand/safety/audit.py +52 -0
- docuhand-0.1.0.dev1/src/docuhand/safety/policy.py +29 -0
- docuhand-0.1.0.dev1/src/docuhand/server.py +221 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/__init__.py +7 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/convert.py +228 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/edit_open.py +92 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/export_pdf.py +92 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/extract.py +63 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/fill_template.py +115 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/inspect.py +57 -0
- docuhand-0.1.0.dev1/src/docuhand/tools/merge.py +143 -0
- docuhand-0.1.0.dev1/tests/__init__.py +0 -0
- docuhand-0.1.0.dev1/tests/conftest.py +33 -0
- docuhand-0.1.0.dev1/tests/integration/__init__.py +0 -0
- docuhand-0.1.0.dev1/tests/integration/test_convert_e2e.py +229 -0
- docuhand-0.1.0.dev1/tests/integration/test_e2e_mcp.py +94 -0
- docuhand-0.1.0.dev1/tests/integration/test_tools_e2e.py +271 -0
- docuhand-0.1.0.dev1/tests/unit/__init__.py +0 -0
- docuhand-0.1.0.dev1/tests/unit/helpers_cfb.py +123 -0
- docuhand-0.1.0.dev1/tests/unit/test_container_guard.py +124 -0
- docuhand-0.1.0.dev1/tests/unit/test_convert_plan.py +84 -0
- docuhand-0.1.0.dev1/tests/unit/test_detect_encryption.py +97 -0
- docuhand-0.1.0.dev1/tests/unit/test_new_tools.py +148 -0
- docuhand-0.1.0.dev1/tests/unit/test_pure.py +95 -0
- 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 & 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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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,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())
|