From 8b531719138857192aa8cdb6742e7fe355e4548a Mon Sep 17 00:00:00 2001 From: doced builder Date: Tue, 8 Sep 2026 03:33:01 +0000 Subject: [PATCH] Scaffold doced: OPC package I/O, XML core, AST model, locator model, reader, LLM contract --- .gitignore | 11 + PLAN.md | 259 ++++++++++++++ pyproject.toml | 50 +++ src/doced/__init__.py | 13 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 600 bytes src/doced/__pycache__/output.cpython-312.pyc | Bin 0 -> 5564 bytes src/doced/__pycache__/package.cpython-312.pyc | Bin 0 -> 9283 bytes src/doced/__pycache__/xmlcore.cpython-312.pyc | Bin 0 -> 6181 bytes src/doced/ast/__init__.py | 1 + .../ast/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 221 bytes src/doced/ast/__pycache__/doc.cpython-312.pyc | Bin 0 -> 5942 bytes .../ast/__pycache__/locator.cpython-312.pyc | Bin 0 -> 9259 bytes .../ast/__pycache__/model.cpython-312.pyc | Bin 0 -> 10845 bytes .../ast/__pycache__/reader.cpython-312.pyc | Bin 0 -> 16316 bytes src/doced/ast/doc.py | 120 +++++++ src/doced/ast/locator.py | 196 +++++++++++ src/doced/ast/model.py | 258 ++++++++++++++ src/doced/ast/reader.py | 315 ++++++++++++++++++ src/doced/cli.py | 133 ++++++++ src/doced/cli/__init__.py | 1 + src/doced/features/__init__.py | 1 + src/doced/ops/__init__.py | 1 + .../ops/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 209 bytes .../ops/__pycache__/load.cpython-312.pyc | Bin 0 -> 1235 bytes src/doced/ops/load.py | 38 +++ src/doced/output.py | 151 +++++++++ src/doced/package.py | 189 +++++++++++ src/doced/xmlcore.py | 131 ++++++++ tests/fixtures/sample.docx | Bin 0 -> 38153 bytes 29 files changed, 1868 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 pyproject.toml create mode 100644 src/doced/__init__.py create mode 100644 src/doced/__pycache__/__init__.cpython-312.pyc create mode 100644 src/doced/__pycache__/output.cpython-312.pyc create mode 100644 src/doced/__pycache__/package.cpython-312.pyc create mode 100644 src/doced/__pycache__/xmlcore.cpython-312.pyc create mode 100644 src/doced/ast/__init__.py create mode 100644 src/doced/ast/__pycache__/__init__.cpython-312.pyc create mode 100644 src/doced/ast/__pycache__/doc.cpython-312.pyc create mode 100644 src/doced/ast/__pycache__/locator.cpython-312.pyc create mode 100644 src/doced/ast/__pycache__/model.cpython-312.pyc create mode 100644 src/doced/ast/__pycache__/reader.cpython-312.pyc create mode 100644 src/doced/ast/doc.py create mode 100644 src/doced/ast/locator.py create mode 100644 src/doced/ast/model.py create mode 100644 src/doced/ast/reader.py create mode 100644 src/doced/cli.py create mode 100644 src/doced/cli/__init__.py create mode 100644 src/doced/features/__init__.py create mode 100644 src/doced/ops/__init__.py create mode 100644 src/doced/ops/__pycache__/__init__.cpython-312.pyc create mode 100644 src/doced/ops/__pycache__/load.cpython-312.pyc create mode 100644 src/doced/ops/load.py create mode 100644 src/doced/output.py create mode 100644 src/doced/package.py create mode 100644 src/doced/xmlcore.py create mode 100644 tests/fixtures/sample.docx 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 0000000000000000000000000000000000000000..505a4a6d40ddb44eb52edd5c976ff4b3332f1bd4 GIT binary patch literal 600 zcmYjOJ#Q015WV$B0(T0cK|(@8BhlbUzDwE&rE!`_4n~3^aedFfuOdAOd<&0XCI^N{a$FVXhiFeNt*-YZM!tqiZBM zJgSwJ%1mM6Y)NCa^?ngMTN~LAPE{$qM1-Bv%AI_*a(bgQUu_s}N#D;#D-pNBG_$Y{ z|AQOBGt?D2pT^&ttM}M45yn_jPKWWQ-K}&x-C8D`lSoe~&Y$-@SOu_u@J9-JkY$-{Tq`MSGH_1~>1xvBsF literal 0 HcmV?d00001 diff --git a/src/doced/__pycache__/output.cpython-312.pyc b/src/doced/__pycache__/output.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb7c24225a4721d6fb77160a2fec612b97c6500c GIT binary patch literal 5564 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 literal 0 HcmV?d00001 diff --git a/src/doced/__pycache__/package.cpython-312.pyc b/src/doced/__pycache__/package.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67ad776443f4eab94faddf303c03d24d50a50569 GIT binary patch literal 9283 zcmcIqTWlLwdY&OUBt=ppMM<`NlSZ@?i%LYwa^l#EEw3yoi#V~Q*b1^y>ZL}Ukwt~K z%#1E%sjN0giWqCGWhAKNrVIOFA8a_l8hxpOJ`|hH0t@toS}Ce??c(rUoxd@8aUaM1fj)|lU{?5zHB=TknH%9` zUbe*fgk{9SQ{5W3j#%-w#cc^;L`c|2>ZBr2ehsNTBisMUJrP~ z9gDfz9rMMF)Mu>0T~lB495*=BE2gFB+tMXPd^tSWC8p9!Qk2Be(U6>q&W?_Xl7`Q0 zEG?$S#UGt}6}?qm3%Oi96@6mplN1MO=n$JMmb@hPrjpZ2QjevQVk?#jme%>t`Hu@z zj>qDPI5Uyb6c>7`F$MFeirB{1XcJp$J)kj_K&yrc;!~QKP~?~t61$>tNz=MU&GnQ~ zhPWo`6O!%%w$F;HBFUPGtr$z{DGW@c)XcNMR2B(^s5%vmN%44weYLeErgTXM25fN* z!E#bmX=|eaE>&sAteS|;XwtM265mk7q%y6jSZ8c17MHOj8YqsdsRZirl%{1wH8qu# z+p)1U2sS*Shzw~FV#zKsri-bWq$ZAK2n)iJO_-MS2`waEP!bY| z85O5v%8ce}Ws4G-bxhlHVVE|orQ*{Bil?HIo>D=vcx+mst#l<)a@QzGsmLKo(?>(D zKM^kkdBZIwlPPQ*$D|DeEk>0r=@LWJ5XNIlTt*+;5ODYtxP=BdW)tyfN>#G8GbvRL z7mp_daJKF(9J-FfiVSDciguBJ(E`)47tMi(-|(Sa

g%hj>|Je#Dxw1g(avk5KGa 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 JhYY|d_`mj@kwgFh literal 0 HcmV?d00001 diff --git a/src/doced/__pycache__/xmlcore.cpython-312.pyc b/src/doced/__pycache__/xmlcore.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05cc6c762f301e34bf1265bd0b91b4267effe70a GIT binary patch literal 6181 zcmb_g-ESMmwclNm5=Ba)Nov{tC-8$Moy2xW;sYBw)W;{XvSc z0@r=LP_n^2XZpf4bKETJHQO%|Yg3enwc$EtYZ1ITt6TmOE0r9#7_lpr7^DZMQc0Me zYcHEc$92qNO<1K;b`ml=dd2jrI$p|jG7TvUPF)>!MjF^AGjtGIhHkTpUY(R&Cm2Mq zblYm^zU4Zwo{0y`;8b&98k6{-sX#}MU0LzXDHwc;I_#k3OjT{X6{1yYen<~p{3Vms zmt4HB>ROIZW?*#_##uH66Xujzv3zj%-I}ok?q2N*S;fodQhy+K&#FOMcN{ll=wXE%L!D`IQ?t} zZnJVn6^~UTe7NNpjC{^JGpa%Cx@R>;xn=m@@O&}J(7Ycc&R;w? z|F!|p?_HD+V~V1#A4lgdvR_kKR>ZcZax;QZ>dimUy9k!NDnc5a7$!V=$R17Sm)we( zuj{rw<>q0MtJq;LFY`;@6GqgFGEJ_!5+o#d@jS@Ciof>)npNfV!PG6|+l_W+^PQF3 zEBChC(;sHoPsaWybD)(x&`yu8zPXNdiV>+teWhodZ(?v)@#RBmuEJzbUQOkGWU`xD zQ(GPN6WtYL)Q`0vYTfzoz>H1pV}-|>>c`3t<1k>nG5=c4vN^0(uG#j=)CV9<`h%K6akbKKr zIU|HCvaw(wTp7gaJwXhcH#dneGWD!)r*?qqLg*UmmnJ-2$g zJ(ya(@!9D1*3QGfI{T|aYsaaFqjRm~Tze$57Q0njdu`2ME4R{nTJb%Cf!@HscMYP4 z*Qx7A(Sf0RzIU~MJEG;}h4QC9h~1BjOeU-qKo+?n60|HVWU*n20z-8XCI3n4cPkAh zD-A^0BwM(&Fy-lG6EzOBhS?vh7p}mmu6eX5|K7y~RyKWv9f|{oHt6q*2r~S#%<#zF zl3!SwM0+Pzr~vX^ByU-MfQPr@`DO(>{O&t%csV@aJ})lA36PcmXqZ4D0H}^?AjZCq z1jEXf9hE3R@`~%6Air3VHA_TG)y8#%m0|hz3hE8wOtl&7&X-C#Dd45E*+h`qP&!E3 zuFi`PBN%;LmSAY9wrCcq#s~(5X}Bmu2zJg~3WfkkZ_6U=Q$=ob=shX8TU4A0@}N`r zM{)VZs4_fyXZH4Nd&`dY*!K30-R;dg+MBk1kscTx=qLk;fzVxgE&)>7bm=ayqVp_w zX(}6$cpvNOdZ2b;4348UW~Ip3OeQN{avDF}Dfp2uuvdAi&pd|W%cW&!!ustJ)t;ZK z2p*|2f-4W^L5c!2q?6hL(BeKkE=07@BEyoT7_B1kJ&(V)4GkhIJ#y#d?UVPm{_N^c z?O&%4x8jHYi4lGRntmfd;a=2q)f8hu^d2CAAiB8P1*MycPf;kZtI2L};|{Lr?x<(R zUN*R?Hr3U%Khj;nV_m-A!HkT~6n3y{XAZ~jr3>M@ezzi)38Q}-qG+5){a;1BTq?

KsXO5d~*LxZhHSy{UUS==?$h1|G4n> z=@3WAt&m^{()(=?0y>U^))f=ZX#$)l0Phn7(a~|X2letaUbbj4+=QDrqj4ky2Zvmo zgbaUDDmeugxq>sITl3+EA*eSJ(sO}KufkSAvKtg)9>c5_;#JajLmWaKFc?`*u9vGG zs{;giifpc+o;y7F`^~a2O1CM31j825npQkrd*18veug*rVq8^hyGr> zUgWNUus?E}8(Bre=3=X#yt)}=LAet%v_+KN@9$BRo3V|`uNk`)(YYp6y& z^~2rG*!Q&$pO1sX&Dce>s_fNqoM@bV1II9g7K84H8qYx3==9Y^eh|{xqJED^*luoDmywWjnG*$+~ZzDR_DYI-Cz%Ezc|P7+O#Z2u0P zuj20!%pt{(ZC~>~+O_&-d&l0p&F?oq`quxY7uNPY*!GJLep&mpc7OMO?s}L#{k{4B zO)s?K3xC1QqmPGcTc!+A|mzcGB~ zQm$97fg2@^M`<7dYlfg1F3Vt9v9I8XR*Fl+j~G2l_pQh#L({xVv#+8F;#XYPmPjJL zMh}T3NJvzMq!e*NE`E!8q)hnQ&3#NE(0P2H`zOmEjoFsxf1{7){l`}KEG3zX_wbtR zSTI;Dq82U|gH*9tuAv|?i$y`+8pP@2qM*GDldL>6Ws1eV&uCamZXr)aGXDyy;$%(@ zQBtCY3@+MxF+&Zbh7MGZlC$_2ShLMj;(ZJ$A$kVdPE1wRKWQm7{^xN;J@9*FcPF)V zr`C=SwuWBs#L)emy5~CybUOn|VyKmRy)#IIq>@OshAwo5XpoYFgPk-DhLuFBl|IxN zp~0x6neSw1ut_d^wKGP8&2rh>oh>xjDre4gw$Wg_oSE{b%# z*6_K`9vbYGgV#FG(}2mBPImUuV84I?x^&@5FG29k%c43{o$tY}wJF dIapvIy`DyI{h+2!uctKi@E56=dPbF-@He~s!Px)+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..432886d5e9b7d4b4218867c70fc8d112b44b37d5 GIT binary patch literal 221 zcmX@j%ge<81XCL3XW0Yk#~=<2FhUuhIe?7m3@Hpz43&(UOjQ<+!66E{`6;P6Itn@Y z$%!TTMKEq+UW!6VMyf(lYGO)ik%C59W@@=YQfX!mkf*8Vr^$GWJw84qKRG^rCBtWs z@xM&nm1xwIJM$`8zpjEuJ!?21@`901ctJuv_P literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b77d3485a76f296c1626f16082f6e5c75c42ee2 GIT binary patch literal 5942 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 WnisB%xILyOw-o?B_Y3NB-TGeuDJWI| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..16d56225ed4def987aa4a40c190f0db4e2369b65 GIT binary patch literal 16316 zcmcJ0d2kz7njaeXNr3kuk%UB25+sYVCEJoH*<(?pWXl$PP?l}k4g{h>3KR&?-Az%V zfe4z+B%q~Tf@@a@McNfycE)g|&2WyY(bneh*6truwb_JnOEIR4n>aJxR3=qP7}Kdr zT=^rv?=`vsk+dnhDzAX|UiZ7ccYNpj`ma46rvX7u-xzg0WH9`W9<*RoGu(0!nK=V* zIA!2D-WcP?jHiqo@07MImVo)oMWz2E)cpuVwzLT#;+Q4`5 zHAoxzEqpD~CVnelhqRgB#@8cl;RAdF(hdA}z7gq0eh1%#w3UB|Z${e2@8nyMwqMu~ z=$<%*iJmwU@e7d<9}#-|;~_ze_)njnObGlgJ`tW6i^Qew^JB5or~T2ml<-TaEL|9n z@cu)CC%T=^6KA5LA3u7({aiFMx!pfB5sjfU|8LXp`J>E9b@!D3Io<7lM)Joa=g|Au zghb5_&=84u>;jvD*zLz`oZ|^GDn%3VP|P3YMgO+br^g2{fnAuGq-8`c6V@_PDfip- zPXhjs0Qf{PIueiY&S>1Pt%V;8gVE1KgvdjFk*&uCR#}unL$Qc|GAf-x^^jkT#z*i> z3(F=do{0{tkUB%d61os2VInL|2mms|MHi}j} z9vX{?SSTZLIgW3kd+IDb!A6yOwB zC>~E>yRao99xV~t{@)Xh;mKBlGhkF~FNDHpLn9HTrj&UhBuK}j*oOGX?>V))g^d%Y zCoPze!Jk`x3Yj^Bq?e7B&{(TTY0@xiu6$PWH%n~>4Igs@0h3}pA66{1_A$jIg+>6T zF%lE0lK<;HyUrxWBD-*|V>=VO#)ZUaBrJ)9EfIc~D1=Kn9N%3kT6K?KP^>}Ln^1!; z8u1tF5v2`_F3-%q>3!MWxg)bjWY>lT^M+*<7{Zm16TEDGk0JwN#7R_zg|%ncjIwJ6 z-k9XB8UENjXb7059v#q+%UctnSadjw%??SD5FMJ3BJ40dE2b zOF^QIXCcWYO*czRrfp@)oHPr@B&N@ESOVkJk(VMkvT;lhB;{>&4ym5EX%SLH-iBET zatvKS&7IUf}zEKLk_aUcTYE%c0e=Inq-_Z*9@(p4l?J<-OYM$=nIKdb8~8NFV-9O?_t1g1J5S zC?a*KDwjcDsz1ck=L}Ip3JaCSt`Dw@13afK*QIR|!O6mUm{V@W8+j9NzFFEh-g0&? zFpJ}@2hq>Ez1Z}zExaAA91qmWskKTOlg3LON#m%k1a?ItIkX2rXx3V2M}l_|oPs*4 z`+-LFXnkB#TU9+!E3einWh$?3(gc`%gR~{aY&fx1j%8 zzE0bflr?D;EJ-VRt$(0i8}wdn6}{TfYhwk}_M}~?l3dz4C+*tmVw@%#ht6N1d1_z( zc}WO`C4cx#h*EHs;e;>-^Z+&Y4-1Jge+U?NaI!~uL3k2qSK>$AQ>MwDA%&Y%xFMkp z{VA4VAX7v!4<%we6-G}e)~Ey`7N(~O&>`TZ;+TkoGsmLw$drAu2i!G!HlkS7bjmi_ z6Hdeuf?`nBB{vGyQhaTa$4v+8qCA^3r5R%=XK+ zmId3E#fFyayKn5hx;MY4V39X;&zt54Z+FQJPknOqPQ$YqQ|8FDYgMCS(?*m$J?&Yv zJ7;XuwyY`pvTWawK2)^X)2Uqbjryzg*G1~_+C{l}*Zkw3Ht#QPXh)l8@7SBl-5rx{ z?F+UYY&A6{YQh-LPq4&uh7nNHG9@ds znzf{dBv_1wJHVC-$_QFz=rg?ePYni80-augS(WHG(Ew1-Z8$pqGVQ7;wzVC;4FotMeBz%T-k%3tgNU0GTe;jSb1BSCh+!@EotQ)tfq-~q-=3|cCu$9??maE zV@V_furOMs<;rQ&cFU!~qNRW-1(@8_v&dcf2kZ7roODIFS$eMM^CL4TOwu@P1eNhj z9nz=_kOh(~3A*6i&@@6LLTLO9=}PS5H?{bh&F@NK&3mJ zpcsb)#Z3B+VkXL>n8!&xwE~YP#^R6)&1ZrCBq9z494Z;0WfC5yh(tZb&9Jyc+7xRD zLN*$3#}#`ogu}BhO~e)JiO>+zfL$?-_s0}7%|x*;5imT4 zej?rfd3D{F2Ctor3Mb`;M>9u@wauBnqPHQp<_?p8mADv)I_4 zH5c8!nP;b;z0TcmUUe4Ccbj(Jb9Yl${g>Y;m)Wn{uQ~6zJC@o3)so-f_GRlc2Nui? zt1;M(88Qt1zYK%RSg^K~T*g_*P28m~a6U`&4EoW7P%Pu^x;?HILXGRT(WMkn2wi#s z-dgxdyfv3O?kLT3MOYhl$VhZ@Rs`P8J2Yzb4K5Uuo)O3l{Qd}yE9dthbPnpYu|m3sA(IT4 zx_q#*%vw=qO2QIQriMwQj(;ZN#=IfP(tvGX%ENf-uF0PG#Mlc$z&J3VuADgCJ?X~6BZ%;p)={yr=CH)T z%bxABJCN=#+T7_>(c26Tn61ZRpTwbb?^r`7ZEm}=Co^%mr_izB+PPrfneWqbtM@~f z9RDlETq!xOyCCTxfX1V#oETXgR=)eG(v-M$o}1O?Vc|UCdU@YGrn5Fz3V2> zx7uqS*|#NgNb&lLzJ@u^tmk?-zv|9#qPhb74cb){!r zxGfqKbT}^slIC@e^%`)epe~h^AR}*$b3BxpAngnb>q21HA%N-R(i2JRcZYy?oZ|%l z1GLo5Sru!gqw%_sx^zfwNgMXqwl1Wr$j5zq{5{%N!0mkC6?W@z!;EyvpR}(F_o}mr z9o0!2Q?6eE5p+mod}Sf&&>-VICDpS=B75|T(l>DCQ_hsDLO>@I&WgZSC0$A9E$>a> zHieWMB$Jr-iZDu)YDGx8HAp}-Pn3wJZ(zy_q8Wy~>748#8a6l{iZepT_#zpl&K-{k zP%~|~Uy*PFqS(VQM@8ck5t3BJGd%byG{aaV1m$!X_bv2V3ctdd9wHe-;l>qM{RJ$I zK_Kl#7bDa` zX)-!4QZbWjn7+%@O~rwG8&&^StV6i>I4d$@AMhwfDXf^o7#UJBmOwGH5v=Nj+5CkG zKni{kenqjKW0Mh{(CLOQq*5{YeTsxM1b{a0fSG=}g))OiNMP&Uf8x zzw6(>*y_*Mebn@R)2)s|c>b}QyW~wha_fOi-<5&I>RKlH*5#(;>g^e8(N(?RYAx0@ z&269Eetj@sn~z)z$~8N|bhhm%T=;a`zN`ChkD}#si#2sj`0dQwVb8o(-nv)bu#a_7<(nCq9?9~# zqjFUzQ`|E@$cZ;5uTHY6rVmng+aFtO-<;p`(cbs>-r7?T=MUd}LhjfvxA$cF?z%UA z1Na*@-RQa6^I^EqQJDB3A#Zq`M*Fw&Xx3OHN4g>1S28T4F}n zID3@lb_1G#8Jsvk+l{z<=@yfs(e?+%{I%6uAm-TOE!&|`=`wSY}zNOpUM!2$^ zl{9GRe#@$n3rI<}0pS%4ao>$4K?S{yTi%V#vjz?`OB&vU)KzJ7OmbR(|CpQOKtig` zh6{#2HBE8?tqODvI^VdBGIl>KOd%^0z@^)!uN?XGW(-0psnCf4$?+cKlWN*B!r>{xUB2ynMI)zvZwVco7rt$GMFva)d!4tE|c4y#Okegz>i{iiJ7sVDvVZ;hU; zNnBW+?Zas?@^;8?4#2Lj+m&Adc4nxC>hdq8YAiepFqY}^5bBqusLC3;x`c7xC{^0bQG-yBg)Dc)1l~^OXzu`!l6Wq7Tp}i_C}V;S3!XJHIHhn8E8H2y z5rdgp42NJ;wvQ(ST#`j4#WFr}9PHdACB_wt5FI%qfpsP%DS@`J$gmXfsHo*0gtGzs zD46wFWw|Rjw@o!pi;9nI)xm@ig~5UOUcd=KW!cWLkT4RB2Wc>ODX)&{DLs{jc9k9( z1UAJaMtNLW0RpgYqd25;W>OYa`GxUX+AhYBwR`WXAhiI@!67nv3qL`HKgVDEABce1 z4(CkUbX#uET}OMmw^-e{Y;arltgt_CdFP4T%Qs%T`WjDu1Y_%n*I=GGWv588Daz9?&&}*PCJD<{y{SoTxFf1 zY?vXV49=xdwZ4F0-+xx{f_X2H?97BOk)&K~9+RbnKcUwY{SE%&Rzy&+bw7o{T<5!O zveSaTHP< zuIE61zmL%b6dTO$fy^eWbdU3#SOrf$ONDl<8^K8MNSP(wbTYcebrvX@lH=2g03S%04M%nD ztz3Mc@k^)PT7P9>OnZZiOXH}n=T-F6s^!E*sx)r_0@C_;cr4y02ht&Da^OK^ zbUBcYC(wK?1i?0u`>CNM5jH>~|S^88~ zi-i9H)kN}dQbods-U63>Ywq0axj#zgYu-)XY3MBphAkU8HV6#1jPF@XGdI&zRZx83 zZBSxyrHMp@-y{JxOp#8E6jLk`FYBU;vHyf(>sQ?q74s-OE);VV{i!CyBWSDG4y&uq zZcWdQkPw<+in6*r!at)*rZDV?e@a9BbBbtX*0?gQx_>zY;!XFcs*h=nMO#b0|8cjEH7{w zt_VRjG97vEFwD&--W`yuIzFxHDO5xII)C$t!mfp_J-A;fDSw-Cr=a|OT65r5?@y0? zd~9C4`TS?qPu;10>aS`)8T@tEU$!s21n01qGuB044ZAMja{FXo=cm5Cw~Rk^e(apz zbJP2o_uw7x!GAIMN$n?*KM%^@uz3p;z|y zqKe%&U0$hh!!jXGrTb2AJVPz*ubj?sf z;B^Z)Zzd&+k$EeOlR9bDpo7g_7biz+%5SwC$cz!NtwH8XSZyimjoO@`^_>L?A|FoZ zk(7^G8|6RgQDNp*-S_minj0*ZGV%TIJNNDJ46Mlu)%*Y}! zCj;R&JzL)>8_meVlP@GBaJ8AZ+NW&L|03D?+7H zEMW!wh}v=fbj77y9qQCWWl;gA6ijigdYV-Or?Ria7)@Y|@O*wjNQ{fS$laV6W55b` zTRR`_k)l#8qF7jhS5CYNLkECEJ0r_@<>kpUa7!CEZ@DO!w7UH z7(1*C9ZEO=U+-~|IpYWc6QRBbkFJ44M=ul=Tba8%h z`l9UhFWCJn7|WvDGxPlP^Rl}&-M?hCHCVqiI4#?!9q@ZQ@s-a2W8W*&uVhc;_Q;<0 zjIrqQOh1_$x-oKXWYG<^2M%yGA2q(;c+VYJsz*DzD_`@(n*kI%@jNo>?Iq8Z{MI^2 zP%azD%YJFRYSzF7Q_6Uy4zfxmM?gOm%C&J`w&1*IUR$QACrYl^Lka$Z=J$!uNaE_( z75LyFLN;FJD9SuURh5&xwh|Lk_ki#rx`&KIL{_ydo5?$rzo3}lr%js|mV)B~Q%_f1 z7}o|#kO)%cV96^>6c*5r8Tc;tAp;+&)^>PTZr+kUTCA>zjnmQwZPZnr?YjH~yq8>^ z#fA;jM?ZIaXO2xD%TC;Lx59*JX(JP+H*3orTrfATrYm&5*eXAbn0)xZQ2}z5sR8&D zprY_Ys=o$DypM|LRAyAa@={w<4DVdsghN+}la7k(9~~Y^R}0E8@HXQ9YnIrlc`(< z9_04GLed70PGao{6(hj&oQ+_)GH*7NWuP^8*;qD(rCdqZrET88Qq#UEJ6`b-nbS%1`Xzkb6cKw^o}O2Ka@3=K6SC&F zaPT!966xDG*1n`1f)VN;%uQ`s)y@y;2tM%eQzue~Pq*k>C(;Xv8j&;UI6l6DEXRbq zax{*GC<}bxNTg|%Z}k)hyM80BRB@6IOi=Zfv9UZ9pb=CjrL<(X70%m5ma8S;;pUYqf*5vLz5cr7Da-KTo*}6fuF5+@6^K6Z6$! zW`1T8WO|qIXH@zZh!lqg5ZJf|j6keZah7>&z+L9g!rxG#37Y^_6OM4_A$X{XNQr9d zBB`BShN=tr@9-#+L_{Y*3o0M4V&5?awLKO76Kau0C}t3)4bX?|8+65RvC6-&=^44| z*n<7o=lAP}+EwGDxhC1QnO!xellbB%WB$VFn%O?RJ=bw#+tqD%>4TqQ%ceW##&mz? zv254pRW;;^+n6zuC+Zk)Y(wpibg zoyd-44}en@>zfx_x(kBbvU_pE_QGy_4N!RN&W1-8o3<8=a?_5&p*v08UwLfpRbLrw z9`BOR;Hk;JIDKpyGI_}rmod6h|7Q!E+5O&K_hUtOwOWx}a~r?vG(gK#uOE-eRa?P4 zZB+}lmZIG?M*}|^D7*0w!1_SOwy$0NC%S{0 z-M}ia&%I!@W?~$%2;mEgh?1+h-)XskQ|+Lpq62@Deeb1y+O?t~lm6H{>f&oBhA34B zD7&fGzoUo>6*C_VOC^Uk;kVlBsWxe1JQjJKko5KA-SF#t6Jn3v&6{XI2H}bZZdR*2 z%ro~D3;!99t4C>7N5KavrG_g9A>vRmh=aj{>I49(yA@@wU=;i**bklx$^9*j&~2Lmjdh}1664Q)e86T&lJAC)qn>~q?`&iir84*()yLG zL2p9NFW2~2RE>?^tLIoNcd69?bXuBdxry2-V-}>+r~*7{5NW9ZznXPWaah*q#aU)K z0l#7k5+w-+6=yIwJVBq%27`hLz2bU@Oo?V1;O7*P9Zw)JgBipWTZvDolV*gMbUmiG ztMrxZbgCcfKFTq2+ef+ADH@`POo{BaxPsg=>pt1K7`bJXl?lR((0y!RA}phXU)v8t z?V5;1o)rEYs)PC&#B+$2OdQAkPos-7|F0GU*YS6TjrjdX!y|udXj-;%+{R_Qk=wEC zv~d3Ah8E7d{1jKuH7-9~!}*rCbKL%AyOrx+ZnASVRP0;sv2fPq1}|5&+-c;xmVGX+ ZZh51ft6pyKA$rKcHGS2zgYz=D{vZFY!<+yB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5367d145f3ed98e418f18191930c8fed084bdf87 GIT binary patch literal 209 zcmX@j%ge<81XCL3XBh+O#~=<2FhUuhIe?7m3@Hpz43&(UOjSCbdAbETiOH!7xuqqE zC7Jno3i$=8MPOF3LRx;2f{VX%ghD}LQAx3$pC;oi_W1ae{N(ufl?tTofE)lnS~7OA_uwXryHshDTld_^&$3CPPd;ruweiP3 za$YEpRw>8i%++heNWdKz5!H}UlErL&&L+08n<6;@^7)+8h>^6=R3{?qdS6USI=J*{ zmWfKJfOS`>9)2KAn0wc}eIqL6V=BQpmEoQq{5CGXkK!Ac`z5XGVe*D)n-r zO#RAf!;CGgg9y5{duo8-k9W=jc!u<@Zgn&~yOsbp(d&`^F4pz2W)qL4S0Vd5Z!Vj@ zAJ5@I$8D>t^8au3VFn1CD>PB8YUij8^PJscoG?=ZqmZH)42cr9Rf|g(NklV}(%kSV zfuVx0b+xp#Od^qKNh5tsRSrN9Gu5^FPEEGpEv0J)RyF{3oYFP-Iskt)3uFuZm_+`} z(OR+9qLM zVVpx9!tfQ6Gr-HeKz_GQ!4?;sotB4yHIt%F0Qp+O7=Lr>*!?+$@X>8_a2p-{ 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 0000000000000000000000000000000000000000..6551a84a77f0c51f9fca79fbb5233e85b9c9f588 GIT binary patch literal 38153 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 HUqAgHu#!)w literal 0 HcmV?d00001