Scaffold doced: OPC package I/O, XML core, AST model, locator model, reader, LLM contract

This commit is contained in:
doced builder
2026-09-08 03:33:01 +00:00
commit 8b53171913
29 changed files with 1868 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
build/
dist/
*.deb
.eggs/
*.egg-info/
.venv/
venv/
+259
View File
@@ -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 <verb>`)
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 <verb> --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.
+50
View File
@@ -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"
+13
View File
@@ -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__"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""AST model, locator model, and the reader (view builder)."""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+120
View File
@@ -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}"
+196
View File
@@ -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()))
+258
View File
@@ -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
+315
View File
@@ -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 ``<w:rPr>`` 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 ``<w:p>`` 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 ``<w:r>`` 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 ``<w:sectPr>`` 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
+133
View File
@@ -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 <command> --help
doced <command> 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 <command> [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 <command> --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())
+1
View File
@@ -0,0 +1 @@
"""Command-line interface entry points."""
+1
View File
@@ -0,0 +1 @@
"""High-level feature operations (tracked changes, comments, tables, …)."""
+1
View File
@@ -0,0 +1 @@
"""In-place mutation operations for DOCX parts."""
Binary file not shown.
Binary file not shown.
+38
View File
@@ -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",
]
+151
View File
@@ -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."
)
+189
View File
@@ -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 (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/" />'
'<Default Extension="xml" ContentType="application/xml" />'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'wordprocessingml.document.main+xml" />'
"</Types>"
)
+131
View File
@@ -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 ``<?xml ... encoding="UTF-8"?>``, 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 ``<?xml version="1.0" encoding="UTF-8"?>`` 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]
BIN
View File
Binary file not shown.