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
docuhand/__init__.py
ADDED
docuhand/__main__.py
ADDED
docuhand/_compat.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Chinese-Windows stdio fix.
|
|
2
|
+
|
|
3
|
+
On Chinese Windows, Python's stdout/stderr default to the GBK codec. An MCP
|
|
4
|
+
stdio server writes JSON-RPC over stdout, so any non-GBK-encodable character
|
|
5
|
+
(a Chinese path, a curly quote in a document) raises UnicodeEncodeError and
|
|
6
|
+
kills the protocol stream. This is the invisible bomb behind most broken
|
|
7
|
+
MCP servers on CJK Windows machines.
|
|
8
|
+
|
|
9
|
+
We reconfigure both streams to UTF-8 *before* the MCP SDK writes anything.
|
|
10
|
+
Every entrypoint (``docuhand serve``, ``python -m docuhand``) calls this at
|
|
11
|
+
import time of :mod:`docuhand.server`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fix_stdio() -> None:
|
|
20
|
+
for name in ("stdout", "stderr"):
|
|
21
|
+
stream = getattr(sys, name, None)
|
|
22
|
+
if stream is None or not hasattr(stream, "reconfigure"):
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except Exception:
|
|
27
|
+
# Never let a cosmetics fix break startup.
|
|
28
|
+
pass
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Engine package: dedicated COM STA worker + Office engine with Word/WPS failover."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
|
|
7
|
+
from .com_thread import ComWorker
|
|
8
|
+
from .office_app import OfficeEngine, sniff_format
|
|
9
|
+
|
|
10
|
+
_worker = ComWorker()
|
|
11
|
+
_engine = OfficeEngine()
|
|
12
|
+
|
|
13
|
+
# A timed-out STA is abandoned wholesale; engine state that lived on that
|
|
14
|
+
# thread is void — reset it so the next call relaunches cleanly on a fresh
|
|
15
|
+
# STA thread. invalidate() only writes plain attributes (GIL-atomic), it
|
|
16
|
+
# never touches COM objects.
|
|
17
|
+
_worker.set_abandon_hook(_engine.invalidate)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_worker() -> ComWorker:
|
|
21
|
+
return _worker
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_engine() -> OfficeEngine:
|
|
25
|
+
return _engine
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _shutdown() -> None:
|
|
29
|
+
"""Quit the hidden Word/WPS instance on its own STA thread at exit."""
|
|
30
|
+
try:
|
|
31
|
+
_worker.submit(_engine.shutdown, timeout=15.0)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass # process teardown must never hang on a sick COM app
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
atexit.register(_shutdown)
|
|
37
|
+
|
|
38
|
+
__all__ = ["ComWorker", "OfficeEngine", "get_engine", "get_worker", "sniff_format"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Dedicated STA thread for all COM work — architecture decision #1.
|
|
2
|
+
|
|
3
|
+
Why this exists:
|
|
4
|
+
- The MCP server runs on an asyncio event loop; calling COM from it explodes
|
|
5
|
+
with RPC_E_WRONG_THREAD.
|
|
6
|
+
- Naive "one thread per call" COM usage launches a fresh WINWORD.EXE per
|
|
7
|
+
call — the instance explosion.
|
|
8
|
+
- One long-lived single-threaded-apartment thread with a serial work queue
|
|
9
|
+
fixes both: every COM operation happens on the same STA, fully serialized.
|
|
10
|
+
|
|
11
|
+
Timeout policy: a stuck call (Word showing a modal first-run / activation /
|
|
12
|
+
recovery dialog) cannot be interrupted safely. On timeout we *abandon* that
|
|
13
|
+
thread and its queue entirely (daemon thread; dies with the process) and
|
|
14
|
+
lazily build a fresh STA thread on the next submit. Any engine state that
|
|
15
|
+
lived on the abandoned STA is void and is reset via the abandon hook.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import queue
|
|
22
|
+
import threading
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
from ..errors import ComCallTimeoutError
|
|
26
|
+
|
|
27
|
+
_DEFAULT_TIMEOUT_S = float(os.environ.get("DOCUHAND_COM_TIMEOUT", "90"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ComWorker:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
self._queue: queue.SimpleQueue | None = None
|
|
34
|
+
self._thread: threading.Thread | None = None
|
|
35
|
+
self._on_abandon: Callable[[], None] | None = None
|
|
36
|
+
|
|
37
|
+
def set_abandon_hook(self, fn: Callable[[], None]) -> None:
|
|
38
|
+
self._on_abandon = fn
|
|
39
|
+
|
|
40
|
+
def submit(self, fn: Callable[..., Any], /, *args: Any, timeout: float = _DEFAULT_TIMEOUT_S) -> Any:
|
|
41
|
+
"""Run fn(*args) on the STA thread; raise on timeout (and abandon)."""
|
|
42
|
+
with self._lock:
|
|
43
|
+
if self._queue is None or self._thread is None or not self._thread.is_alive():
|
|
44
|
+
self._queue = queue.SimpleQueue()
|
|
45
|
+
self._thread = threading.Thread(
|
|
46
|
+
target=self._run,
|
|
47
|
+
args=(self._queue,),
|
|
48
|
+
name="docuhand-com-sta",
|
|
49
|
+
daemon=True,
|
|
50
|
+
)
|
|
51
|
+
self._thread.start()
|
|
52
|
+
work_queue = self._queue
|
|
53
|
+
|
|
54
|
+
box: queue.SimpleQueue = queue.SimpleQueue()
|
|
55
|
+
work_queue.put((fn, args, box))
|
|
56
|
+
try:
|
|
57
|
+
ok, payload = box.get(timeout=timeout)
|
|
58
|
+
except queue.Empty:
|
|
59
|
+
self._abandon()
|
|
60
|
+
raise ComCallTimeoutError(timeout) from None
|
|
61
|
+
if ok:
|
|
62
|
+
return payload
|
|
63
|
+
raise payload
|
|
64
|
+
|
|
65
|
+
def _abandon(self) -> None:
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._queue = None
|
|
68
|
+
self._thread = None
|
|
69
|
+
if self._on_abandon:
|
|
70
|
+
try:
|
|
71
|
+
self._on_abandon()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _run(work_queue: queue.SimpleQueue) -> None:
|
|
77
|
+
import pythoncom
|
|
78
|
+
|
|
79
|
+
pythoncom.CoInitialize() # STA — the whole point of this thread
|
|
80
|
+
try:
|
|
81
|
+
while True:
|
|
82
|
+
fn, args, box = work_queue.get()
|
|
83
|
+
try:
|
|
84
|
+
box.put((True, fn(*args)))
|
|
85
|
+
except BaseException as exc: # propagate to submit()'s caller
|
|
86
|
+
box.put((False, exc))
|
|
87
|
+
finally:
|
|
88
|
+
pythoncom.CoUninitialize()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared COM guard helpers — leaf module, no engine imports (no cycles).
|
|
2
|
+
|
|
3
|
+
``_g``: guard a single metadata probe — one engine lacking a property must
|
|
4
|
+
never fail the whole operation (the seed of docs/wps-vs-word.md).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _g(fn: Callable[[], Any], default: Any = None) -> Any:
|
|
15
|
+
try:
|
|
16
|
+
return fn()
|
|
17
|
+
except Exception:
|
|
18
|
+
return default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _short_exc(exc: BaseException) -> str:
|
|
22
|
+
return f"{type(exc).__name__}: {str(exc)[:200]}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _looks_encrypted(exc: BaseException) -> bool:
|
|
26
|
+
text = str(exc).lower()
|
|
27
|
+
return "password" in text or "密码" in text
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _looks_like_arg_mismatch(exc: BaseException) -> bool:
|
|
31
|
+
return isinstance(exc, TypeError) or "paramet" in str(exc).lower()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def file_claims_encrypted(path: str) -> bool | None:
|
|
35
|
+
"""Header sniff: can this file really be COM-password-encrypted?
|
|
36
|
+
|
|
37
|
+
True → OLE2 with the FIB fEncrypted bit set: genuinely a password .doc.
|
|
38
|
+
False → container rules it out (plain zip can't be COM-encrypted): a
|
|
39
|
+
"password" error on it means the file is CORRUPT, not encrypted.
|
|
40
|
+
None → OLE2 without the bit — could be a normal .doc or an encrypted
|
|
41
|
+
OOXML wrapper; treat encryption reports as genuine (safe default).
|
|
42
|
+
"""
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with open(Path(path), "rb") as f:
|
|
47
|
+
head = f.read(16)
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
if head[:8] != OLE2_MAGIC:
|
|
51
|
+
return False
|
|
52
|
+
try:
|
|
53
|
+
flags = int.from_bytes(head[0x0A:0x0C], "little")
|
|
54
|
+
return bool(flags & 0x0001)
|
|
55
|
+
except Exception:
|
|
56
|
+
return None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Pre-COM container validation — the deterministic corrupt-file gate.
|
|
2
|
+
|
|
3
|
+
Why this exists (found the hard way, 2026-09-11): handing a structurally
|
|
4
|
+
broken file to Word/WPS via COM can pop a MODAL "file is corrupt" dialog.
|
|
5
|
+
DisplayAlerts=0 does not suppress it; FileValidation=1 (the documented
|
|
6
|
+
"skip validation" switch) made it WORSE on WPS: 88s hang vs 3s clean error
|
|
7
|
+
without it. The only airtight fix is to never hand a broken container to
|
|
8
|
+
the engine: validate the container structure in pure Python first and
|
|
9
|
+
reject trash with a structured OPEN_FAILED before any COM call. A modal
|
|
10
|
+
dialog cannot appear for a file the engine never sees.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import zipfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import olefile
|
|
19
|
+
|
|
20
|
+
# Container magic bytes — defined here (not imported from office_app) to
|
|
21
|
+
# keep the dependency direction one-way: office_app → container_guard.
|
|
22
|
+
OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
23
|
+
ZIP_MAGIC = b"PK\x03\x04"
|
|
24
|
+
|
|
25
|
+
# A valid legacy .doc MUST carry a WordDocument stream (or the 6.x-era
|
|
26
|
+
# WordDocument alias); the WPS writer may additionally expose bindings,
|
|
27
|
+
# but this stream is the constant across Word 97-2019 and WPS.
|
|
28
|
+
_WORD_STREAMS = ("worddocument", "1table", "0table")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_container(path: str) -> tuple[bool, str]:
|
|
32
|
+
"""Return (ok, reason). ok=True → safe to hand to the COM engine.
|
|
33
|
+
|
|
34
|
+
Extension-agnostic: the container decides, not the filename.
|
|
35
|
+
Files that are NOT office containers at all (rtf/txt/html disguised as
|
|
36
|
+
.doc) pass through: the engines open those natively without dialogs.
|
|
37
|
+
"""
|
|
38
|
+
p = Path(path)
|
|
39
|
+
try:
|
|
40
|
+
with open(p, "rb") as f:
|
|
41
|
+
head = f.read(8)
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
return False, f"unreadable: {exc}"
|
|
44
|
+
|
|
45
|
+
if head[:4] == ZIP_MAGIC:
|
|
46
|
+
# OOXML family: a real .docx is a structurally valid zip with
|
|
47
|
+
# [Content_Types].xml. Anything else is corrupt — and Word/WPS may
|
|
48
|
+
# react to it with the modal we must avoid.
|
|
49
|
+
try:
|
|
50
|
+
size = p.stat().st_size
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
return False, f"unreadable: {exc}"
|
|
53
|
+
if size < 60: # a zip EOCD alone is 22B; no real .docx is this small
|
|
54
|
+
return False, "zip container truncated"
|
|
55
|
+
try:
|
|
56
|
+
with zipfile.ZipFile(p) as z:
|
|
57
|
+
bad = z.testzip()
|
|
58
|
+
if bad is not None:
|
|
59
|
+
return False, f"corrupt zip member: {bad}"
|
|
60
|
+
names = set(z.namelist())
|
|
61
|
+
if "[Content_Types].xml".lower() not in {n.lower() for n in names}:
|
|
62
|
+
return False, "zip lacks [Content_Types].xml (not an OOXML document)"
|
|
63
|
+
except NotImplementedError:
|
|
64
|
+
# exotic compression method — unusual but a real, openable docx
|
|
65
|
+
return True, ""
|
|
66
|
+
except zipfile.BadZipFile as exc:
|
|
67
|
+
return False, f"not a valid zip container: {exc}"
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
return False, f"unreadable: {exc}"
|
|
70
|
+
return True, ""
|
|
71
|
+
|
|
72
|
+
if head[:8] == OLE2_MAGIC:
|
|
73
|
+
# Legacy .doc family: must be a parseable compound file carrying a
|
|
74
|
+
# WordDocument stream. 8 bytes of OLE magic + garbage otherwise.
|
|
75
|
+
try:
|
|
76
|
+
size = p.stat().st_size
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
return False, f"unreadable: {exc}"
|
|
79
|
+
if size < 512: # OLE header alone is 512B; no real .doc is smaller
|
|
80
|
+
return False, "OLE2 container truncated"
|
|
81
|
+
try:
|
|
82
|
+
if not olefile.isOleFile(p):
|
|
83
|
+
return False, "OLE2 header present but compound file is malformed"
|
|
84
|
+
with olefile.OleFileIO(p) as ole:
|
|
85
|
+
# listdir() entries are [storage, ..., stream] path tuples
|
|
86
|
+
streams = {
|
|
87
|
+
entry[-1].lower()
|
|
88
|
+
for entry in ole.listdir()
|
|
89
|
+
if entry and isinstance(entry[-1], str)
|
|
90
|
+
}
|
|
91
|
+
if not ({"worddocument"} & streams):
|
|
92
|
+
# Not a Word doc — could be Excel .xls binary or legacy
|
|
93
|
+
# WPS .wps. The engines still open those natively without
|
|
94
|
+
# modals, so pass them through rather than mislabel.
|
|
95
|
+
return True, ""
|
|
96
|
+
# sanity: WordDocument stream must be readable/non-empty
|
|
97
|
+
data = ole.openstream("WordDocument").read(64)
|
|
98
|
+
if not data:
|
|
99
|
+
return False, "WordDocument stream is empty"
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
return False, f"malformed OLE2 compound file: {exc}"
|
|
102
|
+
except Exception as exc: # defensive: olefile raises varied parse errors
|
|
103
|
+
return False, f"OLE2 parse error: {exc}"
|
|
104
|
+
return True, ""
|
|
105
|
+
|
|
106
|
+
# RTF / plain text / HTML / unknown disguised as .doc: the engines
|
|
107
|
+
# convert these through their native converters, no modals observed.
|
|
108
|
+
# (Verified: Word opens truncated-OLE2 as text-repair silently, and
|
|
109
|
+
# plain garbage as an encoded-text recovery — neither prompts.)
|
|
110
|
+
return True, ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- encryption pre-flight ---------------------------------------------------
|
|
114
|
+
|
|
115
|
+
_ENCRYPTED_OOXML_STREAMS = ("encryptedpackage", "encryptedsummaryinformation")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fib_is_encrypted(stream_head: bytes) -> bool:
|
|
119
|
+
"""FIB check on the first bytes of a WordDocument stream.
|
|
120
|
+
|
|
121
|
+
FibBase layout: wIdent must be 0xA5EC (little-endian ``EC A5``); the
|
|
122
|
+
flags word lives at stream offset 0x0A and **fEncrypted = 0x0100**.
|
|
123
|
+
(Gotcha recorded in docs/pitfalls.md #9: the FIB is at the start of
|
|
124
|
+
the WordDocument *stream* — file offset 0x0A is the CFB header's
|
|
125
|
+
CLSID, and the bit is 0x0100, not 0x0001.)
|
|
126
|
+
"""
|
|
127
|
+
if len(stream_head) < 12 or stream_head[0:2] != b"\xec\xa5":
|
|
128
|
+
return False
|
|
129
|
+
flags = int.from_bytes(stream_head[0x0A:0x0C], "little")
|
|
130
|
+
return bool(flags & 0x0100)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect_encryption(path: str) -> bool:
|
|
134
|
+
"""True → the document will demand a password from any engine.
|
|
135
|
+
|
|
136
|
+
Two container-level tells, both deterministic and COM-free:
|
|
137
|
+
- OOXML-style encryption: an OLE2 CFB carrying an ``EncryptedPackage``
|
|
138
|
+
stream (Word's "Encrypt with password" on a .docx produces exactly
|
|
139
|
+
this — the file stops being a zip).
|
|
140
|
+
- Legacy .doc: the FIB inside the WordDocument stream has fEncrypted
|
|
141
|
+
set (see :func:`_fib_is_encrypted`).
|
|
142
|
+
|
|
143
|
+
Non-OLE2 containers (zip/rtf/text) cannot carry a COM password request
|
|
144
|
+
→ False. Parse trouble → False: validate_container already rejects
|
|
145
|
+
structurally broken files before this runs.
|
|
146
|
+
"""
|
|
147
|
+
p = Path(path)
|
|
148
|
+
try:
|
|
149
|
+
with open(p, "rb") as f:
|
|
150
|
+
head = f.read(8)
|
|
151
|
+
except OSError:
|
|
152
|
+
return False
|
|
153
|
+
if head[:8] != OLE2_MAGIC:
|
|
154
|
+
return False
|
|
155
|
+
try:
|
|
156
|
+
if not olefile.isOleFile(p):
|
|
157
|
+
return False
|
|
158
|
+
with olefile.OleFileIO(p) as ole:
|
|
159
|
+
names = {entry[-1].lower() for entry in ole.listdir() if entry}
|
|
160
|
+
if names.intersection(_ENCRYPTED_OOXML_STREAMS):
|
|
161
|
+
return True
|
|
162
|
+
if "worddocument" not in names:
|
|
163
|
+
return False
|
|
164
|
+
return _fib_is_encrypted(ole.openstream("WordDocument").read(16))
|
|
165
|
+
except Exception:
|
|
166
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Pure planning for convert_documents — zero COM, fully unit-testable.
|
|
2
|
+
|
|
3
|
+
Kept out of the tool layer so CI can verify the naming/classification rules
|
|
4
|
+
without an Office installation. The engine only sees finished (source,
|
|
5
|
+
target, wd_format) triples.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# SaveFormat constants — locale-independent numbers (docs/pitfalls.md #1):
|
|
13
|
+
# wdFormatDocument = 0 (legacy .doc), wdFormatXMLDocument = 16 (.docx).
|
|
14
|
+
TO_FORMATS: dict[str, int] = {"doc": 0, "docx": 16}
|
|
15
|
+
SOURCE_EXTENSIONS = {".doc", ".docx"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_to_format(to_format: str) -> str:
|
|
19
|
+
"""Accept 'docx', '.DOCX', ' doc ' → canonical key of TO_FORMATS."""
|
|
20
|
+
fmt = (to_format or "").strip().lower().lstrip(".")
|
|
21
|
+
if fmt not in TO_FORMATS:
|
|
22
|
+
raise ValueError(f"Unsupported to_format: {to_format!r} (use 'docx' or 'doc')")
|
|
23
|
+
return fmt
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_target(source: Path, to_format: str, output_dir: Path | None = None) -> Path:
|
|
27
|
+
"""Same stem, new extension; relocated when output_dir is given.
|
|
28
|
+
|
|
29
|
+
Never returns the source path itself: the extension always differs, so
|
|
30
|
+
an in-place conversion can never clobber its own source.
|
|
31
|
+
"""
|
|
32
|
+
target = source.with_suffix("." + to_format)
|
|
33
|
+
if output_dir is not None:
|
|
34
|
+
target = Path(output_dir) / target.name
|
|
35
|
+
return target
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def classify_source(extension: str, to_format: str) -> str:
|
|
39
|
+
"""One of 'convert' | 'already_target' | 'unsupported_source'."""
|
|
40
|
+
ext = (extension or "").lower()
|
|
41
|
+
if ext not in SOURCE_EXTENSIONS:
|
|
42
|
+
return "unsupported_source"
|
|
43
|
+
return "already_target" if ext == "." + to_format else "convert"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Pure planning for edit_open_document — operation validation, zero COM.
|
|
2
|
+
|
|
3
|
+
An edit batch is a list of small operations executed in order against one
|
|
4
|
+
open document. Everything is validated BEFORE any COM call happens so a
|
|
5
|
+
bad batch never reaches the engine mid-way.
|
|
6
|
+
|
|
7
|
+
Operations (v0.1):
|
|
8
|
+
- replace_all {find, replace} literal find→set two-step (pitfall #14)
|
|
9
|
+
- insert_text {text, where} where: start|end
|
|
10
|
+
- save {} explicit save checkpoint
|
|
11
|
+
v0.2 reserved: set_bookmark, apply_style, delete_range.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
MAX_OPS = 50
|
|
19
|
+
MAX_FIND_LEN = 500
|
|
20
|
+
MAX_TEXT_LEN = 100_000
|
|
21
|
+
|
|
22
|
+
KNOWN_OPS = {"replace_all", "insert_text", "save"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_operations(operations: Any) -> list[dict[str, Any]]:
|
|
26
|
+
"""Validate the op batch up-front; raises ValueError on any problem."""
|
|
27
|
+
if not isinstance(operations, list) or not operations:
|
|
28
|
+
raise ValueError("operations must be a non-empty list of edit operations")
|
|
29
|
+
if len(operations) > MAX_OPS:
|
|
30
|
+
raise ValueError(f"at most {MAX_OPS} operations per call (got {len(operations)})")
|
|
31
|
+
clean: list[dict[str, Any]] = []
|
|
32
|
+
for i, op in enumerate(operations):
|
|
33
|
+
if not isinstance(op, dict):
|
|
34
|
+
raise ValueError(f"operations[{i}] must be an object")
|
|
35
|
+
kind = op.get("op")
|
|
36
|
+
if kind not in KNOWN_OPS:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"operations[{i}].op must be one of {sorted(KNOWN_OPS)} (got {kind!r})"
|
|
39
|
+
)
|
|
40
|
+
if kind == "replace_all":
|
|
41
|
+
find = op.get("find")
|
|
42
|
+
replace = op.get("replace", "")
|
|
43
|
+
if not isinstance(find, str) or not find:
|
|
44
|
+
raise ValueError(f"operations[{i}].find must be a non-empty string")
|
|
45
|
+
if len(find) > MAX_FIND_LEN:
|
|
46
|
+
raise ValueError(f"operations[{i}].find exceeds {MAX_FIND_LEN} chars")
|
|
47
|
+
if not isinstance(replace, str):
|
|
48
|
+
raise ValueError(f"operations[{i}].replace must be a string")
|
|
49
|
+
if len(replace) > MAX_TEXT_LEN:
|
|
50
|
+
raise ValueError(f"operations[{i}].replace exceeds {MAX_TEXT_LEN} chars")
|
|
51
|
+
clean.append({"op": "replace_all", "find": find, "replace": replace})
|
|
52
|
+
elif kind == "insert_text":
|
|
53
|
+
text = op.get("text")
|
|
54
|
+
where = op.get("where", "end")
|
|
55
|
+
if not isinstance(text, str) or not text:
|
|
56
|
+
raise ValueError(f"operations[{i}].text must be a non-empty string")
|
|
57
|
+
if len(text) > MAX_TEXT_LEN:
|
|
58
|
+
raise ValueError(f"operations[{i}].text exceeds {MAX_TEXT_LEN} chars")
|
|
59
|
+
if where not in ("start", "end"):
|
|
60
|
+
raise ValueError(f"operations[{i}].where must be 'start' or 'end'")
|
|
61
|
+
clean.append({"op": "insert_text", "text": text, "where": where})
|
|
62
|
+
else: # save
|
|
63
|
+
clean.append({"op": "save"})
|
|
64
|
+
return clean
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def summarize_ops(operations: list[dict[str, Any]]) -> dict[str, int]:
|
|
68
|
+
counts: dict[str, int] = {}
|
|
69
|
+
for op in operations:
|
|
70
|
+
counts[op["op"]] = counts.get(op["op"], 0) + 1
|
|
71
|
+
return counts
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Content extraction: text / tables / outline from legacy .doc via COM.
|
|
2
|
+
|
|
3
|
+
Tables come out as row-major string matrices with merged-cell repeats
|
|
4
|
+
(Horizontal/VerticalMerge cells repeat their origin text, which keeps the
|
|
5
|
+
grid rectangular — documented, lossless for data, and simple for JSON).
|
|
6
|
+
Runs on the STA worker with the same failover contract.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..errors import (
|
|
16
|
+
EngineUnavailableError,
|
|
17
|
+
FileNotFoundError,
|
|
18
|
+
OpenFailedError,
|
|
19
|
+
PasswordProtectedError,
|
|
20
|
+
)
|
|
21
|
+
from .com_utils import _g, _short_exc
|
|
22
|
+
from .container_guard import detect_encryption, validate_container
|
|
23
|
+
from .wd_constants import WD_DO_NOT_SAVE_CHANGES
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean_cell(text: str | None) -> str:
|
|
27
|
+
"""Cell text arrives with trailing \\r\\a markers — strip control noise."""
|
|
28
|
+
if not text:
|
|
29
|
+
return ""
|
|
30
|
+
return text.replace("\x07", "").replace("\r", " ").replace("\n", " ").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_tables(doc: Any, max_tables: int = 100) -> list[dict[str, Any]]:
|
|
34
|
+
tables: list[dict[str, Any]] = []
|
|
35
|
+
try:
|
|
36
|
+
count = int(_g(lambda: doc.Tables.Count, 0) or 0)
|
|
37
|
+
except Exception:
|
|
38
|
+
count = 0
|
|
39
|
+
for ti in range(min(count, max_tables)):
|
|
40
|
+
table = _g(lambda ti=ti: doc.Tables.Item(ti + 1))
|
|
41
|
+
if table is None:
|
|
42
|
+
continue
|
|
43
|
+
rows_n = int(_g(lambda table=table: table.Rows.Count, 0) or 0)
|
|
44
|
+
cols_n = int(_g(lambda table=table: table.Columns.Count, 0) or 0)
|
|
45
|
+
grid: list[list[str]] = []
|
|
46
|
+
# Cell-by-cell read: row-major, tolerant of vertical merges (which
|
|
47
|
+
# break row.Range.Cells iteration ordering on some engines).
|
|
48
|
+
for r in range(1, rows_n + 1):
|
|
49
|
+
row_cells: list[str] = []
|
|
50
|
+
for c in range(1, cols_n + 1):
|
|
51
|
+
# Cell.Range.Text — the portable path. Pitfall #15: on
|
|
52
|
+
# WPS-as-Word 12 Cell.Text itself does not exist (AttributeError)
|
|
53
|
+
# and Range indirection is required; the marker strip below
|
|
54
|
+
# cleans the \r\x07 cell terminator that comes with it.
|
|
55
|
+
cell_text = _g(lambda table=table, r=r, c=c: table.Cell(r, c).Range.Text, "")
|
|
56
|
+
row_cells.append(_clean_cell(cell_text))
|
|
57
|
+
grid.append(row_cells)
|
|
58
|
+
tables.append(
|
|
59
|
+
{
|
|
60
|
+
"index": ti + 1,
|
|
61
|
+
"rows": rows_n,
|
|
62
|
+
"columns": cols_n,
|
|
63
|
+
"data": grid,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return tables
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_outline(doc: Any, max_items: int = 200) -> list[dict[str, Any]]:
|
|
70
|
+
"""Headings via paragraph outline levels (locale-independent numbers)."""
|
|
71
|
+
items: list[dict[str, Any]] = []
|
|
72
|
+
try:
|
|
73
|
+
paras = doc.Paragraphs
|
|
74
|
+
total = int(_g(lambda: paras.Count, 0) or 0)
|
|
75
|
+
except Exception:
|
|
76
|
+
total = 0
|
|
77
|
+
for i in range(min(total, 2000)): # hard scan cap
|
|
78
|
+
para = _g(lambda i=i: paras.Item(i + 1))
|
|
79
|
+
if para is None:
|
|
80
|
+
continue
|
|
81
|
+
level = _g(lambda para=para: para.OutlineLevel, 10)
|
|
82
|
+
if not isinstance(level, int) or level < 1 or level > 9:
|
|
83
|
+
continue # body text is level 10
|
|
84
|
+
text = _g(lambda para=para: para.Range.Text, "")
|
|
85
|
+
text = (text or "").replace("\r", "").replace("\x07", "").strip()
|
|
86
|
+
if not text:
|
|
87
|
+
continue
|
|
88
|
+
items.append({"level": level, "text": text[:200]})
|
|
89
|
+
if len(items) >= max_items:
|
|
90
|
+
break
|
|
91
|
+
return items
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def extract_content_from_doc(doc: Any, what: str) -> dict[str, Any]:
|
|
95
|
+
"""Run the extraction selectors on an already-open document."""
|
|
96
|
+
payload: dict[str, Any] = {"what": what}
|
|
97
|
+
if what in ("text", "all"):
|
|
98
|
+
text = _g(lambda: doc.Content.Text, "") or ""
|
|
99
|
+
payload["text"] = text
|
|
100
|
+
payload["text_chars"] = len(text)
|
|
101
|
+
if what in ("tables", "all"):
|
|
102
|
+
tables = _extract_tables(doc)
|
|
103
|
+
payload["tables"] = tables
|
|
104
|
+
payload["tables_count"] = len(tables)
|
|
105
|
+
if what in ("outline", "all"):
|
|
106
|
+
items = _extract_outline(doc)
|
|
107
|
+
payload["outline"] = items
|
|
108
|
+
payload["outline_count"] = len(items)
|
|
109
|
+
return payload
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_content(
|
|
113
|
+
engine: Any,
|
|
114
|
+
worker: Any,
|
|
115
|
+
path: str,
|
|
116
|
+
what: str,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Full tool flow with failover: probe order, open RO, extract, close.
|
|
119
|
+
|
|
120
|
+
``engine``/``worker`` come from the caller (tools layer) so this module
|
|
121
|
+
stays import-cycle-free and testable with fakes.
|
|
122
|
+
"""
|
|
123
|
+
t0 = time.perf_counter()
|
|
124
|
+
src = Path(path).expanduser().resolve()
|
|
125
|
+
if not src.exists():
|
|
126
|
+
raise FileNotFoundError(str(src))
|
|
127
|
+
ok, reason = validate_container(str(src))
|
|
128
|
+
if not ok:
|
|
129
|
+
raise OpenFailedError(str(src), "container pre-check", f"structurally invalid container: {reason}")
|
|
130
|
+
if detect_encryption(str(src)):
|
|
131
|
+
raise PasswordProtectedError(str(src), "container pre-check")
|
|
132
|
+
|
|
133
|
+
order = engine._engine_order()
|
|
134
|
+
attempts: list[dict[str, str]] = []
|
|
135
|
+
launch_failures = 0
|
|
136
|
+
|
|
137
|
+
for engine_name, progid in order:
|
|
138
|
+
try:
|
|
139
|
+
app = engine._ensure_app(progid, engine_name)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
launch_failures += 1
|
|
142
|
+
attempts.append({"engine": engine_name, "stage": "launch", "error": _short_exc(exc)})
|
|
143
|
+
engine._discard_app()
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
doc = engine._open_read_only(app, str(src))
|
|
147
|
+
try:
|
|
148
|
+
payload = extract_content_from_doc(doc, what)
|
|
149
|
+
finally:
|
|
150
|
+
try:
|
|
151
|
+
doc.Close(WD_DO_NOT_SAVE_CHANGES)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
except PasswordProtectedError:
|
|
155
|
+
raise
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
attempts.append({"engine": engine_name, "stage": "extract", "error": _short_exc(exc)})
|
|
158
|
+
engine._discard_app()
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
payload.update(
|
|
162
|
+
{
|
|
163
|
+
"ok": True,
|
|
164
|
+
"tool": "extract_content",
|
|
165
|
+
"path": str(src),
|
|
166
|
+
"engine": engine_name,
|
|
167
|
+
"engine_version": engine._engine_version,
|
|
168
|
+
"duration_ms": round((time.perf_counter() - t0) * 1000),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
return payload
|
|
172
|
+
|
|
173
|
+
if launch_failures == len(order):
|
|
174
|
+
raise EngineUnavailableError(attempts)
|
|
175
|
+
raise OpenFailedError(str(src), "word→wps failover exhausted", "; ".join(a["error"] for a in attempts))
|