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.
@@ -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