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 ADDED
@@ -0,0 +1,3 @@
1
+ """DocuHand — give your AI agent hands to operate real Word & WPS documents."""
2
+
3
+ __version__ = "0.1.0.dev1"
docuhand/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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))