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,828 @@
|
|
|
1
|
+
"""Office engine: probe Word/WPS via COM, failover, and inspect_document.
|
|
2
|
+
|
|
3
|
+
Threading contract: every method that touches COM runs on the STA thread
|
|
4
|
+
owned by :class:`~docuhand.engine.com_thread.ComWorker` (via submit).
|
|
5
|
+
Only plain-attribute methods (``invalidate``) may run on other threads.
|
|
6
|
+
|
|
7
|
+
Engine selection (architecture decision #6): probe order is
|
|
8
|
+
``Word.Application`` → ``KWPS.Application`` by default, overridable with
|
|
9
|
+
``DOCUHAND_ENGINE=word|wps``. If an engine dies mid-call (RPC crash —
|
|
10
|
+
seen in production when Word drives legacy .doc on some machines) the
|
|
11
|
+
engine is discarded and the next one takes over transparently.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
import win32com.client
|
|
23
|
+
|
|
24
|
+
from ..errors import (
|
|
25
|
+
DocuhandError,
|
|
26
|
+
EngineUnavailableError,
|
|
27
|
+
FileNotFoundError,
|
|
28
|
+
OpenFailedError,
|
|
29
|
+
PasswordProtectedError,
|
|
30
|
+
)
|
|
31
|
+
from .container_guard import detect_encryption, validate_container
|
|
32
|
+
from .templating import (
|
|
33
|
+
_FillOutcome,
|
|
34
|
+
_fill_open_document,
|
|
35
|
+
_final_far_east_sweep,
|
|
36
|
+
_has_cjk,
|
|
37
|
+
)
|
|
38
|
+
from .com_utils import (
|
|
39
|
+
_g,
|
|
40
|
+
_looks_encrypted,
|
|
41
|
+
_looks_like_arg_mismatch,
|
|
42
|
+
_short_exc,
|
|
43
|
+
file_claims_encrypted,
|
|
44
|
+
)
|
|
45
|
+
from .pdf_plan import parse_page_range # noqa: F401 (re-exported for tools layer)
|
|
46
|
+
from .wd_constants import (
|
|
47
|
+
WD_ALERTS_NONE,
|
|
48
|
+
WD_DO_NOT_SAVE_CHANGES,
|
|
49
|
+
WD_EXPORT_ALL_DOCUMENT,
|
|
50
|
+
WD_EXPORT_FORMAT_PDF,
|
|
51
|
+
WD_EXPORT_FROM_TO,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# --- pure helpers (unit-testable, zero COM) -------------------------------
|
|
55
|
+
|
|
56
|
+
OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
57
|
+
ZIP_MAGIC = b"PK\x03\x04"
|
|
58
|
+
FILE_ATTRIBUTE_READONLY = 0x1
|
|
59
|
+
|
|
60
|
+
FRIENDLY_FORMATS = {
|
|
61
|
+
"ole2": "legacy .doc (OLE2 Compound File)",
|
|
62
|
+
"zip": "zip / OOXML (.docx family)",
|
|
63
|
+
"rtf": "RTF",
|
|
64
|
+
"html": "HTML",
|
|
65
|
+
"text": "plain text",
|
|
66
|
+
"unknown": "unknown",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_EXT_TO_FORMAT = {
|
|
70
|
+
".doc": "ole2",
|
|
71
|
+
".dot": "ole2",
|
|
72
|
+
".docx": "zip",
|
|
73
|
+
".dotx": "zip",
|
|
74
|
+
".rtf": "rtf",
|
|
75
|
+
".txt": "text",
|
|
76
|
+
".htm": "html",
|
|
77
|
+
".html": "html",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Word constants (locale-independent numbers, not string names)
|
|
81
|
+
WD_STAT_WORDS = 0
|
|
82
|
+
WD_STAT_PAGES = 2
|
|
83
|
+
WD_STAT_PARAGRAPHS = 4
|
|
84
|
+
WD_DO_NOT_SAVE_CHANGES = 0
|
|
85
|
+
WD_ALERTS_NONE = 0
|
|
86
|
+
|
|
87
|
+
_PROTECTION_MODES = {
|
|
88
|
+
-1: "none",
|
|
89
|
+
-2: "comments",
|
|
90
|
+
-3: "form_fields",
|
|
91
|
+
-4: "read_only",
|
|
92
|
+
-5: "tracked_changes",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sniff_format(head: bytes) -> str:
|
|
97
|
+
"""Detect the real container format from the first bytes."""
|
|
98
|
+
if head[:8] == OLE2_MAGIC:
|
|
99
|
+
return "ole2"
|
|
100
|
+
if head[:4] == ZIP_MAGIC:
|
|
101
|
+
return "zip"
|
|
102
|
+
stripped = head.lstrip()[:512].lower()
|
|
103
|
+
if stripped.startswith(b"{\\rtf"):
|
|
104
|
+
return "rtf"
|
|
105
|
+
if b"<html" in stripped or stripped.startswith(b"<!doctype html"):
|
|
106
|
+
return "html"
|
|
107
|
+
if b"\x00" not in head[:512]:
|
|
108
|
+
try:
|
|
109
|
+
head[:512].decode("utf-8")
|
|
110
|
+
return "text"
|
|
111
|
+
except UnicodeDecodeError:
|
|
112
|
+
pass
|
|
113
|
+
return "unknown"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_matches_extension(extension: str, detected: str) -> bool:
|
|
117
|
+
return _EXT_TO_FORMAT.get(extension.lower()) == detected
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def probe_writable(path: str) -> bool:
|
|
121
|
+
"""Best-effort lock probe: can another writer exist right now?
|
|
122
|
+
|
|
123
|
+
A file opened in Word/WPS is held with share-write denied, so a write
|
|
124
|
+
handle fails. Note: a READONLY *attribute* also denies write handles,
|
|
125
|
+
so callers must exclude that case before calling this a "lock".
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
with open(path, "r+b"):
|
|
129
|
+
pass
|
|
130
|
+
return True
|
|
131
|
+
except OSError:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def owner_lock_file(path: str) -> str | None:
|
|
136
|
+
"""Word/WPS leave a '~$...' owner file next to an open document."""
|
|
137
|
+
p = Path(path)
|
|
138
|
+
try:
|
|
139
|
+
for cand in p.parent.iterdir():
|
|
140
|
+
name = cand.name.lower()
|
|
141
|
+
if not name.startswith("~$"):
|
|
142
|
+
continue
|
|
143
|
+
rest = name[2:]
|
|
144
|
+
target = p.name.lower()
|
|
145
|
+
if target[2:] == rest or target.startswith(rest):
|
|
146
|
+
return cand.name
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _g(fn: Callable[[], Any], default: Any = None) -> Any:
|
|
153
|
+
"""Guard a single metadata probe: one engine lacking a property must
|
|
154
|
+
never fail the whole inspection (the seed of docs/wps-vs-word.md)."""
|
|
155
|
+
try:
|
|
156
|
+
return fn()
|
|
157
|
+
except Exception:
|
|
158
|
+
return default
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _short_exc(exc: BaseException) -> str:
|
|
162
|
+
return f"{type(exc).__name__}: {str(exc)[:200]}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _looks_encrypted(exc: BaseException) -> bool:
|
|
166
|
+
text = str(exc).lower()
|
|
167
|
+
return "password" in text or "密码" in text
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _looks_like_arg_mismatch(exc: BaseException) -> bool:
|
|
171
|
+
return isinstance(exc, TypeError) or "paramet" in str(exc).lower()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# (Historical note: a "deliberately wrong PasswordDocument" guard passed to
|
|
175
|
+
# Documents.Open kills the modal for *genuinely encrypted* files on Word —
|
|
176
|
+
# but on WPS the dynamic dispatch explodes with AttributeError on the
|
|
177
|
+
# unknown kwarg and takes the app object down (RPC_S_SERVER_UNAVAILABLE on
|
|
178
|
+
# the next call). Not portable → not used. See docs/pitfalls.md #9.)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def file_claims_encrypted(path: str) -> bool | None:
|
|
182
|
+
"""Header sniff: can this file really be COM-password-encrypted?
|
|
183
|
+
|
|
184
|
+
True → OLE2 with the FIB fEncrypted bit set: genuinely a password .doc.
|
|
185
|
+
False → container rules it out (plain zip can't be COM-encrypted): a
|
|
186
|
+
"password" error on it means the file is CORRUPT, not encrypted.
|
|
187
|
+
None → OLE2 without the bit — could be a normal .doc or an encrypted
|
|
188
|
+
OOXML wrapper; treat encryption reports as genuine (safe default).
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
with open(path, "rb") as f:
|
|
192
|
+
head = f.read(16)
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
if head[:8] != OLE2_MAGIC:
|
|
196
|
+
return False
|
|
197
|
+
try:
|
|
198
|
+
flags = int.from_bytes(head[0x0A:0x0C], "little")
|
|
199
|
+
return bool(flags & 0x0001)
|
|
200
|
+
except Exception:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def engine_order_names() -> list[str]:
|
|
205
|
+
"""Expose the probe order for tool payloads/tests (pure, no COM)."""
|
|
206
|
+
prefer = os.environ.get("DOCUHAND_ENGINE", "").strip().lower()
|
|
207
|
+
if prefer == "wps":
|
|
208
|
+
return ["wps", "word"]
|
|
209
|
+
return ["word", "wps"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- the engine ------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class OfficeEngine:
|
|
216
|
+
"""Holds the hidden Word/WPS Application object — STA-only."""
|
|
217
|
+
|
|
218
|
+
def __init__(self) -> None:
|
|
219
|
+
self._app: Any = None
|
|
220
|
+
self._engine_name: str | None = None
|
|
221
|
+
self._progid: str | None = None
|
|
222
|
+
self._engine_version: str = "" # e.g. "16.0" (Word) / "11.1.0.1" (WPS)
|
|
223
|
+
|
|
224
|
+
# -- lifecycle ---------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def invalidate(self) -> None:
|
|
227
|
+
"""Drop all engine state after its STA was abandoned (no COM calls)."""
|
|
228
|
+
self._app = None
|
|
229
|
+
self._engine_name = None
|
|
230
|
+
self._progid = None
|
|
231
|
+
self._engine_version = ""
|
|
232
|
+
|
|
233
|
+
def shutdown(self) -> None:
|
|
234
|
+
"""Quit the app — must run on the engine's own STA (via submit)."""
|
|
235
|
+
app = self._app
|
|
236
|
+
self.invalidate()
|
|
237
|
+
if app is not None:
|
|
238
|
+
try:
|
|
239
|
+
app.Quit(WD_DO_NOT_SAVE_CHANGES)
|
|
240
|
+
except Exception:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
def _discard_app(self) -> None:
|
|
244
|
+
"""Quit and forget after a mid-call engine failure (STA-only)."""
|
|
245
|
+
self.shutdown()
|
|
246
|
+
|
|
247
|
+
def _engine_order(self) -> list[tuple[str, str]]:
|
|
248
|
+
prefer = os.environ.get("DOCUHAND_ENGINE", "").strip().lower()
|
|
249
|
+
word_first = [("word", "Word.Application"), ("wps", "KWPS.Application")]
|
|
250
|
+
wps_first = [("wps", "KWPS.Application"), ("word", "Word.Application")]
|
|
251
|
+
if prefer == "wps":
|
|
252
|
+
return wps_first
|
|
253
|
+
if prefer == "word":
|
|
254
|
+
return word_first
|
|
255
|
+
return word_first # default per 开工包 decision #6
|
|
256
|
+
|
|
257
|
+
def _ensure_app(self, progid: str, engine_name: str) -> Any:
|
|
258
|
+
if self._app is not None and self._engine_name == engine_name:
|
|
259
|
+
return self._app
|
|
260
|
+
self.shutdown() # drop any previous engine's app (on this STA)
|
|
261
|
+
app = win32com.client.DispatchEx(progid) # new hidden instance (Launch)
|
|
262
|
+
for attr, value in (("DisplayAlerts", WD_ALERTS_NONE), ("Visible", False)):
|
|
263
|
+
try:
|
|
264
|
+
setattr(app, attr, value)
|
|
265
|
+
except Exception:
|
|
266
|
+
pass # engine differences must not block launch
|
|
267
|
+
try:
|
|
268
|
+
app.Options.ConfirmConversions = False
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
self._app = app
|
|
272
|
+
self._engine_name = engine_name
|
|
273
|
+
self._progid = progid
|
|
274
|
+
try:
|
|
275
|
+
self._engine_version = str(app.Version) # field diagnostics
|
|
276
|
+
except Exception:
|
|
277
|
+
self._engine_version = ""
|
|
278
|
+
return app
|
|
279
|
+
|
|
280
|
+
def inspect_document(self, path: str) -> dict[str, Any]:
|
|
281
|
+
t0 = time.perf_counter()
|
|
282
|
+
p = Path(path).expanduser()
|
|
283
|
+
if not p.exists():
|
|
284
|
+
raise FileNotFoundError(str(p))
|
|
285
|
+
if p.is_dir():
|
|
286
|
+
raise DocuhandError(
|
|
287
|
+
f"Path is a directory, not a document: {p}",
|
|
288
|
+
llm_hint="Pass the full path to a document file, not a folder.",
|
|
289
|
+
details={"path": str(p)},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
size = p.stat().st_size
|
|
293
|
+
# Pre-COM gates (same rationale as convert_document): broken
|
|
294
|
+
# containers trigger a modal corrupt-file dialog; encrypted files
|
|
295
|
+
# trigger a modal password prompt — both hang a headless call.
|
|
296
|
+
ok, reason = validate_container(str(p))
|
|
297
|
+
if not ok:
|
|
298
|
+
raise OpenFailedError(str(p), "container pre-check", f"structurally invalid container: {reason}")
|
|
299
|
+
if detect_encryption(str(p)):
|
|
300
|
+
raise PasswordProtectedError(str(p), "container pre-check")
|
|
301
|
+
with open(p, "rb") as f:
|
|
302
|
+
head = f.read(1024)
|
|
303
|
+
detected = sniff_format(head)
|
|
304
|
+
extension = p.suffix.lower()
|
|
305
|
+
|
|
306
|
+
stat = p.stat()
|
|
307
|
+
readonly_attr = bool(getattr(stat, "st_file_attributes", 0) & FILE_ATTRIBUTE_READONLY)
|
|
308
|
+
writable = probe_writable(str(p))
|
|
309
|
+
is_locked = (not writable) and (not readonly_attr)
|
|
310
|
+
lockfile = owner_lock_file(str(p))
|
|
311
|
+
|
|
312
|
+
order = self._engine_order()
|
|
313
|
+
attempts: list[dict[str, str]] = []
|
|
314
|
+
launch_failures = 0
|
|
315
|
+
|
|
316
|
+
for engine_name, progid in order:
|
|
317
|
+
try:
|
|
318
|
+
app = self._ensure_app(progid, engine_name)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
launch_failures += 1
|
|
321
|
+
attempts.append({"engine": engine_name, "stage": "launch", "error": _short_exc(exc)})
|
|
322
|
+
self._discard_app()
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
info = self._open_and_read(app, engine_name, str(p))
|
|
326
|
+
except PasswordProtectedError:
|
|
327
|
+
raise # failover cannot help; tell the agent immediately
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
attempts.append({"engine": engine_name, "stage": "read", "error": _short_exc(exc)})
|
|
330
|
+
self._discard_app() # engine died or refused — try the next
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
info["ok"] = True
|
|
334
|
+
info["tool"] = "inspect_document"
|
|
335
|
+
info["path"] = str(p)
|
|
336
|
+
info["engine"] = engine_name
|
|
337
|
+
info["engine_version"] = self._engine_version
|
|
338
|
+
info["format"] = {
|
|
339
|
+
"extension": extension or None,
|
|
340
|
+
"detected": detected,
|
|
341
|
+
"detected_friendly": FRIENDLY_FORMATS.get(detected, detected),
|
|
342
|
+
"matches_extension": format_matches_extension(extension, detected),
|
|
343
|
+
"size_bytes": size,
|
|
344
|
+
}
|
|
345
|
+
info["is_locked"] = is_locked
|
|
346
|
+
info["readonly_attribute"] = readonly_attr
|
|
347
|
+
info["owner_lock_file_present"] = lockfile is not None
|
|
348
|
+
info["is_corrupted"] = False
|
|
349
|
+
info["duration_ms"] = round((time.perf_counter() - t0) * 1000)
|
|
350
|
+
return info
|
|
351
|
+
|
|
352
|
+
if launch_failures == len(order):
|
|
353
|
+
raise EngineUnavailableError(attempts)
|
|
354
|
+
raise OpenFailedError(str(p), "word→wps failover exhausted", "; ".join(a["error"] for a in attempts))
|
|
355
|
+
|
|
356
|
+
# -- convert_document (D3–D5) -------------------------------------------
|
|
357
|
+
|
|
358
|
+
def convert_document(self, source: str, target: str, wd_format: int) -> dict[str, Any]:
|
|
359
|
+
"""Convert one file via the real engine; source is never deleted.
|
|
360
|
+
|
|
361
|
+
Same failover contract as inspect_document: try the probe order;
|
|
362
|
+
an engine that dies mid-call is discarded and the next one takes
|
|
363
|
+
over. Runs on the COM STA (via ComWorker.submit) — never call
|
|
364
|
+
directly from the event loop.
|
|
365
|
+
"""
|
|
366
|
+
t0 = time.perf_counter()
|
|
367
|
+
# Absolute paths are mandatory: WPS's Documents.Open fails with
|
|
368
|
+
# "文档打开失败" on relative paths even when the server cwd is right.
|
|
369
|
+
src = Path(source).expanduser().resolve()
|
|
370
|
+
dst = Path(target).expanduser().resolve()
|
|
371
|
+
|
|
372
|
+
if not src.exists():
|
|
373
|
+
raise FileNotFoundError(str(src))
|
|
374
|
+
# Pre-COM gates: (1) structurally broken containers can pop a MODAL
|
|
375
|
+
# "file is corrupt" dialog inside Word/WPS (DisplayAlerts=0 does
|
|
376
|
+
# NOT suppress it); (2) encrypted files pop a modal password prompt
|
|
377
|
+
# that hangs a headless COM call. Both are decided here, without
|
|
378
|
+
# the engine, so the engine only ever sees files it can open.
|
|
379
|
+
ok, reason = validate_container(str(src))
|
|
380
|
+
if not ok:
|
|
381
|
+
raise OpenFailedError(
|
|
382
|
+
str(src),
|
|
383
|
+
"container pre-check",
|
|
384
|
+
f"structurally invalid container: {reason}",
|
|
385
|
+
)
|
|
386
|
+
if detect_encryption(str(src)):
|
|
387
|
+
raise PasswordProtectedError(str(src), "container pre-check")
|
|
388
|
+
if src.resolve() == dst.resolve():
|
|
389
|
+
raise DocuhandError(
|
|
390
|
+
f"Source and target are the same file: {src}",
|
|
391
|
+
llm_hint="convert_documents never overwrites its source; "
|
|
392
|
+
"this indicates an internal planning bug — report it.",
|
|
393
|
+
details={"path": str(src)},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
order = self._engine_order()
|
|
397
|
+
attempts: list[dict[str, str]] = []
|
|
398
|
+
launch_failures = 0
|
|
399
|
+
|
|
400
|
+
for engine_name, progid in order:
|
|
401
|
+
try:
|
|
402
|
+
app = self._ensure_app(progid, engine_name)
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
launch_failures += 1
|
|
405
|
+
attempts.append({"engine": engine_name, "stage": "launch", "error": _short_exc(exc)})
|
|
406
|
+
self._discard_app()
|
|
407
|
+
continue
|
|
408
|
+
try:
|
|
409
|
+
self._open_and_save_as(app, str(src), str(dst), wd_format)
|
|
410
|
+
except PasswordProtectedError:
|
|
411
|
+
raise
|
|
412
|
+
except Exception as exc:
|
|
413
|
+
attempts.append({"engine": engine_name, "stage": "convert", "error": _short_exc(exc)})
|
|
414
|
+
self._discard_app()
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
"ok": True,
|
|
419
|
+
"source": str(src),
|
|
420
|
+
"target": str(dst),
|
|
421
|
+
"engine": engine_name,
|
|
422
|
+
"engine_version": self._engine_version,
|
|
423
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if launch_failures == len(order):
|
|
427
|
+
raise EngineUnavailableError(attempts)
|
|
428
|
+
raise OpenFailedError(str(src), "word→wps failover exhausted", "; ".join(a["error"] for a in attempts))
|
|
429
|
+
|
|
430
|
+
# -- fill_document / export_pdf (D6–D8) ---------------------------------
|
|
431
|
+
|
|
432
|
+
def fill_document(
|
|
433
|
+
self,
|
|
434
|
+
template: str,
|
|
435
|
+
output: str,
|
|
436
|
+
data: dict[str, str],
|
|
437
|
+
mode: str = "auto",
|
|
438
|
+
bookmark_names: list[str] | None = None,
|
|
439
|
+
) -> dict[str, Any]:
|
|
440
|
+
"""Fill one template → one output document. Same failover contract.
|
|
441
|
+
|
|
442
|
+
Opens the template read-only (the template itself is never touched),
|
|
443
|
+
mutates the in-memory copy, applies the NameFarEast final sweep, then
|
|
444
|
+
SaveAs2 to the output path.
|
|
445
|
+
"""
|
|
446
|
+
t0 = time.perf_counter()
|
|
447
|
+
src = Path(template).expanduser().resolve()
|
|
448
|
+
dst = Path(output).expanduser().resolve()
|
|
449
|
+
if not src.exists():
|
|
450
|
+
raise FileNotFoundError(str(src))
|
|
451
|
+
ok, reason = validate_container(str(src))
|
|
452
|
+
if not ok:
|
|
453
|
+
raise OpenFailedError(str(src), "container pre-check", f"structurally invalid container: {reason}")
|
|
454
|
+
if detect_encryption(str(src)):
|
|
455
|
+
raise PasswordProtectedError(str(src), "container pre-check")
|
|
456
|
+
|
|
457
|
+
order = self._engine_order()
|
|
458
|
+
attempts: list[dict[str, str]] = []
|
|
459
|
+
launch_failures = 0
|
|
460
|
+
|
|
461
|
+
for engine_name, progid in order:
|
|
462
|
+
try:
|
|
463
|
+
app = self._ensure_app(progid, engine_name)
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
launch_failures += 1
|
|
466
|
+
attempts.append({"engine": engine_name, "stage": "launch", "error": _short_exc(exc)})
|
|
467
|
+
self._discard_app()
|
|
468
|
+
continue
|
|
469
|
+
try:
|
|
470
|
+
outcome = self._open_fill_save(
|
|
471
|
+
app, str(src), str(dst), data, mode, bookmark_names or []
|
|
472
|
+
)
|
|
473
|
+
except PasswordProtectedError:
|
|
474
|
+
raise
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
attempts.append({"engine": engine_name, "stage": "fill", "error": _short_exc(exc)})
|
|
477
|
+
self._discard_app()
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
payload: dict[str, Any] = {
|
|
481
|
+
"ok": True,
|
|
482
|
+
"template": str(src),
|
|
483
|
+
"output": str(dst),
|
|
484
|
+
"engine": engine_name,
|
|
485
|
+
"engine_version": self._engine_version,
|
|
486
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
487
|
+
"mode": mode,
|
|
488
|
+
}
|
|
489
|
+
payload.update(
|
|
490
|
+
{
|
|
491
|
+
"filled_keys": sorted(set(outcome.filled)),
|
|
492
|
+
"missing_keys": sorted(set(outcome.missing)),
|
|
493
|
+
"content_controls_filled": sorted(set(outcome.controls_ok)),
|
|
494
|
+
"content_controls_failed": sorted(set(outcome.controls_bad)),
|
|
495
|
+
"leftover_placeholders": outcome.leftovers,
|
|
496
|
+
"bookmarks_in_template": outcome.bookmarks_seen,
|
|
497
|
+
"output_size_bytes": dst.stat().st_size if dst.exists() else None,
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
return payload
|
|
501
|
+
|
|
502
|
+
if launch_failures == len(order):
|
|
503
|
+
raise EngineUnavailableError(attempts)
|
|
504
|
+
raise OpenFailedError(str(src), "word→wps failover exhausted", "; ".join(a["error"] for a in attempts))
|
|
505
|
+
|
|
506
|
+
def export_pdf(self, path: str, output: str, page_from: int | None, page_to: int | None) -> dict[str, Any]:
|
|
507
|
+
"""Export print-fidelity PDF via the real layout engine.
|
|
508
|
+
|
|
509
|
+
Uses ExportAsFixedFormat (Word ≥ 2007 SP2, WPS supports it) with a
|
|
510
|
+
SaveAs(FileFormat=17) fallback for exotic engines. Source document
|
|
511
|
+
is opened read-only and never modified.
|
|
512
|
+
"""
|
|
513
|
+
t0 = time.perf_counter()
|
|
514
|
+
src = Path(path).expanduser().resolve()
|
|
515
|
+
dst = Path(output).expanduser().resolve()
|
|
516
|
+
if not src.exists():
|
|
517
|
+
raise FileNotFoundError(str(src))
|
|
518
|
+
ok, reason = validate_container(str(src))
|
|
519
|
+
if not ok:
|
|
520
|
+
raise OpenFailedError(str(src), "container pre-check", f"structurally invalid container: {reason}")
|
|
521
|
+
if detect_encryption(str(src)):
|
|
522
|
+
raise PasswordProtectedError(str(src), "container pre-check")
|
|
523
|
+
|
|
524
|
+
order = self._engine_order()
|
|
525
|
+
attempts: list[dict[str, str]] = []
|
|
526
|
+
launch_failures = 0
|
|
527
|
+
|
|
528
|
+
for engine_name, progid in order:
|
|
529
|
+
try:
|
|
530
|
+
app = self._ensure_app(progid, engine_name)
|
|
531
|
+
except Exception as exc:
|
|
532
|
+
launch_failures += 1
|
|
533
|
+
attempts.append({"engine": engine_name, "stage": "launch", "error": _short_exc(exc)})
|
|
534
|
+
self._discard_app()
|
|
535
|
+
continue
|
|
536
|
+
try:
|
|
537
|
+
self._open_export_pdf(app, str(src), str(dst), page_from, page_to)
|
|
538
|
+
except PasswordProtectedError:
|
|
539
|
+
raise
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
attempts.append({"engine": engine_name, "stage": "export", "error": _short_exc(exc)})
|
|
542
|
+
self._discard_app()
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
if not dst.exists():
|
|
546
|
+
raise OpenFailedError(str(src), engine_name, "export reported success but no PDF was written")
|
|
547
|
+
return {
|
|
548
|
+
"ok": True,
|
|
549
|
+
"source": str(src),
|
|
550
|
+
"output": str(dst),
|
|
551
|
+
"engine": engine_name,
|
|
552
|
+
"engine_version": self._engine_version,
|
|
553
|
+
"page_range": None if page_from is None else (str(page_from) if page_from == page_to else f"{page_from}-{page_to}"),
|
|
554
|
+
"output_size_bytes": dst.stat().st_size,
|
|
555
|
+
"pdf_verified": self._pdf_header_ok(dst),
|
|
556
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if launch_failures == len(order):
|
|
560
|
+
raise EngineUnavailableError(attempts)
|
|
561
|
+
raise OpenFailedError(str(src), "word→wps failover exhausted", "; ".join(a["error"] for a in attempts))
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _pdf_header_ok(path: Path) -> bool:
|
|
565
|
+
try:
|
|
566
|
+
with open(path, "rb") as f:
|
|
567
|
+
return f.read(5) == b"%PDF-"
|
|
568
|
+
except OSError:
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
def _open_export_pdf(
|
|
572
|
+
self,
|
|
573
|
+
app: Any,
|
|
574
|
+
source: str,
|
|
575
|
+
target: str,
|
|
576
|
+
page_from: int | None,
|
|
577
|
+
page_to: int | None,
|
|
578
|
+
) -> None:
|
|
579
|
+
docs = app.Documents
|
|
580
|
+
try:
|
|
581
|
+
doc = docs.Open(
|
|
582
|
+
FileName=source,
|
|
583
|
+
ConfirmConversions=False,
|
|
584
|
+
ReadOnly=True,
|
|
585
|
+
AddToRecentFiles=False,
|
|
586
|
+
Visible=False,
|
|
587
|
+
)
|
|
588
|
+
except Exception as exc:
|
|
589
|
+
if _looks_encrypted(exc):
|
|
590
|
+
if file_claims_encrypted(source) is not False:
|
|
591
|
+
raise PasswordProtectedError(source, getattr(app, "Name", "engine")) from None
|
|
592
|
+
raise OpenFailedError(source, "engine", _short_exc(exc)) from None
|
|
593
|
+
if not _looks_like_arg_mismatch(exc):
|
|
594
|
+
raise
|
|
595
|
+
doc = docs.Open(source)
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
if page_from is not None:
|
|
599
|
+
export_range, wd_from, wd_to = WD_EXPORT_FROM_TO, page_from, page_to or page_from
|
|
600
|
+
else:
|
|
601
|
+
export_range, wd_from, wd_to = WD_EXPORT_ALL_DOCUMENT, 1, 1
|
|
602
|
+
try:
|
|
603
|
+
doc.ExportAsFixedFormat(
|
|
604
|
+
OutputFileName=target,
|
|
605
|
+
ExportFormat=WD_EXPORT_FORMAT_PDF,
|
|
606
|
+
OpenAfterExport=False,
|
|
607
|
+
OptimizeFor=0, # wdExportOptimizeForPrint — print fidelity
|
|
608
|
+
Range=export_range,
|
|
609
|
+
From=wd_from,
|
|
610
|
+
To=wd_to,
|
|
611
|
+
Item=0, # wdExportDocumentContent
|
|
612
|
+
IncludeDocProps=True,
|
|
613
|
+
KeepIRM=True,
|
|
614
|
+
CreateBookmarks=0,
|
|
615
|
+
DocStructureTags=True,
|
|
616
|
+
BitmapMissingFonts=True,
|
|
617
|
+
UseISO19005_1=False,
|
|
618
|
+
)
|
|
619
|
+
except Exception as exc:
|
|
620
|
+
if isinstance(exc, AttributeError) or _looks_like_arg_mismatch(exc):
|
|
621
|
+
# minimal-signature fallback (old Word / WPS variants)
|
|
622
|
+
doc.SaveAs(target, WD_EXPORT_FORMAT_PDF) # 17 == wdFormatPDF
|
|
623
|
+
else:
|
|
624
|
+
raise
|
|
625
|
+
finally:
|
|
626
|
+
try:
|
|
627
|
+
doc.Close(WD_DO_NOT_SAVE_CHANGES)
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
# -- merge_documents (D13–D14) --------------------------------------------
|
|
632
|
+
|
|
633
|
+
def merge_one(
|
|
634
|
+
self,
|
|
635
|
+
template: str,
|
|
636
|
+
output: str,
|
|
637
|
+
data: dict[str, str],
|
|
638
|
+
) -> dict[str, Any]:
|
|
639
|
+
"""Fill one row into the template → output. Thin wrapper over the
|
|
640
|
+
fill pipeline in 'placeholders+bookmarks' auto mode; raises on
|
|
641
|
+
engine failure (the batch layer catches and isolates per row)."""
|
|
642
|
+
result = self.fill_document(template, output, data, mode="auto", bookmark_names=None)
|
|
643
|
+
problems = result.get("missing_keys") or result.get("leftover_placeholders")
|
|
644
|
+
if problems:
|
|
645
|
+
# per-row fidelity matters in a merge: report, don't hide
|
|
646
|
+
result["row_warning"] = {
|
|
647
|
+
"missing_keys": result.get("missing_keys") or [],
|
|
648
|
+
"leftover_placeholders": result.get("leftover_placeholders") or [],
|
|
649
|
+
}
|
|
650
|
+
return result
|
|
651
|
+
|
|
652
|
+
def _open_fill_save(
|
|
653
|
+
self,
|
|
654
|
+
app: Any,
|
|
655
|
+
source: str,
|
|
656
|
+
target: str,
|
|
657
|
+
data: dict[str, str],
|
|
658
|
+
mode: str,
|
|
659
|
+
bookmark_names: list[str],
|
|
660
|
+
) -> _FillOutcome:
|
|
661
|
+
docs = app.Documents
|
|
662
|
+
try:
|
|
663
|
+
doc = docs.Open(
|
|
664
|
+
FileName=source,
|
|
665
|
+
ConfirmConversions=False,
|
|
666
|
+
ReadOnly=True,
|
|
667
|
+
AddToRecentFiles=False,
|
|
668
|
+
Visible=False,
|
|
669
|
+
)
|
|
670
|
+
except Exception as exc:
|
|
671
|
+
if _looks_encrypted(exc):
|
|
672
|
+
if file_claims_encrypted(source) is not False:
|
|
673
|
+
raise PasswordProtectedError(source, getattr(app, "Name", "engine")) from None
|
|
674
|
+
raise OpenFailedError(source, "engine", _short_exc(exc)) from None
|
|
675
|
+
if not _looks_like_arg_mismatch(exc):
|
|
676
|
+
raise
|
|
677
|
+
doc = docs.Open(source)
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
outcome = _fill_open_document(doc, data, mode, bookmark_names)
|
|
681
|
+
_final_far_east_sweep(doc)
|
|
682
|
+
# wdFormatXMLDocument = 16 for .docx, wdFormatDocument = 0 for .doc
|
|
683
|
+
wd_format = 0 if target.lower().endswith(".doc") else 16
|
|
684
|
+
try:
|
|
685
|
+
doc.SaveAs2(target, FileFormat=wd_format)
|
|
686
|
+
except Exception as exc:
|
|
687
|
+
if isinstance(exc, AttributeError) or _looks_like_arg_mismatch(exc):
|
|
688
|
+
doc.SaveAs(target, wd_format)
|
|
689
|
+
else:
|
|
690
|
+
raise
|
|
691
|
+
# SaveAs2 itself can reset NameFarEast on the (already-saved) doc;
|
|
692
|
+
# re-assert once more so the SAVED bytes carry correct EA fonts.
|
|
693
|
+
_final_far_east_sweep(doc)
|
|
694
|
+
try:
|
|
695
|
+
doc.Save()
|
|
696
|
+
except Exception:
|
|
697
|
+
pass # older engines may not allow Save after SaveAs — best effort
|
|
698
|
+
return outcome
|
|
699
|
+
finally:
|
|
700
|
+
try:
|
|
701
|
+
doc.Close(WD_DO_NOT_SAVE_CHANGES)
|
|
702
|
+
except Exception:
|
|
703
|
+
pass
|
|
704
|
+
|
|
705
|
+
def _open_read_only(self, app: Any, source: str) -> Any:
|
|
706
|
+
"""Open a document read-only in the given app; raise structured errors.
|
|
707
|
+
|
|
708
|
+
Shared by every engine flow. Handles the password-misclassification
|
|
709
|
+
guard and the positional-arg fallback for engines that reject named
|
|
710
|
+
arguments (docs/pitfalls.md #13 family).
|
|
711
|
+
"""
|
|
712
|
+
docs = app.Documents
|
|
713
|
+
try:
|
|
714
|
+
doc = docs.Open(
|
|
715
|
+
FileName=source,
|
|
716
|
+
ConfirmConversions=False,
|
|
717
|
+
ReadOnly=True,
|
|
718
|
+
AddToRecentFiles=False,
|
|
719
|
+
Visible=False,
|
|
720
|
+
)
|
|
721
|
+
except Exception as exc:
|
|
722
|
+
if _looks_encrypted(exc):
|
|
723
|
+
if file_claims_encrypted(source) is not False:
|
|
724
|
+
raise PasswordProtectedError(source, getattr(app, "Name", "engine")) from None
|
|
725
|
+
raise OpenFailedError(source, "engine", _short_exc(exc)) from None
|
|
726
|
+
if not _looks_like_arg_mismatch(exc):
|
|
727
|
+
raise
|
|
728
|
+
doc = docs.Open(source) # minimal fallback for engines that reject named args
|
|
729
|
+
return doc
|
|
730
|
+
|
|
731
|
+
def _open_and_save_as(self, app: Any, source: str, target: str, wd_format: int) -> None:
|
|
732
|
+
docs = app.Documents
|
|
733
|
+
try:
|
|
734
|
+
doc = docs.Open(
|
|
735
|
+
FileName=source,
|
|
736
|
+
ConfirmConversions=False,
|
|
737
|
+
ReadOnly=True,
|
|
738
|
+
AddToRecentFiles=False,
|
|
739
|
+
Visible=False,
|
|
740
|
+
)
|
|
741
|
+
except Exception as exc:
|
|
742
|
+
if _looks_encrypted(exc):
|
|
743
|
+
# A "password" complaint is only trusted when the container
|
|
744
|
+
# can actually be COM-encrypted; otherwise it is Word's
|
|
745
|
+
# confused reaction to a corrupt file (its misclassification
|
|
746
|
+
# is what pops the password dialog we guard against).
|
|
747
|
+
if file_claims_encrypted(source) is not False:
|
|
748
|
+
raise PasswordProtectedError(source, getattr(app, "Name", "engine")) from None
|
|
749
|
+
raise OpenFailedError(source, "engine", _short_exc(exc)) from None
|
|
750
|
+
if not _looks_like_arg_mismatch(exc):
|
|
751
|
+
raise
|
|
752
|
+
doc = docs.Open(source) # minimal fallback for engines that reject named args
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
try:
|
|
756
|
+
doc.SaveAs2(target, FileFormat=wd_format)
|
|
757
|
+
except Exception as exc:
|
|
758
|
+
# Word 2007 and older have no SaveAs2 at all: late binding
|
|
759
|
+
# raises AttributeError at attribute access (not a call
|
|
760
|
+
# TypeError) — so the net must catch AttributeError too.
|
|
761
|
+
if isinstance(exc, AttributeError) or _looks_like_arg_mismatch(exc):
|
|
762
|
+
doc.SaveAs(target, wd_format) # pre-2010 engines
|
|
763
|
+
else:
|
|
764
|
+
raise
|
|
765
|
+
finally:
|
|
766
|
+
try:
|
|
767
|
+
doc.Close(WD_DO_NOT_SAVE_CHANGES)
|
|
768
|
+
except Exception:
|
|
769
|
+
pass
|
|
770
|
+
|
|
771
|
+
# -- per-engine worker (runs inside the try above) ----------------------
|
|
772
|
+
|
|
773
|
+
def _open_and_read(self, app: Any, engine_name: str, path: str) -> dict[str, Any]:
|
|
774
|
+
docs = app.Documents
|
|
775
|
+
try:
|
|
776
|
+
doc = docs.Open(
|
|
777
|
+
FileName=path,
|
|
778
|
+
ConfirmConversions=False,
|
|
779
|
+
ReadOnly=True,
|
|
780
|
+
AddToRecentFiles=False,
|
|
781
|
+
Visible=False,
|
|
782
|
+
)
|
|
783
|
+
except Exception as exc:
|
|
784
|
+
if _looks_encrypted(exc):
|
|
785
|
+
if file_claims_encrypted(path) is not False:
|
|
786
|
+
raise PasswordProtectedError(path, engine_name) from None
|
|
787
|
+
raise OpenFailedError(path, engine_name, _short_exc(exc)) from None
|
|
788
|
+
if not _looks_like_arg_mismatch(exc):
|
|
789
|
+
raise
|
|
790
|
+
doc = docs.Open(path) # minimal fallback for engines that reject named args
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
pages = _g(lambda: doc.ComputeStatistics(WD_STAT_PAGES))
|
|
794
|
+
words = _g(lambda: doc.ComputeStatistics(WD_STAT_WORDS))
|
|
795
|
+
paragraphs = _g(lambda: doc.ComputeStatistics(WD_STAT_PARAGRAPHS))
|
|
796
|
+
protection_raw = _g(lambda: doc.ProtectionType, -1)
|
|
797
|
+
revisions = _g(lambda: doc.Revisions.Count, 0) or 0
|
|
798
|
+
|
|
799
|
+
fields_count = _g(lambda: doc.Fields.Count, 0) or 0
|
|
800
|
+
sample_codes: list[str] = []
|
|
801
|
+
for i in range(min(int(fields_count), 10)):
|
|
802
|
+
code = _g(lambda i=i: re.sub(r"\s+", " ", doc.Fields.Item(i + 1).Code.Text).strip())
|
|
803
|
+
if code:
|
|
804
|
+
sample_codes.append(code)
|
|
805
|
+
|
|
806
|
+
bookmarks_count = _g(lambda: doc.Bookmarks.Count, 0) or 0
|
|
807
|
+
bookmark_names: list[str] = []
|
|
808
|
+
for i in range(min(int(bookmarks_count), 50)):
|
|
809
|
+
name = _g(lambda i=i: doc.Bookmarks.Item(i + 1).Name)
|
|
810
|
+
if name:
|
|
811
|
+
bookmark_names.append(name)
|
|
812
|
+
|
|
813
|
+
protection = protection_raw if isinstance(protection_raw, int) else -1
|
|
814
|
+
return {
|
|
815
|
+
"opened_read_only": True,
|
|
816
|
+
"pages": pages,
|
|
817
|
+
"words": words,
|
|
818
|
+
"paragraphs": paragraphs,
|
|
819
|
+
"protection": _PROTECTION_MODES.get(protection, str(protection_raw)),
|
|
820
|
+
"has_unaccepted_revisions": bool(revisions),
|
|
821
|
+
"bookmarks": {"count": bookmarks_count, "names": bookmark_names},
|
|
822
|
+
"fields": {"count": fields_count, "sample_codes": sample_codes},
|
|
823
|
+
}
|
|
824
|
+
finally:
|
|
825
|
+
try:
|
|
826
|
+
doc.Close(WD_DO_NOT_SAVE_CHANGES)
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|