commit 8b531719138857192aa8cdb6742e7fe355e4548a Author: doced builder Date: Tue Sep 8 03:33:01 2026 +0000 Scaffold doced: OPC package I/O, XML core, AST model, locator model, reader, LLM contract diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61d94d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +build/ +dist/ +*.deb +.eggs/ +*.egg-info/ +.venv/ +venv/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6d53904 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,259 @@ +# doced — Plan for an LLM-Driven DOCX CLI on Debian/Ubuntu + +> **Status:** Plan (v1) — awaiting user review before implementation. +> **Deliverable:** A design for `doced`, a CLI that lets an LLM with terminal access manipulate `.docx` files at an advanced level, installable on any Debian/Ubuntu terminal. + +--- + +## 0. TL;DR + +`doced` is a Python CLI that treats a `.docx` as an OOXML package (a zip of XML parts) and mutates it **in place** — never by rebuilding from a lossy round-trip. It speaks an LLM-friendly contract: **stable locators**, a **lossless JSON AST**, **machine-readable output**, and an **exit-code-as-success-signal** protocol. It ships as both a `pipx`-installable package and an `apt` `.deb`, so `doced` is on PATH on any Debian/Ubuntu box. + +The name `doced` is deliberately distinct from the existing `docx` CLI (see §1). + +--- + +## 1. Research: what already exists + +### 1.1 Web research — caveat + +The delegated web-research sub-agent returned **empty results** (general web search is blocked in this environment; this matches the prior `docling_ocr_skill` session, where web search also came back empty). So the competitive analysis below is grounded in: + +1. A **detailed review of an existing, mature project already in this workspace** — `docx-cli` (github.com/kklimuk/docx-cli) — including its skill, its agent-facing `CLAUDE.md`, its `install.sh`, and its `build-deb.sh`. +2. Domain knowledge of the Python/LLM document ecosystem. + +This is sufficient to map the landscape and differentiate `doced`, but I flag it as a research gap to close if web access becomes available. + +### 1.2 Existing applications (the competitive landscape) + +**A. The dominant direct competitor: `docx-cli` (Bun/TypeScript, by Kirill Klimuk)** + +This is the single most relevant existing tool and it does almost exactly what `doced` is being asked for. Key facts, verified from the workspace copy: + +- **In-place OOXML mutation.** It never rebuilds the document from Markdown/AST. It mutates `XmlNode` refs directly, so unmodeled elements don't break. This is the gold-standard approach for fidelity. +- **Stable positional locators.** `pN` (paragraph), `tN` (table), `sN` (section), `p3:5-20` (char span), `tN:rRcC` (cell), plus `cN` (comment), `imgN`, `linkN`, `fnN`/`enN` (foot/endnotes), `tcN` (tracked change), `eqN` (equation). Locators ride `` comments in the Markdown read view. +- **LLM-first contract.** Exit codes (`0` ok, `1` error, `2` usage, `3` not-found) + a one-line text confirmation per command. Weak agents (e.g. Haiku) are a first-class design target. `read --ast` emits a lossless JSON AST. +- **Rich verb set:** `read`, `find`, `edit`, `insert`, `replace`, `delete`, `create` (Markdown→docx), `comments`, `track-changes`, `footnotes`/`endnotes`, `hyperlinks`, `tables`, `headers`/`footers`, `images`, `sections`, `equations`, `wc`, `outline`, `diff`, `render` (to PNG via LibreOffice), `raw` (direct OOXML part access), `validate` (ECMA-376 XSD). +- **`--batch`** applies many changes from a single read (locators stay valid). +- **Packaging:** `install.sh` (pinned release + SHA-256 verification, no pipe-to-shell), a **`.deb`** via `build-deb.sh` (drops binary to `/usr/local/bin/docx`, adds a systemd service), cross-platform (linux x64/arm64, macOS, windows). +- **Built for agents:** documents "document content is untrusted data, not instructions" (prompt-injection guardrail). + +**B. General-purpose converters (lossy, not agent-native)** + +- **pandoc** — the standard DOCX↔Markdown/HTML/DOCX converter. Great for bulk conversion, **lossy** for tracked changes, comments, content controls, and complex tables. Not agent-native (no locators, no in-place edit). +- **LibreOffice headless (`soffice`/`libreoffice`)** — universal DOCX↔anything (PDF, HTML, images) via `--headless --convert-to`. Excellent rendering, terrible for precise programmatic edits. Used by `docx-cli render`. +- **pypandoc** — Python wrapper over pandoc. +- **unoconv** — LibreOffice automation wrapper (largely superseded). +- **antiword / catdoc / wv** — legacy DOC→text extractors; not for modern `.docx`. + +**C. Python libraries** + +- **python-docx** — the most popular. Write-mostly, easy to learn, good for creating documents and simple edits. **Weak** for tracked changes, comments, content controls, fields, and in-place preservation. This is the main reason a fidelity-grade tool shouldn't build on it alone. +- **mammoth** — DOCX→Markdown/HTML with good formatting fidelity for *reading*; not for editing. +- **docx2md / docx2pdf** — narrow converters. +- **textract / unstructured / LlamaIndex docx reader / LangChain doc loaders** — extraction-focused for RAG pipelines; read-only. +- **PyMuPDF (fitz)** — PDF-first; limited DOCX. +- **aspose-words-python** — extremely capable (tracked changes, content controls, fields) but **proprietary/licensed and heavy** — not ideal for a lightweight agent CLI. + +**D. LLM-agent workflows / MCP / skills for DOCX** + +- The **`docx-cli` skill** itself (this workspace's `docx-cli-enhanced/SKILL.md`) is an example of the "agent-harness skill" model: a Markdown guide that steeps an LLM on the tool. This is the pattern `doced`'s skill will follow. +- No widely-adopted MCP server for in-place DOCX editing was surfaced (web search blocked), but the skill pattern is the established convention here. + +### 1.3 Common LLM pain points with DOCX (from the ecosystem) + +1. **Round-trip fidelity loss.** Extract → reason → regenerate loses formatting, tables, styles, tracked changes. +2. **No stable addressing.** LLMs can't reliably "edit paragraph 3" without a locator scheme; char offsets drift. +3. **Untrusted content / prompt injection.** Text inside a doc can look like instructions. +4. **Opaque success.** LLMs can't tell if a mutation landed without re-reading. +5. **Advanced legal features** (tracked changes, comments, content controls, fields) are invisible to naive extractors. + +--- + +## 2. Positioning & differentiation + +`doced` will **not** reinvent `docx-cli`'s in-place-mutation + locator design — that's the correct architecture. Instead, `doced` differentiates on three axes: + +| Dimension | `docx-cli` (existing) | `doced` (proposed) | +|---|---|---| +| **Language** | Bun/TypeScript | **Python 3.12** — matches this workspace's venv, the legal-team's stack, and the Python DOCX ecosystem. | +| **Core engine** | Custom JSX/XML tree | **lxml** (libxml2) directly on the OOXML zip — robust, fast, no custom parser; reuses the mature Python XML toolchain. | +| **Distribution** | npm binary + `.deb` | **pipx-first** (trivial `pipx install doced`) **and** `.deb`; zero system Python pollution. | +| **Fidelity target** | Full OOXML | Same in-place model; **explicit legal-feature coverage** (tracked changes, comments, content controls, fields, TOC, headers/footers). | +| **Skill** | Markdown guide | Markdown skill modeled on the existing skill, but Python-flavored and legally-oriented. | + +**Why Python:** The workspace venv (`/home/wall-o/workspace/venv`) already has Flask/pytest/requests; Python-docx, lxml, and the whole LLM doc toolchain is Python-native. Legal stakeholders are more likely to extend/maintain a Python CLI than a Bun one. Python also gives us `lxml` for the fidelity-critical OOXML layer without a custom parser. + +**Risk to manage:** We will **not** rely on python-docx for the fidelity-critical path (its tracked-changes/comment support is weak). We'll use `lxml` to read/write the OOXML parts directly, mirroring `docx-cli`'s "mutate in place" invariant. python-docx may be used only for *convenience authoring* paths (e.g. creating from scratch), never for round-tripping an existing doc. + +--- + +## 3. Architecture + +``` +doced/ +├── pyproject.toml # pipx-installable; entry point `doced` +├── README.md +├── doced_skill.md # LLM skill (the "guide") +├── src/doced/ +│ ├── __main__.py # `python -m doced` +│ ├── cli.py # thin arg parsing (subcommands, flags) ← thin surface +│ ├── output.py # exit codes + one-line acks (LLM contract) +│ ├── io/ +│ │ ├── package.py # open .docx as zip; read/write OPC parts +│ │ └── xmlns.py # namespace map (w:, a:, r:, mc:, etc.) +│ ├── model/ +│ │ ├── locator.py # pN / tN / sN / p3:5-20 / tN:rRcC / cN / tcN ... +│ │ ├── ast.py # lossless JSON AST builder +│ │ └── markdown.py # AST → Markdown read view (with locator hints) +│ ├── ops/ +│ │ ├── read.py # read / find / outline / wc +│ │ ├── edit.py # edit / insert / replace / delete +│ │ ├── track.py # tracked changes (on/off/list/accept/reject) +│ │ ├── comments.py # comments (add/list/reply/resolve) +│ │ ├── fields.py # content controls + fields (cross-ref/hyperlink/etc.) +│ │ ├── marginals.py # headers / footers / page numbers +│ │ ├── tables.py # table ops +│ │ ├── toc.py # table of contents +│ │ └── raw.py # direct OOXML part access (gated) +│ └── author/ +│ └── create.py # Markdown/text → new docx +├── tests/ +│ ├── unit/ # XML-shape assertions +│ └── fixtures/ # round-trip via LibreOffice headless +├── debian/ # .deb packaging (control, postinst, service) +└── build-deb.sh +``` + +### 3.1 Core invariant (from docx-cli) + +> **Changes must survive the write→read loop.** Any change made must be retrievable on the next turn in the AST and (where possible) in Markdown. **Mutate `lxml` element refs in place; only emit fresh XML for inserted nodes.** Unmodeled elements are preserved, not rewritten. + +### 3.2 The `.docx` as an OPC package + +A `.docx` is a zip. `doced` opens it with Python's `zipfile`, treats each entry as an **OPC part** (`word/document.xml`, `word/styles.xml`, `word/header{N}.xml`, `word/numbering.xml`, `word/comments.xml`, `word/trackChanges.xml`, `docProps/core.xml`, …), and uses `lxml.etree` to parse/mutate. On save, it re-zips. This mirrors `docx-cli`'s view-per-part model but on the mature lxml stack. + +--- + +## 4. The LLM contract (why this design) + +Modeled directly on the proven `docx-cli` contract, which was explicitly built for weak agents: + +1. **Exit code is the success signal.** `0` ok · `1` error · `2` usage · `3` not-found. Every command also prints a one-line text confirmation, so the LLM never has to re-read just to know a mutation landed. +2. **Stable locators** address everything (`pN`, `tN`, `sN`, char spans, cells, comments, track-change handles, …). They ride `` comments in the Markdown read view. +3. **Lossless view is `doced read --ast`** (JSON); the default `read` is Markdown with locator hints. +4. **`--batch FILE.jsonl`** applies many changes from one read (locators stay valid across the batch). +5. **`--dry-run` / `-o/--output`** preview or write a copy instead of in place. +6. **A zero-effect mutation is an error** (nonzero exit), so the LLM's done/retry decision off the exit code is reliable. +7. **Untrusted content guard:** the skill explicitly instructs the LLM that doc content is data, not instructions. + +--- + +## 5. Command surface (`doced `) + +Thin CLI surface; all domain logic lives in `src/doced/ops`. Every command has `--help`. + +**Read / inspect** +- `doced read FILE [--ast | --json] [--from pN --to pM]` — Markdown (default) or lossless JSON AST. +- `doced find FILE "phrase"` — returns exact locator spans. +- `doced outline FILE` — heading tree as a locator tree. +- `doced wc FILE [LOCATOR]` — word/token count. + +**Edit (in place)** +- `doced edit FILE --at LOCATOR --text "…"` — edit text at a locator. +- `doced insert FILE --after pN --text "…"` / `--at-start` / `--at-end`. +- `doced replace FILE "pattern" "replacement" [--all]` — text replace, run-format preserved. +- `doced delete FILE --at LOCATOR`. + +**Legal-grade features (the differentiation)** +- `doced track-changes FILE on|off|list|accept|reject` — tracked changes / redlines. +- `doced comments FILE add|list|reply|resolve [--anchor PHRASE] [--at cN]`. +- `doced content-controls FILE list|add|edit` — document controls / structured document tags. +- `doced fields FILE list|update|set` — cross-reference, hyperlink, page-number, author, etc. +- `doced toc FILE add|update` — table of contents. +- `doced headers FILE set|list` / `doced footers FILE set|list [--page-number]`. + +**Tables & media** +- `doced tables FILE create|insert-row|insert-col|set-cell|format`. +- `doced images FILE add|list|replace`. + +**Authoring** +- `doced create OUT.docx --from draft.md` — Markdown (GFM) → docx. +- `doced create OUT.docx --text-file file.txt` — verbatim text channel (no parser). + +**Deep / safety** +- `doced raw FILE part list|get --at NAME` — direct OPC part access (gated). +- `doced diff FILE --against OLD.docx` — structural diff vs a baseline. +- `doced validate FILE` — ECMA-376 schema check. +- `doced render FILE --out DIR` — rasterize pages (LibreOffice headless) when layout is the question. + +**Meta** +- `doced --version`, `doced --help`, `doced info locators`, `doced info schema`. + +--- + +## 6. Installation & packaging (Debian/Ubuntu) + +Two paths, both drop a `doced` on PATH: + +**A. pipx (primary, zero system pollution)** +```bash +pipx install doced +doced --version +``` +`pyproject.toml` declares the `doced = "doced.cli:main"` console-script entry point. pipx gives each tool an isolated env — perfect for an agent's terminal. + +**B. apt `.deb` (enterprise/immutable hosts)** +```bash +sudo dpkg -i dist/doced_0.1.0_amd64.deb +doced --version +``` +`debian/` + `build-deb.sh` mirror the existing `docx-cli` recipe: binary→`/usr/local/bin/doced`, optional systemd service with sandboxing (`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp`). + +**Install-time guarantees (from docx-cli's `install.sh`):** +- Pinned release + **SHA-256 verification** against published `SHA256SUMS`; never pipe a remote script into a shell. +- Platform detection (linux x64/arm64). + +**Runtime dependency:** LibreOffice (`libreoffice`) optional, only for `doced render`. Everything else is pure Python + lxml. + +--- + +## 7. Roadmap (phased) + +| Phase | Status | Deliverable | +|---|---|---| +| **P0 — Scaffold** | pending | `pyproject.toml`, `doced` entry point, package I/O (zip + lxml), `--version`, `--help`, exit-code output module. | +| **P1 — Read layer** | pending | `read` (Markdown + `--ast`), `find`, `outline`, `wc`; locator model; Markdown read view with locator hints. | +| **P2 — Edit layer** | pending | `edit`, `insert`, `replace`, `delete`, `--batch`, `--dry-run`/`-o`; in-place mutation invariant; unit + LibreOffice round-trip fixtures. | +| **P3 — Legal features** | pending | `track-changes`, `comments`, `content-controls`, `fields`, `toc`, `headers`/`footers`. | +| **P4 — Tables/media/author** | pending | `tables`, `images`, `create` (Markdown + verbatim text). | +| **P5 — Deep/safety** | pending | `raw` (gated part access), `diff`, `validate`, `render`. | +| **P6 — Skill** | pending | `doced_skill.md` — LLM guide modeled on the existing skill (Python-flavored, legally oriented). | +| **P7 — Packaging** | pending | pipx install + `.deb` (`debian/`, `build-deb.sh`) + `install.sh` (pinned + SHA-256). | +| **P8 — Dockerize** | pending | Production Docker image (post host-verification), per deployment strategy. | + +--- + +## 8. Testing discipline + +- **Unit:** `lxml`-shape assertions on every op (proves the emitted XML is correct). +- **Fixture round-trip:** each new verb gets a builder fixture that round-trips through **LibreOffice headless** (catches render-invalidity: bad child order, broken parts) — the same discipline `docx-cli` enforces. +- **Weak-agent scenario:** each feature gets a task scenario proving an LLM can discover + use it (the gap unit tests structurally can't see). +- **Contract tests:** exit codes + one-line acks + `--batch` locator-stability. +- Run on the host venv; sub-agents execute tests in a single pass, report issues, then isolated sub-agents fix (per workspace testing discipline). + +--- + +## 9. Open questions for the user + +1. **Language:** Python (this plan) vs. reusing the existing Bun `docx-cli` as the engine and wrapping it? I recommend **Python + lxml** for stack alignment and legal-team maintainability, but confirm. +2. **Reuse vs. replace:** Should `doced` be a **fresh implementation**, or should we build `doced` as a **thin Python layer over** the existing `docx-cli` binary (reuse its fidelity engine)? Fresh Python gives independence; over `docx-cli` reuses a mature engine. +3. **Scope:** Is the full legal-feature set (§5) in scope for v1, or a trimmed MVP (read/find/edit + comments + tracked changes) first? +4. **Distribution priority:** pipx-first, .deb-first, or both from the start? +5. **Web research gap:** Web search is blocked. Should I try an alternative method (e.g., `fetch_url` on specific GitHub/pypa pages) to strengthen the competitive analysis, or proceed with the workspace-grounded analysis? + +--- + +## 10. Immediate next step + +On your go, I'll scaffold **P0** (pyproject, entry point, package I/O, `--version`/`--help`, exit-code module), verify it runs on the host venv, and commit on a feature branch. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..73a3c39 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "doced" +version = "0.1.0" +description = "A terminal-native CLI that lets an LLM interact with DOCX files at an advanced level on Debian/Ubuntu." +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +authors = [{ name = "doced contributors" }] +keywords = ["docx", "word", "ooxml", "llm", "agent", "cli", "legal"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Legal Industry", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.12", + "Topic :: Text Processing :: Markup", +] +dependencies = [ + "lxml>=5.3.0,<6.0.0", +] + +[project.optional-dependencies] +# Only needed for `doced render` (rasterize pages to PNG via LibreOffice). +render = [] +# Development / test extras. +dev = [ + "pytest>=8.0.0", + "pytest-cov>=5.0.0", +] + +[project.scripts] +doced = "doced.cli:main" + +[project.urls] +Homepage = "https://example.com/doced" +Source = "https://example.com/doced" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["doced*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-q" diff --git a/src/doced/__init__.py b/src/doced/__init__.py new file mode 100644 index 0000000..bbd0d77 --- /dev/null +++ b/src/doced/__init__.py @@ -0,0 +1,13 @@ +"""doced — a terminal-native CLI for advanced DOCX manipulation by an LLM. + +The package treats a ``.docx`` as an OPC package (a zip of XML parts) and +mutates it *in place* via ``lxml`` — never by rebuilding from a lossy +round-trip. See :mod:`doced.package` for the package I/O and +:mod:`doced.xmlcore` for the XML helpers. +""" + +from __future__ import annotations + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/src/doced/__pycache__/__init__.cpython-312.pyc b/src/doced/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..505a4a6 Binary files /dev/null and b/src/doced/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/doced/__pycache__/output.cpython-312.pyc b/src/doced/__pycache__/output.cpython-312.pyc new file mode 100644 index 0000000..fb7c242 Binary files /dev/null and b/src/doced/__pycache__/output.cpython-312.pyc differ diff --git a/src/doced/__pycache__/package.cpython-312.pyc b/src/doced/__pycache__/package.cpython-312.pyc new file mode 100644 index 0000000..67ad776 Binary files /dev/null and b/src/doced/__pycache__/package.cpython-312.pyc differ diff --git a/src/doced/__pycache__/xmlcore.cpython-312.pyc b/src/doced/__pycache__/xmlcore.cpython-312.pyc new file mode 100644 index 0000000..05cc6c7 Binary files /dev/null and b/src/doced/__pycache__/xmlcore.cpython-312.pyc differ diff --git a/src/doced/ast/__init__.py b/src/doced/ast/__init__.py new file mode 100644 index 0000000..279c311 --- /dev/null +++ b/src/doced/ast/__init__.py @@ -0,0 +1 @@ +"""AST model, locator model, and the reader (view builder).""" diff --git a/src/doced/ast/__pycache__/__init__.cpython-312.pyc b/src/doced/ast/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..432886d Binary files /dev/null and b/src/doced/ast/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/doced/ast/__pycache__/doc.cpython-312.pyc b/src/doced/ast/__pycache__/doc.cpython-312.pyc new file mode 100644 index 0000000..1b77d34 Binary files /dev/null and b/src/doced/ast/__pycache__/doc.cpython-312.pyc differ diff --git a/src/doced/ast/__pycache__/locator.cpython-312.pyc b/src/doced/ast/__pycache__/locator.cpython-312.pyc new file mode 100644 index 0000000..4b883d7 Binary files /dev/null and b/src/doced/ast/__pycache__/locator.cpython-312.pyc differ diff --git a/src/doced/ast/__pycache__/model.cpython-312.pyc b/src/doced/ast/__pycache__/model.cpython-312.pyc new file mode 100644 index 0000000..08769ca Binary files /dev/null and b/src/doced/ast/__pycache__/model.cpython-312.pyc differ diff --git a/src/doced/ast/__pycache__/reader.cpython-312.pyc b/src/doced/ast/__pycache__/reader.cpython-312.pyc new file mode 100644 index 0000000..16d5622 Binary files /dev/null and b/src/doced/ast/__pycache__/reader.cpython-312.pyc differ diff --git a/src/doced/ast/doc.py b/src/doced/ast/doc.py new file mode 100644 index 0000000..9c83d33 --- /dev/null +++ b/src/doced/ast/doc.py @@ -0,0 +1,120 @@ +"""The document model: package + AST + locator index, mutated in place. + +This is the heart of the in-place-mutation model. A :class:`DocumentModel` +owns an open :class:`~doced.package.Package` and a parsed +:class:`~doced.ast.model.Document` AST. The AST is a **read-only view** — we +never mutate AST objects. Instead we keep a locator→``lxml`` node index +(:attr:`nodes`) and mutate the XML directly, then :meth:`rebuild` the AST. + +Core invariant (from the docx-cli design): + +> **Changes must survive the write→read loop.** Mutate ``lxml`` element +> refs in place; only emit fresh XML for inserted nodes. Unmodeled elements +> are preserved, not rewritten. + +Because positional ids (``pN`` / ``tN`` / …) are assigned in document order +during a read, an id may shift after a *structural* edit (insert/delete). We +therefore rebuild the AST (and the index) after every mutation, so callers +always address the *current* document. +""" + +from __future__ import annotations + +from lxml import etree as ET + +from .. import xmlcore as xc +from ..ast import reader +from ..ast.locator import Locator, parse_locator +from ..ast.model import Document +from ..package import Package + + +class DocumentModel: + """An open document ready for read + in-place mutation.""" + + def __init__(self, pkg: Package): + self.pkg = pkg + self.ast: Document = reader.build_document(pkg) + self.nodes: dict[str, ET._Element] = {} + self._index() + + # ------------------------------------------------------------------ + # Indexing + # ------------------------------------------------------------------ + def _index(self) -> None: + """Build the locator → node index from the current XML. + + Walks ``word/document.xml`` in document order and assigns positional + ids (``pN`` paragraphs, ``tN`` tables, ``sN`` sections) directly, so + the index never depends on the AST. Rebuild after any mutation. + """ + self.nodes = {} + root = self.pkg.parse("word/document.xml") + body = xc.find(root, "w:body") + if body is None: + body = root # degenerate document; treat root as body + + # Comments live in a separate part. + if self.pkg.has_part("word/comments.xml"): + croot = self.pkg.parse("word/comments.xml") + for c in croot.findall(xn("w", "comment")): + cid = c.get(xn("w", "id")) or "c0" + self.nodes[cid] = c + + p_idx = t_idx = s_idx = 0 + for el in body: + tag = xc.local(el.tag) + if tag == "p": + self.nodes[f"p{p_idx}"] = el + p_idx += 1 + elif tag == "tbl": + self.nodes[f"t{t_idx}"] = el + t_idx += 1 + elif tag == "sectPr": + self.nodes[f"s{s_idx}"] = el + s_idx += 1 + + # ------------------------------------------------------------------ + # Root element access + # ------------------------------------------------------------------ + def root(self) -> ET._Element: + """Return the root element of ``word/document.xml``.""" + return self.pkg.parse("word/document.xml") + + # ------------------------------------------------------------------ + # Save + # ------------------------------------------------------------------ + def save(self, path: str | None = None) -> None: + """Write the (mutated) package to *path* (defaults to the source).""" + self.pkg.save(path) + + def rebuild(self) -> None: + """Rebuild the AST + locator index from the current XML.""" + self.ast = reader.build_document(self.pkg) + self._index() + + # ------------------------------------------------------------------ + # Locator resolution + # ------------------------------------------------------------------ + def resolve(self, locator: str | Locator) -> ET._Element: + """Resolve a locator string/locator to its ``lxml`` node. + + Raises: + KeyError: if the locator does not address any node. + """ + if isinstance(locator, str): + locator = parse_locator(locator) + key = self._locator_key(locator) + if key not in self.nodes: + raise KeyError(f"locator not found: {locator}") + return self.nodes[key] + + def _locator_key(self, locator: Locator) -> str: + if locator.kind == "cell" and locator.container and locator.row is not None and locator.col is not None: + return f"{locator.container}:{locator.row}c{locator.col}" + return str(locator.id) + + +def xn(prefix: str, name: str) -> str: + """Namespace-qualified tag/attr name.""" + return f"{{{xc.NSMAP[prefix]}}}{name}" diff --git a/src/doced/ast/locator.py b/src/doced/ast/locator.py new file mode 100644 index 0000000..cebcc2a --- /dev/null +++ b/src/doced/ast/locator.py @@ -0,0 +1,196 @@ +"""The locator model: stable, positional addressing for an LLM. + +Locators are how an LLM addresses content without re-reading the whole file. +They are **positional** (``p3`` = the 3rd paragraph in document order) and +**stable within a single read** — they shift only after *structural* edits +(insert/delete/section changes), never after in-place content/format edits. + +Grammar (all parsed by :func:`parse_locator`): + +.. code-block:: text + + pN a paragraph (block) + pN:pM or pN-pM a range of paragraphs + pN:a-b a character span within paragraph pN + tN a table + tN:rR a row of table tN + tN:cC a column of table tN + tN:rRcC a cell (row R, column C) of table tN + tN:rRcC:pK a paragraph inside that cell + sN a section break + cN a comment + tcN a tracked change + imgN an image + linkN a hyperlink + fnN / enN a footnote / endnote + hdrN / ftrN a header / footer marginal + +Locators ride ```` HTML comments in the Markdown read view, so an +LLM sees the handle it needs and can feed it straight back into ``--at``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Union + +# --------------------------------------------------------------------------- +# Locator kinds (mirrors the AST node types they address). +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class Locator: + """A parsed locator. ``kind`` selects the addressed node type.""" + + kind: str + # The primary id (e.g. "p3", "t0", "c1"). ``start``/``end`` are character + # offsets for ``blockSpan`` locators; row/col for cell locators. + id: str | None = None + start: int | None = None + end: int | None = None + row: int | None = None + col: int | None = None + # For cell-scoped locators, the container table id (e.g. "t0"). + container: str | None = None + # For nested cell locators (paragraph inside a cell), the chained id. + inner: "Locator | None" = None + # Internal-only: the marginal kind, only meaningful for kind=="marginal". + _marginal_kind: str | None = None + + def __str__(self) -> str: + return self._format() + + def _format(self) -> str: + if self.kind == "comment": + return str(self.id) + if self.kind == "cell": + base = f"{self.container}:{self.row}c{self.col}" + return f"{base}:{self.inner}" if self.inner else base + if self.kind == "cellRange": + return f"{self.container}:{self.row}c{self.col}-{self.end}c{self.col}" + return str(self.id) + + +# --------------------------------------------------------------------------- +# Parser. +# --------------------------------------------------------------------------- + +_BLOCK_RE = re.compile(r"^(p|t|s)(\d+)$") +_SPAN_RE = re.compile(r"^p(\d+):(\d+)-(\d+)$") +_BLOCK_RANGE_RE = re.compile(r"^p(\d+)-p(\d+)$") +_CELL_RE = re.compile(r"^(t\d+):r(\d+)c(\d+)(?::(.+))?$") +_CELL_RANGE_RE = re.compile(r"^(t\d+):r(\d+)c(\d+)-r(\d+)c(\d+)$") +_TABLE_ROW_RE = re.compile(r"^(t\d+):r(\d+)$") +_TABLE_COL_RE = re.compile(r"^(t\d+):c(\d+)$") +_COMMENT_RE = re.compile(r"^c(\d+)$") +_TRACKED_RE = re.compile(r"^tc(\d+)$") +_IMAGE_RE = re.compile(r"^img(\d+)$") +_LINK_RE = re.compile(r"^link(\d+)$") +_FOOTNOTE_RE = re.compile(r"^fn(\d+)$") +_ENDNOTE_RE = re.compile(r"^en(\d+)$") +_MARGINAL_RE = re.compile(r"^(hdr|ftr)(\d+)$") + + +class LocatorParseError(ValueError): + """Raised when a locator string cannot be parsed.""" + + +def parse_locator(input_str: str) -> Locator: + """Parse a locator string into a :class:`Locator`. + + Raises: + LocatorParseError: if the string is empty or unrecognizable. + """ + s = input_str.strip() + if not s: + raise LocatorParseError("empty locator") + + # Cell range: tN:rRcC-rRcC + m = _CELL_RANGE_RE.match(s) + if m: + tid, r1, c1, r2, c2 = m.groups() + return Locator( + kind="cellRange", + container=tid, + row=int(r1), + col=int(c1), + end=int(r2), + ) + + # Cell: tN:rRcC[:inner] + m = _CELL_RE.match(s) + if m: + tid, r, c, rest = m.groups() + inner = parse_locator(rest) if rest else None + return Locator(kind="cell", container=tid, row=int(r), col=int(c), inner=inner) + + # Table row: tN:rR + m = _TABLE_ROW_RE.match(s) + if m: + tid, r = m.groups() + return Locator(kind="tableRow", container=tid, row=int(r)) + + # Table column: tN:cC + m = _TABLE_COL_RE.match(s) + if m: + tid, c = m.groups() + return Locator(kind="tableColumn", container=tid, col=int(c)) + + # Comment / tracked change / image / link / notes / marginal + m = _COMMENT_RE.match(s) + if m: + return Locator(kind="comment", id=f"c{m.group(1)}") + m = _TRACKED_RE.match(s) + if m: + return Locator(kind="trackedChange", id=f"tc{m.group(1)}") + m = _IMAGE_RE.match(s) + if m: + return Locator(kind="image", id=f"img{m.group(1)}") + m = _LINK_RE.match(s) + if m: + return Locator(kind="hyperlink", id=f"link{m.group(1)}") + m = _FOOTNOTE_RE.match(s) + if m: + return Locator(kind="footnote", id=f"fn{m.group(1)}") + m = _ENDNOTE_RE.match(s) + if m: + return Locator(kind="endnote", id=f"en{m.group(1)}") + m = _MARGINAL_RE.match(s) + if m: + kind = "header" if m.group(1) == "hdr" else "footer" + return Locator(kind="marginal", id=f"{m.group(1)}{m.group(2)}", _marginal_kind=kind) + + # Block range: pN-pM + m = _BLOCK_RANGE_RE.match(s) + if m: + a, b = m.groups() + if int(b) < int(a): + raise LocatorParseError("end paragraph precedes start") + return Locator(kind="blockRange", id=f"p{a}", end=b) + + # Block span: pN:a-b + m = _SPAN_RE.match(s) + if m: + p, a, b = m.groups() + if int(b) < int(a): + raise LocatorParseError("end offset precedes start") + return Locator(kind="blockSpan", id=f"p{p}", start=int(a), end=int(b)) + + # Plain block: pN / tN / sN + m = _BLOCK_RE.match(s) + if m: + prefix, idx = m.groups() + kind = "block" if prefix == "p" else ("section" if prefix == "s" else "table") + return Locator(kind=kind, id=f"{prefix}{idx}") + + raise LocatorParseError(f"unrecognized locator syntax: {input_str!r}") + + +def is_cell_scoped(input_str: str) -> bool: + """True if a locator addresses content inside a table cell.""" + return bool(re.search(r":r\d+c\d+(?::|$)", input_str.strip())) + + +def is_relationship_locator(input_str: str) -> bool: + """True for an ``rIdN`` relationship handle.""" + return bool(re.match(r"^rId\d+$", input_str.strip())) diff --git a/src/doced/ast/model.py b/src/doced/ast/model.py new file mode 100644 index 0000000..169319f --- /dev/null +++ b/src/doced/ast/model.py @@ -0,0 +1,258 @@ +"""The AST model: typed nodes describing a document's structure. + +These dataclasses are the in-memory representation an LLM reads. They are a +*view* over the underlying XML — the source of truth is the OOXML in the +package, mutated in place. The AST gives an LLM a stable, structured, +JSON-serializable picture of that XML without forcing it to reason about raw +tags. + +Nodes: + +* :class:`Document` — the root; holds body blocks, properties, and side tables + (comments, notes, numbering, styles, marginals). +* :class:`Paragraph`, :class:`Run`, :class:`Table` / :class:`TableRow` / + :class:`TableCell` — the body. +* :class:`SectionBreak`, :class:`Marginal` — layout and page chrome. +* :class:`Comment`, :class:`Note`, :class:`TrackedChange` — legal-grade features. + +The :class:`~doced.ast.doc.DocumentModel` owns the package + this AST and +performs in-place XML mutation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Union + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- +@dataclass +class DocProperties: + """Core document properties (``docProps/core.xml``).""" + + title: str | None = None + author: str | None = None + created: str | None = None + modified: str | None = None + keywords: str | None = None + subject: str | None = None + + +# --------------------------------------------------------------------------- +# Runs (inline content within a paragraph) +# --------------------------------------------------------------------------- +@dataclass +class TextRun: + """A run of text with character-level formatting.""" + + type: str = "text" + text: str = "" + bold: bool = False + italic: bool = False + underline: bool = False + strike: bool = False + color: str | None = None + font: str | None = None + size_half_points: int | None = None # 2 * point size + highlight: str | None = None + hyperlink_id: str | None = None + tracked_change_id: str | None = None + + +@dataclass +class BreakRun: + """A line or page break.""" + + type: str = "break" + kind: str = "line" # "line" | "page" | "column" + tracked_change_id: str | None = None + + +@dataclass +class TabRun: + """A tab character.""" + + type: str = "tab" + tracked_change_id: str | None = None + + +Run = Union[TextRun, BreakRun, TabRun] + + +# --------------------------------------------------------------------------- +# Paragraph +# --------------------------------------------------------------------------- +@dataclass +class Paragraph: + """A paragraph (block). Carries its positional id ``pN``.""" + + id: str + type: str = "paragraph" + style: str | None = None + alignment: str | None = None # left | center | right | justify + runs: list[Run] = field(default_factory=list) + list_level: int | None = None + list_id: str | None = None + indent_left: int | None = None # twips + space_before: int | None = None # twips + space_after: int | None = None # twips + # Tracked change wrapping this paragraph (e.g. a deleted paragraph). + tracked_change_id: str | None = None + + @property + def text(self) -> str: + """The concatenated text of all text runs (for search/replace).""" + return "".join(r.text for r in self.runs if isinstance(r, TextRun)) + + +# --------------------------------------------------------------------------- +# Tables +# --------------------------------------------------------------------------- +@dataclass +class TableCell: + """A table cell, containing nested blocks.""" + + blocks: list[Union["Paragraph", "Table", "SectionBreak"]] = field(default_factory=list) + grid_span: int = 1 + v_merge: str | None = None # restart | continue + shading: str | None = None + width_twips: int | None = None + v_align: str | None = None # top | center | bottom + + +@dataclass +class TableRow: + """A table row.""" + + cells: list[TableCell] = field(default_factory=list) + repeat_header: bool = False + height_twips: int | None = None + tracked_change_id: str | None = None + + +@dataclass +class Table: + """A table (block). Carries its positional id ``tN``.""" + + id: str + type: str = "table" + rows: list[TableRow] = field(default_factory=list) + grid: list[int] = field(default_factory=list) # column widths in twips + style: str | None = None + align: str | None = None # left | center | right + + +# --------------------------------------------------------------------------- +# Sections & marginals +# --------------------------------------------------------------------------- +@dataclass +class SectionBreak: + """A section break. Carries its positional id ``sN``.""" + + id: str + type: str = "sectionBreak" + columns: int | None = None + section_type: str | None = None # continuous | nextPage | evenPage | oddPage + page_orientation: str | None = None # portrait | landscape + margin_top: int | None = None # twips + margin_right: int | None = None + margin_bottom: int | None = None + margin_left: int | None = None + + +@dataclass +class Marginal: + """A header or footer. Carries its positional id (``hdrN`` / ``ftrN``).""" + + id: str + kind: str # header | footer + type: str = "default" # default | first | even + text: str = "" + section_id: str | None = None + + +# --------------------------------------------------------------------------- +# Legal-grade features +# --------------------------------------------------------------------------- +@dataclass +class Comment: + """A document comment. Carries its positional id ``cN``.""" + + id: str + author: str = "" + text: str = "" + date: str | None = None + parent_id: str | None = None + resolved: bool = False + start_block: str | None = None + start_offset: int = 0 + end_block: str | None = None + end_offset: int = 0 + + +@dataclass +class Note: + """A footnote or endnote body. Carries its positional id (``fnN`` / ``enN``).""" + + id: str + kind: str # footnote | endnote + text: str = "" + + +@dataclass +class TrackedChange: + """A tracked change (insertion / deletion / move). Id ``tcN``.""" + + id: str + kind: str # ins | del | moveFrom | moveTo + author: str = "" + date: str | None = None + revision_id: str = "" + paragraph_id: str | None = None + + +# --------------------------------------------------------------------------- +# Document +# --------------------------------------------------------------------------- +Block = Union[Paragraph, Table, SectionBreak] + + +@dataclass +class Document: + """The root AST node: body blocks plus side tables.""" + + type: str = "document" + blocks: list[Block] = field(default_factory=list) + properties: DocProperties = field(default_factory=DocProperties) + comments: list[Comment] = field(default_factory=list) + footnotes: list[Note] = field(default_factory=list) + endnotes: list[Note] = field(default_factory=list) + tracked_changes: list[TrackedChange] = field(default_factory=list) + # Numbering definitions (abstractNum / num) — surfaced on demand. + styles: dict[str, dict] = field(default_factory=dict) + # Marginals keyed by section id -> {"header": [...], "footer": [...]}. + marginals: dict[str, dict] = field(default_factory=dict) + path: str | None = None + + # ------------------------------------------------------------------ + # Convenience accessors + # ------------------------------------------------------------------ + def paragraphs(self) -> list[Paragraph]: + return [b for b in self.blocks if isinstance(b, Paragraph)] + + def tables(self) -> list[Table]: + return [b for b in self.blocks if isinstance(b, Table)] + + def sections(self) -> list[SectionBreak]: + return [b for b in self.blocks if isinstance(b, SectionBreak)] + + def get_block(self, block_id: str) -> Block | None: + for b in self.blocks: + if b.id == block_id: + return b + return None + + def all_notes(self) -> list[Note]: + return self.footnotes + self.endnotes diff --git a/src/doced/ast/reader.py b/src/doced/ast/reader.py new file mode 100644 index 0000000..2d1358b --- /dev/null +++ b/src/doced/ast/reader.py @@ -0,0 +1,315 @@ +"""The reader: parse ``word/document.xml`` into the typed AST. + +This is the *view* builder — it walks the parsed XML tree and populates a +:class:`~doced.ast.model.Document`. It never mutates; it only reads. The +positional ids (``pN`` / ``tN`` / ``sN`` / ``cN`` / ``tcN`` / …) are assigned +in document order here, so they are stable within a single read and shift only +after structural edits (see :mod:`doced.ast.locator`). + +The reader is namespace-aware (see :mod:`doced.xmlcore`) and tolerant: unknown +elements are skipped rather than raising, so a document with features we don't +model still reads. +""" + +from __future__ import annotations + +from lxml import etree as ET + +from .. import xmlcore as xc +from ..package import Package, PackagePartMissing +from . import model as M + + +def _local(el: ET._Element) -> str: + return xc.local(el.tag) + + +def _xn(prefix: str, name: str) -> str: + """Namespace-qualified attribute name, e.g. ``{w}val``.""" + return f"{{{xc.NSMAP[prefix]}}}{name}" + + +def _opt(el: ET._Element | None, prefix: str, name: str, attr: str) -> str | None: + """Return ``el.{prefix}:{attr}`` if ``el`` and the attr exist, else None.""" + if el is None: + return None + val = el.get(_xn(prefix, attr)) + return val if val else None + + +# --------------------------------------------------------------------------- +# Character formatting extraction. +# --------------------------------------------------------------------------- +def _run_format(rPr: ET._Element | None) -> dict: + """Extract character formatting from a ```` element.""" + fmt: dict = {} + if rPr is None: + return fmt + if xc.find(rPr, "w:b") is not None: + fmt["bold"] = _opt(xc.find(rPr, "w:b"), "w", "b", "val") != "false" + if xc.find(rPr, "w:i") is not None: + fmt["italic"] = True + u = xc.find(rPr, "w:u") + if u is not None and u.get(_xn("w", "val")) != "none": + fmt["underline"] = True + if xc.find(rPr, "w:strike") is not None: + fmt["strike"] = True + color = xc.find(rPr, "w:color") + col = _opt(color, "w", "color", "val") + if col and col != "auto": + fmt["color"] = col + font = xc.find(rPr, "w:rFonts") + f = _opt(font, "w", "rFonts", "ascii") + if f: + fmt["font"] = f + sz = xc.find(rPr, "w:sz") + sz_val = _opt(sz, "w", "sz", "val") + if sz_val: + fmt["size_half_points"] = int(sz_val) + return fmt + + +# --------------------------------------------------------------------------- +# Paragraph reading. +# --------------------------------------------------------------------------- +def _paragraph_runs(pEl: ET._Element) -> tuple[list[M.Run], str | None]: + """Extract runs and the paragraph style id from a ```` element.""" + runs: list[M.Run] = [] + style = _opt(xc.find(pEl, "w:pPr"), "w", "pStyle", "val") + + for child in pEl: + tag = _local(child) + if tag == "r": + run = _run_element(child) + if run is not None: + runs.append(run) + elif tag == "br": + br_type = _opt(child, "w", "br", "type") + kind = "line" + if br_type == "page": + kind = "page" + elif br_type == "column": + kind = "column" + runs.append(M.BreakRun(kind=kind)) + elif tag == "tab": + runs.append(M.TabRun()) + return runs, style + + +def _run_element(rEl: ET._Element) -> M.Run | None: + """Build a run from a ```` element.""" + t = xc.find(rEl, "w:t") + if t is not None and t.text: + fmt = _run_format(xc.find(rEl, "w:rPr")) + hyperlink = xc.find(rEl, "r:hyperlink") + hl_id = _opt(hyperlink, "r", "hyperlink", "id") + return M.TextRun( + text=t.text, + bold=fmt.get("bold", False), + italic=fmt.get("italic", False), + underline=fmt.get("underline", False), + strike=fmt.get("strike", False), + color=fmt.get("color"), + font=fmt.get("font"), + size_half_points=fmt.get("size_half_points"), + hyperlink_id=hl_id, + ) + return None + + +def _paragraph_alignment(pEl: ET._Element) -> str | None: + return _opt(xc.find(pEl, "w:pPr"), "w", "jc", "val") + + +def _paragraph_list(pEl: ET._Element) -> tuple[int | None, str | None]: + numPr = xc.find(pEl, "w:pPr/w:numPr") + if numPr is None: + return None, None + level = _opt(numPr, "w", "ilvl", "val") + nid = _opt(numPr, "w", "numId", "val") + return int(level) if level else None, nid + + +# --------------------------------------------------------------------------- +# Table reading. +# --------------------------------------------------------------------------- +def _table_grid(tbl: ET._Element) -> list[int]: + widths: list[int] = [] + grid = xc.find(tbl, "w:tblGrid") + if grid is not None: + for col in grid.findall(xn("w", "gridCol")): + w = col.get(_xn("w", "w")) + if w: + widths.append(int(w)) + return widths + + +def _table_cell(tc: ET._Element) -> M.TableCell: + cell = M.TableCell() + tcPr = xc.find(tc, "w:tcPr") + if tcPr is not None: + span = xc.find(tcPr, "w:gridSpan") + if span is not None and span.get(_xn("w", "val")): + cell.grid_span = int(span.get(_xn("w", "val"))) + vMerge = xc.find(tcPr, "w:vMerge") + if vMerge is not None: + val = vMerge.get(_xn("w", "val")) + cell.v_merge = "restart" if val == "restart" else "continue" + shading = xc.find(tcPr, "w:shd") + if shading is not None and shading.get(_xn("w", "val")) not in (None, "clear"): + cell.shading = shading.get(_xn("w", "fill")) + tcW = xc.find(tcPr, "w:tcW") + if tcW is not None and tcW.get(_xn("w", "w")): + cell.width_twips = int(tcW.get(_xn("w", "w"))) + vAlign = xc.find(tcPr, "w:vAlign") + if vAlign is not None: + cell.v_align = vAlign.get(_xn("w", "val")) + + for child in tc: + if _local(child) == "p": + runs, style = _paragraph_runs(child) + para = M.Paragraph(id="", style=style, runs=runs) + cell.blocks.append(para) + return cell + + +def _read_table(tbl: ET._Element, idx: int) -> M.Table: + return M.Table( + id=f"t{idx}", + rows=[M.TableRow(cells=(_table_cell(tc) for tc in tbl.findall(_xn("w", "tr"))))], + grid=_table_grid(tbl), + ) + + +# --------------------------------------------------------------------------- +# Section break reading. +# --------------------------------------------------------------------------- +def _section_break(sid: str, sectPr: ET._Element) -> M.SectionBreak: + sb = M.SectionBreak(id=sid) + for child in sectPr: + tag = _local(child) + if tag == "cols" and child.get(_xn("w", "num")): + sb.columns = int(child.get(_xn("w", "num"))) + elif tag == "pgSz": + w = int(child.get(_xn("w", "w"), "0")) + h = int(child.get(_xn("w", "h"), "0")) + sb.page_orientation = "landscape" if w > h else "portrait" + elif tag == "pgMar": + sb.margin_top = _twips(child, "top") + sb.margin_right = _twips(child, "right") + sb.margin_bottom = _twips(child, "bottom") + sb.margin_left = _twips(child, "left") + return sb + + +def _twips(el: ET._Element, name: str) -> int | None: + val = el.get(_xn("w", name)) + return int(val) if val else None + + +# --------------------------------------------------------------------------- +# Side parts. +# --------------------------------------------------------------------------- +def _read_comments(pkg: Package) -> list[M.Comment]: + if "word/comments.xml" not in pkg.parts: + return [] + root = pkg.parse("word/comments.xml") + comments: list[M.Comment] = [] + for c in root.findall(xn("w", "comment")): + cid = c.get(_xn("w", "id")) or f"c{len(comments)}" + author = (xc.find(c, "w:author") or ET.Element("x")).get(_xn("w", "val"), "") + date = (xc.find(c, "w:date") or ET.Element("x")).get(_xn("w", "val")) + text = "".join(t.text or "" for t in c.iter(xn("w", "t"))) + comments.append(M.Comment(id=cid, author=author, date=date, text=text)) + return comments + + +def _read_notes(pkg: Package, part: str, kind: str) -> list[M.Note]: + if part not in pkg.parts: + return [] + root = pkg.parse(part) + notes: list[M.Note] = [] + for n in root.findall(xn("w", kind)): + nid = n.get(_xn("w", "id")) or f"{kind[0]}{len(notes) + 1}" + notes.append(M.Note(id=nid, kind=kind, text=n.text or "")) + return notes + + +def _read_properties(pkg: Package) -> M.DocProperties: + if "docProps/core.xml" not in pkg.parts: + return M.DocProperties() + root = pkg.parse("docProps/core.xml") + props = M.DocProperties() + for tag, attr in ( + ("dc:title", "title"), + ("dc:creator", "author"), + ("dcterms:created", "created"), + ("dcterms:modified", "modified"), + ("dc:subject", "subject"), + ("dc:keywords", "keywords"), + ): + el = xc.find(root, tag) + if el is not None and el.text: + setattr(props, attr, el.text) + return props + + +# --------------------------------------------------------------------------- +# Document reading. +# --------------------------------------------------------------------------- +def _iter_body_elements(body: ET._Element) -> list[ET._Element]: + """Return body elements in order, separating ```` out.""" + els: list[ET._Element] = [] + sect_pr: ET._Element | None = None + for child in body: + if _local(child) == "sectPr": + sect_pr = child + else: + els.append(child) + return els, sect_pr + + +def build_document(pkg: Package) -> M.Document: + """Parse the package into a typed :class:`M.Document`. + + Raises: + PackagePartMissing: if ``word/document.xml`` is absent. + """ + if "word/document.xml" not in pkg.parts: + raise PackagePartMissing("word/document.xml not found in package") + + root = pkg.parse("word/document.xml") + body = xc.find(root, "w:body") + if body is None: + body = root # degenerate document; treat root as body + + doc = M.Document(path=pkg.path) + p_idx, t_idx, s_idx = 0, 0, 0 + + for el in body: + tag = _local(el) + if tag == "p": + runs, style = _paragraph_runs(el) + doc.blocks.append(M.Paragraph( + id=f"p{p_idx}", + style=style, + runs=runs, + alignment=_paragraph_alignment(el), + list_level=_paragraph_list(el)[0], + list_id=_paragraph_list(el)[1], + )) + p_idx += 1 + elif tag == "tbl": + doc.blocks.append(_read_table(el, t_idx)) + t_idx += 1 + + # The trailing sectPr defines document-wide section properties. + sect_pr = xc.find(body, "w:sectPr") + if sect_pr is not None: + doc.blocks.append(_section_break(f"s{s_idx}", sect_pr)) + + doc.comments = _read_comments(pkg) + doc.footnotes = _read_notes(pkg, "word/footnotes.xml", "footnote") + doc.endnotes = _read_notes(pkg, "word/endnotes.xml", "endnote") + doc.properties = _read_properties(pkg) + return doc diff --git a/src/doced/cli.py b/src/doced/cli.py new file mode 100644 index 0000000..ad34db0 --- /dev/null +++ b/src/doced/cli.py @@ -0,0 +1,133 @@ +"""CLI entry point and command dispatch. + +``doced`` is a thin shell over :mod:`doced.ops` and :mod:`doced.features`. +Each command is a *command function* ``run(args: list[str]) -> int`` that +returns an exit code. Commands register themselves via :func:`register`, so +the dispatch table stays declarative and new verbs can be added by dropping a +module into :mod:`doced.ops` / :mod:`doced.features` and adding one line here. + +The surface is deliberately small and uniform: + +.. code-block:: text + + doced --version + doced --help + doced FILE [args] + +Every command prints a one-line ack (or a machine-parseable error) and returns +an exit code — see :mod:`doced.output` for the contract. +""" + +from __future__ import annotations + +import argparse +import sys +from typing import Callable, NamedTuple + +from . import __version__ +from . import output + +# A command function takes normalized args (after the command name) and +# returns an exit code. +CommandFn = Callable[[list[str]], int] + +# Registry: name -> (summary, command_fn) +_REGISTRY: dict[str, tuple[str, CommandFn]] = {} + + +def register(name: str, summary: str, fn: CommandFn) -> None: + """Register a command. ``name`` is the kebab-case verb on the CLI.""" + _REGISTRY[name] = (summary, fn) + + +class _Command(NamedTuple): + name: str + summary: str + fn: CommandFn + + +def _commands() -> list[_Command]: + return [_Command(n, s, f) for n, (s, f) in sorted(_REGISTRY.items())] + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="doced", + description="An LLM-driven CLI for advanced .docx manipulation.", + add_help=False, + ) + parser.add_argument("--version", action="store_true", help="show version and exit") + parser.add_argument( + "-h", "--help", action="store_true", help="show this help and exit" + ) + parser.add_argument("command", nargs="?", help="command to run") + parser.add_argument("args", nargs=argparse.REMAINDER) + return parser + + +def _print_version() -> int: + print(f"doced {__version__}") + return output.EXIT_OK + + +def _print_help() -> int: + lines = [ + f"doced {__version__} — an LLM-driven CLI for advanced .docx manipulation.", + "", + "USAGE:", + " doced [options] FILE [args]", + "", + "COMMANDS:", + ] + for cmd in _commands(): + lines.append(f" {cmd.name:<16} {cmd.summary}") + lines += [ + "", + "GLOBAL OPTIONS:", + " --version show version and exit", + " -h, --help show this help and exit", + "", + "Run 'doced --help' for command-specific options.", + "", + "The exit code is the success signal: 0 ok, 1 error, 2 usage, 3 not-found.", + ] + print("\n".join(lines)) + return output.EXIT_OK + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point. Returns an exit code (never raises for user errors).""" + argv = list(sys.argv[1:] if argv is None else argv) + parser = _build_parser() + + try: + ns = parser.parse_args(argv) + except SystemExit as exc: # argparse already printed a message + return int(exc.code) if exc.code is not None else output.EXIT_USAGE + + if ns.version: + return _print_version() + if ns.help or ns.command is None: + return _print_help() + + fn = _REGISTRY.get(ns.command) + if fn is None: + return output.emit_error( + output.ErrorCode.USAGE, + f"unknown command: {ns.command}", + hint='Run "doced --help" for the list of commands.', + ) + _, command_fn = fn + try: + return command_fn(ns.args) + except KeyboardInterrupt: + return output.EXIT_ERROR + except Exception as exc: # last-resort guard; commands should catch their own + return output.emit_error( + output.ErrorCode.UNHANDLED, + f"{type(exc).__name__}: {exc}", + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/doced/cli/__init__.py b/src/doced/cli/__init__.py new file mode 100644 index 0000000..aafd75c --- /dev/null +++ b/src/doced/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line interface entry points.""" diff --git a/src/doced/features/__init__.py b/src/doced/features/__init__.py new file mode 100644 index 0000000..3e156a7 --- /dev/null +++ b/src/doced/features/__init__.py @@ -0,0 +1 @@ +"""High-level feature operations (tracked changes, comments, tables, …).""" diff --git a/src/doced/ops/__init__.py b/src/doced/ops/__init__.py new file mode 100644 index 0000000..94f00d1 --- /dev/null +++ b/src/doced/ops/__init__.py @@ -0,0 +1 @@ +"""In-place mutation operations for DOCX parts.""" diff --git a/src/doced/ops/__pycache__/__init__.cpython-312.pyc b/src/doced/ops/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5367d14 Binary files /dev/null and b/src/doced/ops/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/doced/ops/__pycache__/load.cpython-312.pyc b/src/doced/ops/__pycache__/load.cpython-312.pyc new file mode 100644 index 0000000..1a454d0 Binary files /dev/null and b/src/doced/ops/__pycache__/load.cpython-312.pyc differ diff --git a/src/doced/ops/load.py b/src/doced/ops/load.py new file mode 100644 index 0000000..954b3b7 --- /dev/null +++ b/src/doced/ops/load.py @@ -0,0 +1,38 @@ +"""Document loading: open a ``.docx`` package and parse it into a model. + +This is the single choke point for reading a document. It opens the OPC +package and returns a :class:`~doced.ast.doc.DocumentModel` — the package plus +a parsed AST plus a locator index, ready for read + in-place mutation. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..ast.doc import DocumentModel +from ..package import ( + NotAZipError, + Package, + PackageNotFound, + PackagePartMissing, + PartNotFoundError, +) + + +def open_document(path: str | Path) -> DocumentModel: + """Open *path* and return a :class:`~doced.ast.doc.DocumentModel`. + + Raises the package-level errors through so the CLI can map them to the + LLM contract's error codes. + """ + pkg = Package.open(path) + return DocumentModel(pkg) + + +__all__ = [ + "open_document", + "PackageNotFound", + "PartNotFoundError", + "PackagePartMissing", + "NotAZipError", +] diff --git a/src/doced/output.py b/src/doced/output.py new file mode 100644 index 0000000..580ab00 --- /dev/null +++ b/src/doced/output.py @@ -0,0 +1,151 @@ +"""The LLM contract: machine-readable output + an exit-code success signal. + +This is the interface an LLM observes. It is modeled on the proven design used +by mature agent-facing DOCX tools: + +1. **Exit code is the success signal.** ``0`` ok · ``1`` error · ``2`` usage · + ``3`` not-found. Every command prints a one-line text confirmation, so an + LLM never has to re-read a file just to know a mutation landed. +2. **A zero-effect mutation is an error** (nonzero exit), so the LLM's + done/retry decision off the exit code is reliable. +3. **Errors are machine-parseable**: ``{"code": "...", "error": "...", + "hint": "..."}`` — the LLM reads the ``code`` to decide recovery. + +The one-line ack is text-first (not JSON) by default, so a weak model reading +stdout sees a human/LLM-friendly sentence; ``--verbose`` prints full JSON. +""" + +from __future__ import annotations + +import sys +from typing import Any + +# --------------------------------------------------------------------------- +# Exit codes. +# --------------------------------------------------------------------------- +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_USAGE = 2 +EXIT_NOT_FOUND = 3 + + +class ExitCode(int): + """A typed exit-code sentinel (subclass of ``int`` for CLI ergonomics).""" + + OK = EXIT_OK + ERROR = EXIT_ERROR + USAGE = EXIT_USAGE + NOT_FOUND = EXIT_NOT_FOUND + + +# --------------------------------------------------------------------------- +# Error codes (machine-readable ``code`` field on failures). +# --------------------------------------------------------------------------- +class ErrorCode: + USAGE = "USAGE" + FILE_NOT_FOUND = "FILE_NOT_FOUND" + PART_NOT_FOUND = "PART_NOT_FOUND" + NOT_A_ZIP = "NOT_A_ZIP" + INVALID_LOCATOR = "INVALID_LOCATOR" + BLOCK_NOT_FOUND = "BLOCK_NOT_FOUND" + RANGE_NOT_FOUND = "RANGE_NOT_FOUND" + MATCH_NOT_FOUND = "MATCH_NOT_FOUND" + COMMENT_NOT_FOUND = "COMMENT_NOT_FOUND" + IMAGE_NOT_FOUND = "IMAGE_NOT_FOUND" + IMAGE_SOURCE = "IMAGE_SOURCE" + HYPERLINK_NOT_FOUND = "HYPERLINK_NOT_FOUND" + RELATIONSHIP_NOT_FOUND = "RELATIONSHIP_NOT_FOUND" + TRACKED_CHANGE_NOT_FOUND = "TRACKED_CHANGE_NOT_FOUND" + TABLE_STRUCTURE = "TABLE_STRUCTURE" + INVALID_XML = "INVALID_XML" + VALIDATION_FAILED = "VALIDATION_FAILED" + RENDER_ENGINE = "RENDER_ENGINE" + RENDER_FAILED = "RENDER_FAILED" + LOCKED_FILE = "LOCKED_FILE" + ZERO_EFFECT = "ZERO_EFFECT" + UNHANDLED = "UNHANDLED" + + +# Exit code for each error code. +_EXIT_FOR_CODE: dict[str, int] = { + ErrorCode.USAGE: EXIT_USAGE, + ErrorCode.INVALID_LOCATOR: EXIT_USAGE, + ErrorCode.INVALID_XML: EXIT_USAGE, + ErrorCode.FILE_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.PART_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.BLOCK_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.RANGE_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.MATCH_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.COMMENT_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.IMAGE_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.HYPERLINK_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.RELATIONSHIP_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.TRACKED_CHANGE_NOT_FOUND: EXIT_NOT_FOUND, + ErrorCode.NOT_A_ZIP: EXIT_ERROR, + ErrorCode.TABLE_STRUCTURE: EXIT_ERROR, + ErrorCode.VALIDATION_FAILED: EXIT_ERROR, + ErrorCode.IMAGE_SOURCE: EXIT_ERROR, + ErrorCode.RENDER_ENGINE: EXIT_ERROR, + ErrorCode.RENDER_FAILED: EXIT_ERROR, + ErrorCode.LOCKED_FILE: EXIT_ERROR, + ErrorCode.ZERO_EFFECT: EXIT_ERROR, + ErrorCode.UNHANDLED: EXIT_ERROR, +} + + +# --------------------------------------------------------------------------- +# Output helpers. +# --------------------------------------------------------------------------- +def emit_success(message: str, *, verbose: bool = False, payload: dict[str, Any] | None = None) -> int: + """Print a success ack and return ``EXIT_OK``. + + Args: + message: The one-line, text-first confirmation. + verbose: When true, also emit the full JSON payload. + payload: Optional structured data (e.g. minted locators). + """ + print(message) + if verbose and payload: + import json + + print(json.dumps(payload)) + return EXIT_OK + + +def emit_error( + code: str, + message: str, + *, + hint: str | None = None, +) -> int: + """Print a machine-parseable error and return the mapped exit code. + + The stdout line is ``{"code": "...", "error": "...", "hint": "..."}`` so an + LLM can parse the ``code`` to decide recovery. The hint is a human/LLM + friendly recovery suggestion. + """ + import json + + payload: dict[str, Any] = {"code": code, "error": message} + if hint: + payload["hint"] = hint + print(json.dumps(payload)) + return _EXIT_FOR_CODE.get(code, EXIT_ERROR) + + +def emit_json(payload: dict[str, Any]) -> int: + """Print a full JSON payload (used by ``read --ast`` / ``--verbose``).""" + import json + + print(json.dumps(payload)) + return EXIT_OK + + +# The canonical "locator ids shift after structural edits" hint. +STALE_LOCATOR_HINT = ( + "Locator ids are positional and shift after structural edits " + "(insert/delete/section changes) — an id from an earlier read may now point " + "elsewhere or be gone. Re-read the file to get current ids, then retry. " + "Applying several changes? Do them from ONE read with --batch so ids never " + "go stale mid-run." +) diff --git a/src/doced/package.py b/src/doced/package.py new file mode 100644 index 0000000..f4e721b --- /dev/null +++ b/src/doced/package.py @@ -0,0 +1,189 @@ +"""OPC package I/O: open a ``.docx`` as a zip of XML parts. + +A ``.docx`` is an Open Packaging Convention (OPC) package — a zip file whose +entries are *parts* (XML documents) plus media. :class:`Package` opens that +zip, reads parts into memory, and — crucially — **mutates parts in place** and +re-zips on save. We never rebuild a part from a lossy round-trip. + +The class is deliberately thin: it owns bytes and part paths. Semantic views +(building the AST, resolving locators) live in :mod:`doced.ast`. +""" + +from __future__ import annotations + +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +from . import xmlcore as xc + +# Required / optional OPC part paths. ``word/document.xml`` is mandatory; the +# rest are optional and materialized lazily. +DOCUMENT_PART = "word/document.xml" +DEFAULT_CONTENT_TYPES = "[Content_Types].xml" + + +class DocedError(Exception): + """Base class for all doced errors.""" + + +class PackageNotFound(DocedError): + """Raised when the target path does not exist.""" + + +class NotAZipError(DocedError): + """Raised when the file is not a valid zip / OPC package.""" + + +class PartNotFoundError(DocedError): + """Raised when a requested part is absent from the package.""" + + +class PackagePartMissing(DocedError): + """Raised when a required part (e.g. word/document.xml) is absent.""" + + +@dataclass +class Package: + """An in-memory OPC package. + + Attributes: + path: The on-disk path this package was opened from (may be ``None`` + for a freshly-created, never-saved package). + parts: Mapping of part path -> raw XML bytes. + media: Mapping of media path (e.g. ``word/media/img1.png``) -> bytes. + """ + + path: str | None + parts: dict[str, bytes] = field(default_factory=dict) + media: dict[str, bytes] = field(default_factory=dict) + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + @classmethod + def open(cls, path: str | Path) -> "Package": + """Open an existing ``.docx`` from *path*. + + Raises: + PackageNotFound: if the path does not exist. + NotAZipError: if the file is not a readable zip. + """ + p = Path(path) + if not p.exists(): + raise PackageNotFound(f"file not found: {p}") + if not p.is_file(): + raise NotAZipError(f"not a file: {p}") + try: + with zipfile.ZipFile(p, "r") as zf: + bad = zf.testzip() + if bad is not None: + raise NotAZipError(f"corrupt zip entry: {bad}") + parts = { + n: zf.read(n) + for n in zf.namelist() + if not n.startswith("word/media/") + } + media = { + n: zf.read(n) + for n in zf.namelist() + if n.startswith("word/media/") + } + except zipfile.BadZipFile as exc: + raise NotAZipError(f"not a valid zip/OPC package: {exc}") from exc + return cls(path=str(p), parts=parts, media=media) + + @classmethod + def empty(cls, path: str | None = None) -> "Package": + """Create an empty (or lazily-populated) package. + + Used for authoring new documents and for ``--dry-run`` previews. Parts + are added on demand via :meth:`write_part`. + """ + return cls(path=path, parts={}, media={}) + + # ------------------------------------------------------------------ + # Part access + # ------------------------------------------------------------------ + def has_part(self, part: str) -> bool: + return part in self.parts + + def read_part(self, part: str) -> bytes: + """Return the raw bytes of *part*. + + Raises: + PartNotFoundError: if the part is absent. + """ + if part not in self.parts: + raise PartNotFoundError(f"part not found: {part}") + return self.parts[part] + + def read_text(self, part: str) -> str: + """Return the decoded text of *part*.""" + return self.read_part(part).decode("utf-8") + + def parse(self, part: str): + """Parse *part* into an lxml root element.""" + return xc.parse_root(self.read_text(part)) + + def write_part(self, part: str, xml: str | bytes) -> None: + """Stash *xml* for *part*; written to disk on :meth:`save`.""" + self.parts[part] = xml.encode("utf-8") if isinstance(xml, str) else xml + + def delete_part(self, part: str) -> None: + self.parts.pop(part, None) + + # ------------------------------------------------------------------ + # Media + # ------------------------------------------------------------------ + def write_media(self, path: str, data: bytes) -> None: + self.media[path] = data + + def read_media(self, path: str) -> bytes: + if path not in self.media: + raise PartNotFoundError(f"media not found: {path}") + return self.media[path] + + # ------------------------------------------------------------------ + # Save + # ------------------------------------------------------------------ + def save(self, path: str | Path | None = None) -> None: + """Re-zip the package and write it to *path* (defaults to the source). + + Writes an initial ``[Content_Types].xml`` if the package was created + empty and none was provided. + """ + target = Path(path) if path else ( + Path(self.path) if self.path else None + ) + if target is None: + raise DocedError("cannot save: no path specified") + + # Ensure a content-types part exists for a fresh package. + if DEFAULT_CONTENT_TYPES not in self.parts: + self.parts[DEFAULT_CONTENT_TYPES] = self._default_content_types_xml() + + with zipfile.ZipFile( + target, "w", compression=zipfile.ZIP_DEFLATED + ) as zf: + # Content types first (convention, not required, but tidy). + zf.writestr(DEFAULT_CONTENT_TYPES, self.parts[DEFAULT_CONTENT_TYPES]) + for part, data in self.parts.items(): + if part == DEFAULT_CONTENT_TYPES: + continue + zf.writestr(part, data) + for mpath, data in self.media.items(): + zf.writestr(mpath, data) + + @staticmethod + def _default_content_types_xml() -> str: + return ( + '\n' + '' + '' + '' + '' + "" + ) diff --git a/src/doced/xmlcore.py b/src/doced/xmlcore.py new file mode 100644 index 0000000..9feec36 --- /dev/null +++ b/src/doced/xmlcore.py @@ -0,0 +1,131 @@ +"""XML core helpers for DOCX (OOXML) processing. + +All XML handling funnels through :mod:`doced.xmlcore` so that: + +* parsing is **secure** (no external-entity / network access — + :data:`lxml.etree.default_parser` is configured with ``no_network`` and + ``resolve_none_uri``), +* namespaces are handled through a single canonical map, and +* serialization preserves unmodeled elements byte-for-byte (in-place + mutation is the whole point — we never re-emit untouched subtrees). +""" + +from __future__ import annotations + +from lxml import etree as ET + +# --------------------------------------------------------------------------- +# Canonical OOXML namespace map. +# --------------------------------------------------------------------------- +NSMAP: dict[str, str] = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", + "o": "http://schemas.openxmlformats.org/officeDocument/2006/officeMetadata", + "v": "urn:schemas-microsoft-com:vmo", + "w10": "urn:schemas-microsoft-com:office:word", + "wp": "http://schemas.openxmlformats.org/drawingml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "rId": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "m": "http://schemas.openxmlformats.org/officeDocument/2006/math", + "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w15": "http://schemas.microsoft.com/office/word/2012/wordml", + "w16": "http://schemas.microsoft.com/office/word/2018/wordml", + "w16c": "http://schemas.microsoft.com/office/word/2018/wordml/collection", + "w16e": "http://schemas.microsoft.com/office/word/2019/wordml", + "dc": "http://purl.org/dc/elements/1.1/", + "dcterms": "http://purl.org/dc/terms/", + "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", +} + +# Element local-name lookup helpers. +def local(tag: str) -> str: + """Return the local name of a namespaced ``{uri}local`` tag.""" + if "}" in tag: + return tag.split("}", 1)[1] + return tag + + +def qn(prefix: str, name: str) -> str: + """Build a fully-qualified ``{uri}name`` tag from the canonical map.""" + uri = NSMAP.get(prefix) + if uri is None: + raise KeyError(f"unknown namespace prefix: {prefix!r}") + return f"{{{uri}}}{name}" + + +# --------------------------------------------------------------------------- +# Secure parser / serializer configuration. +# --------------------------------------------------------------------------- + +def get_parser() -> ET.XMLParser: + """Return a secure parser: no network, no external entities, DTD-safe. + + ``no_network`` blocks DTD/XSD fetches and ``resolve_none_uri`` prevents the + parser from trying to resolve ``system`` URIs. ``dtd_validation`` is off — + Word files are common; they are validated explicitly by ``doced validate``. + """ + return ET.XMLParser( + no_network=True, + load_dtd=False, + dtd_validation=False, + huge_tree=False, + recover=True, + ) + + +def parse_string(text: str | bytes) -> ET._ElementTree: + """Parse an OOXML part from a string/bytes into an ElementTree.""" + return ET.fromstring(text, parser=get_parser()) + + +def parse_root(text: str | bytes) -> ET._Element: + """Parse and return the root element of an OOXML part. + + Accepts ``bytes`` (preferred — preserves the XML encoding declaration) or + ``str``. Word parts carry ````, which requires + bytes input to :func:`lxml.etree.fromstring`. + """ + if isinstance(text, str): + text = text.encode("utf-8") + root = ET.fromstring(text, parser=get_parser()) + if root is None: + raise ValueError("empty or malformed XML part") + return root + + +def tostring( + element: ET._Element, + *, + xml_declaration: bool = False, + pretty_print: bool = False, +) -> bytes: + """Serialize an element to bytes, preserving namespaces. + + ``xml_declaration`` emits ```` which + each OPC part carries. ``pretty_print`` is deliberately *off* by default — + DOCX fidelity depends on compact, byte-stable output. + """ + return ET.tostring( + element, + xml_declaration=xml_declaration, + encoding="UTF-8", + pretty_print=pretty_print, + ) + + +def find(element: ET._Element, path: str) -> ET._Element | None: + """Namespace-aware ``find`` using the canonical map prefixes.""" + return element.find(path, namespaces=NSMAP) + + +def findall(element: ET._Element, path: str) -> list[ET._Element]: + """Namespace-aware ``findall`` using the canonical map prefixes.""" + return element.findall(path, namespaces=NSMAP) + + +def iter_all(element: ET._Element, tag: str) -> list[ET._Element]: + """Iterate all descendants with a given *local* name, namespace-agnostic.""" + local_name = local(tag) + return [e for e in element.iter() if local(e.tag) == local_name] diff --git a/tests/fixtures/sample.docx b/tests/fixtures/sample.docx new file mode 100644 index 0000000..6551a84 Binary files /dev/null and b/tests/fixtures/sample.docx differ