docuhand 0.1.0.dev1__py3-none-any.whl
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/__init__.py +3 -0
- docuhand/__main__.py +4 -0
- docuhand/_compat.py +28 -0
- docuhand/engine/__init__.py +38 -0
- docuhand/engine/com_thread.py +88 -0
- docuhand/engine/com_utils.py +56 -0
- docuhand/engine/container_guard.py +166 -0
- docuhand/engine/convert_plan.py +43 -0
- docuhand/engine/edit_plan.py +71 -0
- docuhand/engine/extraction.py +175 -0
- docuhand/engine/live_edit.py +202 -0
- docuhand/engine/merge_plan.py +58 -0
- docuhand/engine/office_app.py +828 -0
- docuhand/engine/pdf_plan.py +49 -0
- docuhand/engine/template_plan.py +107 -0
- docuhand/engine/templating.py +316 -0
- docuhand/engine/wd_constants.py +23 -0
- docuhand/errors.py +202 -0
- docuhand/safety/__init__.py +10 -0
- docuhand/safety/allowlist.py +41 -0
- docuhand/safety/audit.py +52 -0
- docuhand/safety/policy.py +29 -0
- docuhand/server.py +221 -0
- docuhand/tools/__init__.py +7 -0
- docuhand/tools/convert.py +228 -0
- docuhand/tools/edit_open.py +92 -0
- docuhand/tools/export_pdf.py +92 -0
- docuhand/tools/extract.py +63 -0
- docuhand/tools/fill_template.py +115 -0
- docuhand/tools/inspect.py +57 -0
- docuhand/tools/merge.py +143 -0
- docuhand-0.1.0.dev1.dist-info/METADATA +14 -0
- docuhand-0.1.0.dev1.dist-info/RECORD +36 -0
- docuhand-0.1.0.dev1.dist-info/WHEEL +4 -0
- docuhand-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- docuhand-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Edit documents that are currently OPEN in a live Word/WPS instance.
|
|
2
|
+
|
|
3
|
+
The signature differentiator: every other engine method works on its own
|
|
4
|
+
hidden ``DispatchEx`` instance. This one ATTACHES to the instance the user
|
|
5
|
+
is running (``Dispatch`` — the running-object-table lookup — vs Launch)
|
|
6
|
+
and drives the visible document through the same STA worker.
|
|
7
|
+
|
|
8
|
+
Safety model:
|
|
9
|
+
- resolve_document matches by absolute path OR exact window title.
|
|
10
|
+
- The document must already exist in the attached instance's Documents
|
|
11
|
+
collection; we never open files silently in the user's session.
|
|
12
|
+
- ``save`` default ``"ask"`` maps to: do NOT save, report dirty state and
|
|
13
|
+
let the agent ask the user. ``"save"`` saves explicitly as the final op.
|
|
14
|
+
- Attach failure (no live instance) is a structured error, not a fallback
|
|
15
|
+
to a hidden instance — silently editing a file the user cannot see
|
|
16
|
+
would betray the "hands on the visible document" contract.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import pythoncom
|
|
27
|
+
import win32com.client
|
|
28
|
+
|
|
29
|
+
from ..errors import (
|
|
30
|
+
DocuhandError,
|
|
31
|
+
FileNotFoundError,
|
|
32
|
+
InternalError,
|
|
33
|
+
)
|
|
34
|
+
from .com_utils import _g, _short_exc
|
|
35
|
+
from .templating import _restore_font, _snapshot_font
|
|
36
|
+
|
|
37
|
+
WD_DO_NOT_SAVE_CHANGES = 0
|
|
38
|
+
WD_SAVE_CHANGES = -1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _attach_running_app() -> Any:
|
|
42
|
+
"""Attach to the user's running Word/WPS instance (ROT lookup)."""
|
|
43
|
+
pythoncom.CoInitialize()
|
|
44
|
+
try:
|
|
45
|
+
return win32com.client.Dispatch("Word.Application")
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
try:
|
|
49
|
+
return win32com.client.Dispatch("KWPS.Application")
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
raise DocuhandError(
|
|
52
|
+
"No running Word/WPS instance to attach to",
|
|
53
|
+
llm_hint=(
|
|
54
|
+
"edit_open_document works on documents the user has open "
|
|
55
|
+
"right now. If the file is closed, use fill_template / "
|
|
56
|
+
"convert_documents instead; if it should be open, ask the "
|
|
57
|
+
"user to open it first."
|
|
58
|
+
),
|
|
59
|
+
details={"com_error": _short_exc(exc)},
|
|
60
|
+
) from None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_document(app: Any, path_or_title: str) -> tuple[Any, dict[str, Any]]:
|
|
64
|
+
"""Match by absolute path first, then exact/full window title."""
|
|
65
|
+
docs = app.Documents
|
|
66
|
+
count = int(_g(lambda: docs.Count, 0) or 0)
|
|
67
|
+
wanted_path = str(Path(path_or_title).expanduser().resolve()).lower()
|
|
68
|
+
wanted_title = path_or_title.strip().lower()
|
|
69
|
+
|
|
70
|
+
candidates: list[dict[str, Any]] = []
|
|
71
|
+
for i in range(count):
|
|
72
|
+
doc = _g(lambda i=i: docs.Item(i + 1))
|
|
73
|
+
if doc is None:
|
|
74
|
+
continue
|
|
75
|
+
full_name = _g(lambda doc=doc: doc.FullName, "") or ""
|
|
76
|
+
name = _g(lambda doc=doc: doc.Name, "") or ""
|
|
77
|
+
title = _g(lambda doc=doc: doc.ActiveWindow.Caption if doc.Windows.Count else name, "") or name
|
|
78
|
+
candidates.append(
|
|
79
|
+
{
|
|
80
|
+
"doc": doc,
|
|
81
|
+
"full_name": str(full_name),
|
|
82
|
+
"name": str(name),
|
|
83
|
+
"title": str(title),
|
|
84
|
+
"saved": bool(_g(lambda doc=doc: doc.Saved, True)),
|
|
85
|
+
"readonly": bool(_g(lambda doc=doc: doc.ReadOnly, False)),
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for c in candidates:
|
|
90
|
+
if c["full_name"] and c["full_name"].lower() == wanted_path:
|
|
91
|
+
return c["doc"], c
|
|
92
|
+
for c in candidates:
|
|
93
|
+
if wanted_title and wanted_title in (c["title"].lower(), c["name"].lower()):
|
|
94
|
+
return c["doc"], c
|
|
95
|
+
# one more chance: title minus hidden extension variants
|
|
96
|
+
stem = Path(path_or_title).stem.lower()
|
|
97
|
+
for c in candidates:
|
|
98
|
+
if stem and stem == Path(c["name"]).stem.lower():
|
|
99
|
+
return c["doc"], c
|
|
100
|
+
|
|
101
|
+
raise FileNotFoundError(
|
|
102
|
+
f"Document not found among the {count} open document(s): {path_or_title}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _replace_all_two_step(doc: Any, token: str, value: str) -> int:
|
|
107
|
+
"""Pitfall #14 recipe against the LIVE document (see templating.py)."""
|
|
108
|
+
n = 0
|
|
109
|
+
while n < 500:
|
|
110
|
+
find = _g(lambda: doc.Content.Find, None)
|
|
111
|
+
if find is None:
|
|
112
|
+
break
|
|
113
|
+
try:
|
|
114
|
+
find.ClearFormatting()
|
|
115
|
+
find.Forward = True
|
|
116
|
+
find.Wrap = 1
|
|
117
|
+
hit = find.Execute(token)
|
|
118
|
+
except Exception:
|
|
119
|
+
break
|
|
120
|
+
if not hit:
|
|
121
|
+
break
|
|
122
|
+
try:
|
|
123
|
+
find.Parent.Text = value
|
|
124
|
+
n += 1
|
|
125
|
+
except Exception:
|
|
126
|
+
break
|
|
127
|
+
return n
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _apply_operations(doc: Any, operations: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
131
|
+
results: list[dict[str, Any]] = []
|
|
132
|
+
for i, op in enumerate(operations):
|
|
133
|
+
kind = op["op"]
|
|
134
|
+
if kind == "replace_all":
|
|
135
|
+
n = _replace_all_two_step(doc, op["find"], op["replace"])
|
|
136
|
+
results.append({"index": i, "op": kind, "find": op["find"], "replacements": n, "ok": True})
|
|
137
|
+
elif kind == "insert_text":
|
|
138
|
+
where = op["where"]
|
|
139
|
+
text = op["text"]
|
|
140
|
+
if where == "start":
|
|
141
|
+
rng = doc.Range(0, 0)
|
|
142
|
+
snap = _snapshot_font(_g(lambda rng=rng: rng.Font, {}) or {})
|
|
143
|
+
rng.Text = text
|
|
144
|
+
_restore_font(_g(lambda rng=rng: rng.Font, {}) or {}, snap, text)
|
|
145
|
+
else:
|
|
146
|
+
end = doc.Content.End
|
|
147
|
+
rng = doc.Range(end - 1 if end > 0 else 0, end - 1 if end > 0 else 0)
|
|
148
|
+
snap = _snapshot_font(_g(lambda rng=rng: rng.Font, {}) or {})
|
|
149
|
+
rng.Text = text
|
|
150
|
+
_restore_font(_g(lambda rng=rng: rng.Font, {}) or {}, snap, text)
|
|
151
|
+
results.append({"index": i, "op": kind, "where": where, "chars": len(text), "ok": True})
|
|
152
|
+
elif kind == "save":
|
|
153
|
+
doc.Save()
|
|
154
|
+
results.append({"index": i, "op": kind, "ok": True})
|
|
155
|
+
return results
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def edit_open_document(
|
|
159
|
+
path_or_title: str,
|
|
160
|
+
operations: list[dict[str, Any]],
|
|
161
|
+
save: str = "ask",
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""Attach to the user's live instance and edit an OPEN document."""
|
|
164
|
+
t0 = time.perf_counter()
|
|
165
|
+
if save not in ("ask", "save"):
|
|
166
|
+
raise DocuhandError(
|
|
167
|
+
f"save must be 'ask' or 'save' (got {save!r})",
|
|
168
|
+
llm_hint="'ask' leaves the document dirty and reports the state; "
|
|
169
|
+
"'save' writes the changes to disk after the last operation.",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
app = _attach_running_app()
|
|
173
|
+
doc, meta = _resolve_document(app, path_or_title)
|
|
174
|
+
|
|
175
|
+
if meta.get("readonly"):
|
|
176
|
+
raise DocuhandError(
|
|
177
|
+
f"Document is open READ-ONLY in the user's session: {meta['full_name'] or meta['name']}",
|
|
178
|
+
llm_hint="The user opened it read-only (or it is locked). Ask them to reopen it editable first.",
|
|
179
|
+
details={"document": meta["full_name"] or meta["name"]},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
op_results = _apply_operations(doc, operations)
|
|
183
|
+
|
|
184
|
+
dirty = not bool(_g(lambda: doc.Saved, True))
|
|
185
|
+
saved_now = False
|
|
186
|
+
if save == "save":
|
|
187
|
+
doc.Save()
|
|
188
|
+
saved_now = True
|
|
189
|
+
dirty = False
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
"ok": True,
|
|
193
|
+
"tool": "edit_open_document",
|
|
194
|
+
"document": meta["full_name"] or meta["name"],
|
|
195
|
+
"matched_by": "path" if meta["full_name"].lower() == str(Path(path_or_title).expanduser().resolve()).lower() else "title",
|
|
196
|
+
"operations_applied": op_results,
|
|
197
|
+
"saved": saved_now,
|
|
198
|
+
"dirty_after": dirty,
|
|
199
|
+
"engine": _g(lambda: app.Name, "unknown"),
|
|
200
|
+
"engine_version": str(_g(lambda: app.Version, "") or ""),
|
|
201
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
202
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Pure planning for merge_documents — output naming, zero COM.
|
|
2
|
+
|
|
3
|
+
One template + N data rows → N filled files. Naming: a pattern with
|
|
4
|
+
``{i}`` (1-based, zero-padded to 3 for sort order) and any ``{field}``
|
|
5
|
+
tokens from the row data. Everything that can reach a filename is
|
|
6
|
+
sanitized (path separators, reserved characters, length) — a hostile
|
|
7
|
+
data value must not be able to write outside the output directory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_ILLEGAL = '<>:"/\\|?*'
|
|
16
|
+
_COMPONENT_CAP = 40
|
|
17
|
+
_PAD = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sanitize_component(value: str) -> str:
|
|
21
|
+
"""Make one naming component filesystem-safe and short."""
|
|
22
|
+
text = str(value)
|
|
23
|
+
out: list[str] = []
|
|
24
|
+
for ch in text:
|
|
25
|
+
if ch in _ILLEGAL or ord(ch) < 0x20:
|
|
26
|
+
out.append("_")
|
|
27
|
+
else:
|
|
28
|
+
out.append(ch)
|
|
29
|
+
cleaned = re.sub(r"\s+", " ", "".join(out)).strip().strip(".")
|
|
30
|
+
return cleaned[:_COMPONENT_CAP]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_name(pattern: str, index: int, row: dict) -> str:
|
|
34
|
+
"""Substitute {i} and {field} tokens, then sanitize the whole stem.
|
|
35
|
+
|
|
36
|
+
``{i}`` renders zero-padded to 3 digits so directory listings sort in
|
|
37
|
+
row order regardless of how the OS sorts numbers.
|
|
38
|
+
"""
|
|
39
|
+
text = str(pattern or "")
|
|
40
|
+
text = text.replace("{i}", str(index).zfill(_PAD))
|
|
41
|
+
for key, value in row.items():
|
|
42
|
+
text = text.replace("{" + str(key) + "}", sanitize_component(str(value)))
|
|
43
|
+
# the pattern itself is untrusted input too
|
|
44
|
+
return sanitize_component(text) or str(index).zfill(_PAD)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_collision(directory: Path, stem: str, extension: str) -> Path:
|
|
48
|
+
"""First free '<stem>.<ext>', then '<stem>-2', '-3', ... (cap 999)."""
|
|
49
|
+
candidate = directory / f"{stem}{extension}"
|
|
50
|
+
n = 2
|
|
51
|
+
while candidate.exists() and n <= 999:
|
|
52
|
+
candidate = directory / f"{stem}-{n}{extension}"
|
|
53
|
+
n += 1
|
|
54
|
+
return candidate
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def default_output_dir(template: Path) -> Path:
|
|
58
|
+
return template.parent / ("merged_" + template.stem)
|