Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nous Research
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+196
View File
@@ -0,0 +1,196 @@
---
name: docx
description: Create, read, edit, template, and review Word .docx files.
version: 1.1.0
author: Nous Research
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [word, docx, documents, office, templates, revisions, comments]
category: productivity
related_skills: [pdf, xlsx, powerpoint]
---
# Docx Skill
Create, read, edit, and template Microsoft Word `.docx` files with
python-docx via small CLIs. It handles text, styles, lists, tables,
images, headers/footers, `{{token}}` templating, tracked changes
(list/accept/reject), comments (list/add/delete), TOC and page-number
fields, and package health checks. It does not render documents itself
(PDF needs LibreOffice — see Converting to PDF) or edit legacy `.doc`.
## When to Use
- The user asks to generate a Word document (report, letter, contract).
- You need the text, outline, styles, or embedded images of a `.docx`.
- You must change an existing `.docx`: replace text, edit table cells,
insert/delete paragraphs, apply styles, merge fragmented runs.
- You have a `.docx` template with `{{placeholders}}` to fill from data.
- The document has tracked changes to review, accept, or reject.
- You need to read reviewers' comments, or add/delete comments.
- A `.docx` won't open or behaves oddly and you need corruption triage.
- The document needs a table of contents or "Page X of Y" footers.
- Not for: `.doc` (legacy), `.odt`, or WYSIWYG layout work.
## Prerequisites
- Python 3.10+ with `python-docx` installed:
`pip install python-docx` (import name is `docx`; lxml comes with it).
- Comments `add` uses the native API on python-docx >= 1.2 and an XML
fallback on older versions — both are automatic.
- For image blocks: the image files must exist locally (PNG/JPEG).
## How to Run
All helpers live in `scripts/` next to this file. Run them with the
`terminal` tool; each supports `--help` and prints JSON to stdout.
```bash
python scripts/docx_create.py spec.json out.docx
python scripts/docx_read.py out.docx --text
python scripts/docx_edit.py replace out.docx --find old --replace new
python scripts/docx_template.py tpl.docx values.json filled.docx
python scripts/docx_revisions.py list out.docx
python scripts/docx_comments.py list out.docx
python scripts/docx_validate.py out.docx
```
## Quick Reference
| Task | Command |
| --- | --- |
| Create from JSON spec | `docx_create.py spec.json out.docx` |
| Full text (body+tables+headers/footers) | `docx_read.py f.docx --text` |
| Heading outline + table shapes | `docx_read.py f.docx --structure` |
| Styles actually used | `docx_read.py f.docx --styles` |
| Extract embedded images | `docx_read.py f.docx --images outdir/` |
| Detect tracked changes/comments | `docx_read.py f.docx --revisions` |
| Find/replace (formatting kept) | `docx_edit.py replace f.docx --find A --replace B -o out.docx` |
| Set a table cell | `docx_edit.py set-cell f.docx --table 0 --row 1 --col 2 --text X` |
| Insert paragraph before index N | `docx_edit.py insert f.docx --index N --text X --style Normal` |
| Delete paragraph N | `docx_edit.py delete f.docx --index N` |
| Apply style to paragraph N | `docx_edit.py style f.docx --index N --style "Heading 1"` |
| Merge equal-format adjacent runs | `docx_edit.py normalize f.docx -o out.docx` |
| Insert TOC field before para N | `docx_edit.py toc f.docx --index N -o out.docx` |
| "Page X of Y" footer fields | `docx_edit.py page-numbers f.docx` |
| Fill `{{tokens}}` | `docx_template.py tpl.docx values.json out.docx --strict` |
| List revisions (id/author/date/text) | `docx_revisions.py list f.docx` |
| Accept / reject all revisions | `docx_revisions.py accept-all f.docx -o out.docx` (or `reject-all`) |
| Accept / reject one revision | `docx_revisions.py accept f.docx --id 3 -o out.docx` |
| List comments (+anchored text) | `docx_comments.py list f.docx` |
| Add comment anchored to text | `docx_comments.py add f.docx --target "phrase" --text "note" --author You` |
| Delete comment by id | `docx_comments.py delete f.docx --id 0` |
| Health-check the package | `docx_validate.py f.docx` (exit 1 on errors) |
## Procedure
1. **Create.** Write a JSON spec with `write_file`, then run
`scripts/docx_create.py`. The spec supports: `page` (size + margins in
mm), `header`/`footer` strings, `footer_page_numbers` (adds a
"Page X of Y" field footer), `styles` (custom paragraph styles with
font, size, bold/italic, hex `color`), and `blocks``heading`
(level 1-9), `paragraph` (either `text` or a `runs` list where each run
may set `bold`/`italic`/`underline`), `bullet_list`, `numbered_list`,
`table` (`header` row rendered bold, `rows`, optional built-in table
`style` such as `Table Grid`), `image` (`path`, optional `width_mm`),
`toc` (Table of Contents field), and `page_break`. The full spec
format is documented at the top of `scripts/docx_create.py`.
2. **Read.** Use `scripts/docx_read.py` with exactly one mode flag.
`--text` returns body paragraphs, all table cell text, and
header/footer text as JSON. `--structure` returns the heading outline
plus paragraph/table/section counts. `--images DIR` copies every file
under `word/media/` out of the package.
3. **Edit.** Use `scripts/docx_edit.py`. `replace` walks body, tables
(nested included), headers and footers, and preserves run formatting;
add `--body-only` to skip headers/footers. Pass `-o out.docx` to keep
the original; omit it to edit in place. Paragraph indices for
`insert`/`delete`/`style`/`toc` refer to `--structure`/`--text` body
order. Run `normalize` first on documents that came out of heavy Word
editing — it merges adjacent runs with identical formatting so later
find-replace matches reliably.
4. **Review revisions.** `docx_revisions.py list` reports every `w:ins`
and `w:del` (id, author, date, affected text) anywhere in body,
tables, headers, or footers. `accept-all` / `reject-all` resolve them
in bulk; `accept`/`reject --id N` handles a single revision. Accept
keeps insertions and drops deleted text; reject does the reverse.
5. **Comments.** `docx_comments.py list` returns each comment's id,
author, date, body text, and the document text it is anchored to.
`add --target "some phrase"` anchors a new comment to the first
occurrence of that phrase (runs are split as needed; formatting is
preserved). `delete --id N` removes the comment and its markers
without touching document text.
6. **Template.** Put `{{name}}`-style tokens in the document. Run
`scripts/docx_template.py` with a JSON object of values. Use
`--strict` to fail when tokens remain unfilled; the JSON output lists
`filled` counts and `unfilled_tokens` either way.
7. **Verify** (always): re-read the output with `--text` or
`--structure`, and run `docx_validate.py` on anything you produced
via revision/comment surgery.
## Converting to PDF
No script needed. When LibreOffice is installed, convert headlessly:
```bash
soffice --headless --convert-to pdf --outdir outdir/ file.docx
```
Check availability first (`command -v soffice || command -v
libreoffice`). If neither exists, tell the user PDF conversion is
unavailable in this environment rather than improvising — python-docx
cannot render PDFs, and layout fidelity requires a real renderer.
## Pitfalls
- **Tokens split across runs.** Word often fragments text into several
runs. The replace helpers collapse matched runs (replacement inherits
the first run's formatting); running `docx_edit.py normalize` first
reduces fragmentation for all later edits.
- **Revision coverage.** `docx_revisions.py` resolves run-level
insertions and deletions (the overwhelming majority). Paragraph-mark
and table-row revisions, format-change records, and moves are detected
by `--revisions` but not auto-resolved — see
`references/revisions-and-comments.md` and hand those to Word.
- **Comment threading.** Replies and "resolved" status live in
`commentsExtended.xml`, which this skill ignores; comments it adds are
plain top-level comments.
- **Field results are computed by Word.** `toc`, `page-numbers`, and the
`toc`/`footer_page_numbers` spec options write *field codes*.
Word/LibreOffice populates the actual entries and numbers when the
file is opened (Word may prompt to update fields); python-docx never
computes them, so placeholder text shows until then.
- **Validation is a health check, not schema validation.**
`docx_validate.py` verifies the zip, required parts, relationship
targets, image magic bytes, and referenced styles. It is NOT XSD
validation — a file can pass and still contain XML Word dislikes.
- **Style names must exist.** Applying a style that isn't defined in the
document raises `KeyError`. Built-ins like `Heading 1`, `List Bullet`,
`List Number`, `Table Grid` exist in the default template; custom
styles must be declared in the create spec first.
- **Numbered lists restart.** `List Number` relies on Word's default
numbering; separate lists in one document may continue numbering
instead of restarting. Warn users needing precise multi-list numbering.
- **Cell writes replace formatting.** `set-cell` uses `cell.text = ...`,
which resets runs in that cell to plain formatting.
- **Encoding.** All JSON specs/values files are read as UTF-8 explicitly;
never rely on locale defaults when writing your own glue code.
- **Don't unzip-and-sed the XML.** Edit through the scripts (or
python-docx); raw text substitution in `document.xml` corrupts files
easily. Use `patch`/`write_file` only for the JSON inputs, never on the
`.docx` itself.
## Verification
- After create/edit/template, run `docx_read.py out.docx --text` and
check the expected strings appear (and old strings are gone).
- After accept/reject, `docx_revisions.py list` should return `[]` (or
only the ids you intentionally left); after comment surgery,
`docx_comments.py list` should reflect the change and `--text` output
must be unchanged.
- `docx_validate.py out.docx` exits 0 with `"ok": true` on a healthy
package — run it after any revision/comment/field manipulation.
- For templates run with `--strict`, or check `unfilled_tokens == []`.
- Structure checks: `--structure` should show the expected heading
outline and table shapes; `--styles` confirms custom styles applied.
@@ -0,0 +1,88 @@
# Revisions and Comments — XML details
Deep reference for `docx_revisions.py` and `docx_comments.py`. Read this
when you need to reason about the raw WordprocessingML, extend the
scripts, or debug an unusual document. Everyday use only needs SKILL.md.
## Tracked changes (w:ins / w:del)
Word records run-level tracked changes as wrapper elements inside a
paragraph (`w:p`), in the `w` namespace
`http://schemas.openxmlformats.org/wordprocessingml/2006/main`:
```xml
<w:p>
<w:r><w:t>Base </w:t></w:r>
<w:ins w:id="1" w:author="Editor" w:date="2026-01-02T03:04:05Z">
<w:r><w:t>inserted text</w:t></w:r>
</w:ins>
<w:del w:id="2" w:author="Editor" w:date="2026-01-02T03:04:05Z">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
</w:p>
```
Key facts the script relies on:
- Deleted text lives in `w:delText`, not `w:t` — that is why plain text
extraction naturally shows the "accepted" view (insertions visible,
deletions hidden).
- Resolution semantics:
- accept `w:ins` → unwrap (move child runs up, drop the wrapper)
- reject `w:ins` → remove the wrapper and its contents
- accept `w:del` → remove the wrapper and its contents
- reject `w:del` → rename each `w:delText` to `w:t`, then unwrap
- Revisions can appear anywhere block content is allowed: body, table
cells (nested tables too), headers, footers, text boxes. The script
iterates the body root plus every header/footer part root with
`root.iter(W+"ins", W+"del")`, which finds them at any depth.
- `w:id` values are unique per revision *element*, but one logical edit
session may produce several elements. `accept`/`reject --id` acts on
exactly the element(s) carrying that id.
Not handled by the script (detected by `docx_read.py --revisions` but
left alone): paragraph-mark revisions (`w:rPr/w:ins` on `w:pPr`), table
row insertions/deletions (`w:trPr/w:ins`), format-change records
(`w:rPrChange`, `w:pPrChange`), and moves (`w:moveFrom`/`w:moveTo`).
Moves are rare from typical editors; if present, treat the file with
Word itself rather than guessing.
## Comments
Three cooperating pieces:
1. **`word/comments.xml`** — one `w:comment` element per comment,
carrying `w:id`, `w:author`, `w:initials`, `w:date`, and body
paragraphs. Related from document.xml via the relationship type
`.../comments` and content type
`application/vnd...wordprocessingml.comments+xml` (also needs a
`[Content_Types].xml` override — python-docx's part machinery adds it
when the part is registered).
2. **Range markers in the story**`w:commentRangeStart w:id="N"`
before the anchored runs, `w:commentRangeEnd w:id="N"` after them.
3. **The reference run** — a `w:r` containing `w:commentReference
w:id="N"`, placed right after the range end; it ties the balloon to
the location.
`docx_comments.py` behavior:
- **list / delete** always work at the XML level, so they handle files
from any producer. `anchored_text` is reconstructed by walking each
part root in document order and collecting `w:t` text between the
start and end markers for each id.
- **add** first isolates the target text into whole runs. If the match
starts or ends mid-run, the run is split at the boundary (the split
copies `w:rPr`, so formatting is preserved). Then:
- python-docx >= 1.2: the native `document.add_comment(runs, ...)`
API is used (it creates the comments part, markers, and reference
run itself).
- older versions or `--xml`: the script builds `word/comments.xml`,
registers the part + relationship through the opc layer, and
inserts the markers/reference manually.
- Deleting a comment removes the `w:comment` element and all three
marker kinds for that id; the anchored document text is untouched.
Modern Word also writes `commentsExtended.xml` (threading/resolved
state). The scripts neither read nor produce it: replies and "resolved"
flags are invisible here, and comments added by this skill are plain
top-level comments.
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""List, add, and delete comments in a .docx.
Subcommands:
list JSON per comment: id, author, initials, date, text, anchored_text
add add a comment anchored to the first occurrence of --target
delete remove a comment (and its range markers) by --id
Examples:
docx_comments.py list report.docx
docx_comments.py add report.docx --target "Q3 revenue" \
--text "Needs a source" --author "Reviewer" -o out.docx
docx_comments.py delete report.docx --id 0 -o out.docx
Uses the native python-docx comments API (>= 1.2) when available; falls
back to building word/comments.xml and the range markers directly for
older versions (or when --xml is passed). Listing and deletion always
work at the XML level so they handle documents from any producer.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import sys
from copy import deepcopy
from docx import Document
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from lxml import etree
from docx_common import iter_part_roots
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
COMMENTS_CT = ("application/vnd.openxmlformats-officedocument"
".wordprocessingml.comments+xml")
def q(tag: str) -> str:
return f"{{{W}}}{tag}"
# ---------------------------------------------------------------- reading
def _comments_root(doc):
"""Return the XML root of the comments part, or None."""
for rel in doc.part.rels.values():
if rel.reltype == RT.COMMENTS:
part = rel.target_part
el = getattr(part, "_element", None)
if el is not None:
return el
return etree.fromstring(part.blob)
return None
def _anchored_texts(doc) -> dict:
"""Map comment id -> document text between its range markers."""
anchored: dict[str, list[str]] = {}
for root in iter_part_roots(doc):
active: set[str] = set()
for el in root.iter():
if el.tag == q("commentRangeStart"):
cid = el.get(q("id"))
active.add(cid)
anchored.setdefault(cid, [])
elif el.tag == q("commentRangeEnd"):
active.discard(el.get(q("id")))
elif el.tag == q("t") and active:
for cid in active:
anchored[cid].append(el.text or "")
return {cid: "".join(parts) for cid, parts in anchored.items()}
def list_comments(doc) -> list:
root = _comments_root(doc)
if root is None:
return []
anchored = _anchored_texts(doc)
out = []
for c in root.iter(q("comment")):
cid = c.get(q("id"))
text = "\n".join(
"".join(t.text or "" for t in p.iter(q("t")))
for p in c.iter(q("p")))
out.append({"id": cid, "author": c.get(q("author")),
"initials": c.get(q("initials")),
"date": c.get(q("date")), "text": text,
"anchored_text": anchored.get(cid, "")})
return out
# ---------------------------------------------------------------- anchoring
def _split_run(para, run_el, offset: int):
"""Split a run element at text offset; return the new right-hand run."""
text = "".join(t.text or "" for t in run_el.iter(q("t")))
right = deepcopy(run_el)
run_el.addnext(right)
for el, s in ((run_el, text[:offset]), (right, text[offset:])):
for t in list(el.iter(q("t"))):
el.remove(t)
t = etree.SubElement(el, q("t"))
t.text = s
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
return right
def find_anchor_runs(doc, target: str):
"""Isolate `target`'s first occurrence into whole runs; return them."""
from docx_common import iter_all_paragraphs
for para in iter_all_paragraphs(doc):
full = para.text
start = full.find(target)
if start < 0:
continue
end = start + len(target)
pos = 0
covered = []
for run_el in para._p.iter(q("r")):
rtext = "".join(t.text or "" for t in run_el.iter(q("t")))
r_start, r_end = pos, pos + len(rtext)
pos = r_end
if r_end <= start or r_start >= end:
continue
if r_start < start: # split off the left part
run_el = _split_run(para, run_el, start - r_start)
r_start = start
if r_end > end: # split off the right part
_split_run(para, run_el, end - r_start)
covered.append(run_el)
return para, covered
return None, []
# ---------------------------------------------------------------- adding
def _next_id(doc) -> int:
root = _comments_root(doc)
if root is None:
return 0
ids = [int(c.get(q("id"), "0")) for c in root.iter(q("comment"))
if c.get(q("id"), "").isdigit()]
return max(ids) + 1 if ids else 0
def add_comment_native(doc, runs, text, author, initials):
from docx.text.run import Run
run_objs = [Run(r, None) for r in runs]
comment = doc.add_comment(run_objs, text=text, author=author,
initials=initials or "")
return str(comment.comment_id)
def add_comment_xml(doc, runs, text, author, initials) -> str:
cid = str(_next_id(doc))
root = _comments_root(doc)
if root is None:
root = etree.fromstring(
f'<w:comments xmlns:w="{W}"/>'.encode("utf-8"))
from docx.opc.packuri import PackURI
from docx.opc.part import Part
blob = etree.tostring(root, xml_declaration=True,
encoding="UTF-8", standalone=True)
part = Part(PackURI("/word/comments.xml"), COMMENTS_CT, blob,
doc.part.package)
doc.part.relate_to(part, RT.COMMENTS)
# keep a live element on the part so edits reach save()
part._element = root
part.blob_ = None
def _blob(self=part):
return etree.tostring(self._element, xml_declaration=True,
encoding="UTF-8", standalone=True)
part.__class__ = type("CommentsXmlPart", (Part,),
{"blob": property(lambda self: _blob(self))})
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
comment = etree.SubElement(root, q("comment"))
comment.set(q("id"), cid)
comment.set(q("author"), author)
if initials:
comment.set(q("initials"), initials)
comment.set(q("date"), now)
p = etree.SubElement(comment, q("p"))
r = etree.SubElement(p, q("r"))
t = etree.SubElement(r, q("t"))
t.text = text
# range markers around the anchor runs + reference run after them
first, last = runs[0], runs[-1]
start = first.makeelement(q("commentRangeStart"), {q("id"): cid})
first.addprevious(start)
end = last.makeelement(q("commentRangeEnd"), {q("id"): cid})
last.addnext(end)
ref_run = last.makeelement(q("r"), {})
ref = etree.SubElement(ref_run, q("commentReference"))
ref.set(q("id"), cid)
end.addnext(ref_run)
return cid
# ---------------------------------------------------------------- deleting
def delete_comment(doc, cid: str) -> bool:
root = _comments_root(doc)
found = False
if root is not None:
for c in list(root.iter(q("comment"))):
if c.get(q("id")) == cid:
c.getparent().remove(c)
found = True
for part_root in iter_part_roots(doc):
for tag in ("commentRangeStart", "commentRangeEnd",
"commentReference"):
for el in list(part_root.iter(q(tag))):
if el.get(q("id")) == cid:
parent = el.getparent()
# remove the wrapping run for reference marks
if tag == "commentReference" and parent.tag == q("r"):
parent.getparent().remove(parent)
else:
parent.remove(el)
found = True
return found
def main() -> int:
ap = argparse.ArgumentParser(
description="List, add, or delete comments in a .docx.")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("list", help="list comments as JSON")
p.add_argument("path", help="input .docx")
p = sub.add_parser("add", help="add a comment anchored to text")
p.add_argument("path", help="input .docx")
p.add_argument("-o", "--output", help="output path (default: in place)")
p.add_argument("--target", required=True,
help="anchor: first occurrence of this text")
p.add_argument("--text", required=True, help="comment body")
p.add_argument("--author", default="Hermes")
p.add_argument("--initials", default="")
p.add_argument("--xml", action="store_true",
help="force the XML fallback (skip native API)")
p = sub.add_parser("delete", help="delete a comment by id")
p.add_argument("path", help="input .docx")
p.add_argument("-o", "--output", help="output path (default: in place)")
p.add_argument("--id", required=True, help="comment id")
args = ap.parse_args()
doc = Document(args.path)
if args.cmd == "list":
print(json.dumps({"ok": True, "comments": list_comments(doc)},
ensure_ascii=False))
return 0
if args.cmd == "add":
para, runs = find_anchor_runs(doc, args.target)
if not runs:
print(json.dumps({"ok": False,
"error": f"target not found: {args.target}"}))
return 1
native = hasattr(doc, "add_comment") and not args.xml
if native:
cid = add_comment_native(doc, runs, args.text, args.author,
args.initials)
else:
cid = add_comment_xml(doc, runs, args.text, args.author,
args.initials)
result = {"ok": True, "comment_id": cid,
"native_api": native, "anchored_to": args.target}
else: # delete
if not delete_comment(doc, args.id):
print(json.dumps({"ok": False,
"error": f"no comment with id {args.id}"}))
return 1
result = {"ok": True, "deleted_id": args.id}
out = args.output or args.path
doc.save(out)
result["output"] = out
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
# MIT License. Shared helpers for the docx skill scripts.
"""Shared helpers: paragraph iteration and run-preserving text replacement."""
from __future__ import annotations
def iter_all_paragraphs(doc, include_headers_footers: bool = True):
"""Yield every paragraph in body, tables (recursively), headers, footers."""
yield from _iter_container(doc)
if include_headers_footers:
for section in doc.sections:
for part in (
section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer,
):
if part is not None:
yield from _iter_container(part)
def _iter_container(container):
for para in container.paragraphs:
yield para
for table in container.tables:
yield from _iter_table(table)
def _iter_table(table):
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
yield para
for nested in cell.tables:
yield from _iter_table(nested)
def iter_part_roots(doc):
"""Yield the XML root of the body plus every header/footer part."""
yield doc.element.body
seen = set()
for section in doc.sections:
for part in (
section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer,
):
if part is not None and id(part._element) not in seen:
seen.add(id(part._element))
yield part._element
def replace_in_paragraph(para, old: str, new: str) -> int:
"""Replace `old` with `new` in a paragraph, preserving run formatting.
Strategy: first replace occurrences fully contained in a single run
(formatting fully preserved). If the needle spans multiple runs, the
matched runs are collapsed: the replacement inherits the formatting of
the run where the match starts. Returns number of replacements made.
"""
if not old or old not in para.text:
return 0
count = 0
# Pass 1: within-run replacements.
for run in para.runs:
if old in run.text:
count += run.text.count(old)
run.text = run.text.replace(old, new)
# Pass 2: cross-run occurrences.
while old in para.text:
runs = para.runs
# Map paragraph text offsets to (run_index, offset_in_run).
full = "".join(r.text for r in runs)
start = full.find(old)
if start < 0:
break
end = start + len(old)
pos = 0
spans = [] # (run_idx, cut_start, cut_end) portions inside the match
for i, r in enumerate(runs):
r_start, r_end = pos, pos + len(r.text)
if r_end > start and r_start < end:
spans.append((i, max(start, r_start) - r_start,
min(end, r_end) - r_start))
pos = r_end
first = True
for i, cs, ce in spans:
t = runs[i].text
if first:
runs[i].text = t[:cs] + new + t[ce:]
first = False
else:
runs[i].text = t[:cs] + t[ce:]
count += 1
return count
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Create a .docx document from a JSON spec.
Usage: docx_create.py spec.json output.docx
Run with --help for the spec format summary.
Spec (JSON object):
{
"page": {"width_mm": 210, "height_mm": 297,
"margins_mm": {"top": 25, "bottom": 25, "left": 20, "right": 20}},
"header": "text shown in page header",
"footer": "text shown in page footer",
"styles": [{"name": "MyStyle", "base": "Normal", "font": "Arial",
"size_pt": 12, "bold": true, "color": "1F4E79"}],
"blocks": [
{"type": "heading", "text": "Title", "level": 1},
{"type": "paragraph", "style": "MyStyle", "runs": [
{"text": "plain "}, {"text": "bold", "bold": true},
{"text": " italic", "italic": true},
{"text": " under", "underline": true}]},
{"type": "paragraph", "text": "shortcut: single plain run"},
{"type": "bullet_list", "items": ["a", "b"]},
{"type": "numbered_list", "items": ["one", "two"]},
{"type": "table", "header": ["Col1", "Col2"],
"rows": [["1", "2"]], "style": "Light Grid Accent 1",
"header_bold": true},
{"type": "image", "path": "pic.png", "width_mm": 60},
{"type": "page_break"},
{"type": "toc"}
]
}
Extras: `"footer_page_numbers": true` at the top level adds a
"Page X of Y" footer built from PAGE/NUMPAGES fields, and a `toc` block
inserts a Table of Contents field. Field results are computed by
Word/LibreOffice when the file is opened, not by python-docx.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_BREAK
from docx.shared import Mm, Pt, RGBColor
def apply_page(doc, page: dict) -> None:
section = doc.sections[0]
if "width_mm" in page:
section.page_width = Mm(page["width_mm"])
if "height_mm" in page:
section.page_height = Mm(page["height_mm"])
m = page.get("margins_mm", {})
for side in ("top", "bottom", "left", "right"):
if side in m:
setattr(section, f"{side}_margin", Mm(m[side]))
def add_styles(doc, styles: list) -> None:
for s in styles:
style = doc.styles.add_style(s["name"], WD_STYLE_TYPE.PARAGRAPH)
if s.get("base"):
style.base_style = doc.styles[s["base"]]
font = style.font
if s.get("font"):
font.name = s["font"]
if s.get("size_pt"):
font.size = Pt(s["size_pt"])
if s.get("bold") is not None:
font.bold = s["bold"]
if s.get("italic") is not None:
font.italic = s["italic"]
if s.get("color"):
font.color.rgb = RGBColor.from_string(s["color"])
def add_runs(para, block: dict) -> None:
runs = block.get("runs")
if runs is None:
runs = [{"text": block.get("text", "")}]
for r in runs:
run = para.add_run(r.get("text", ""))
if r.get("bold"):
run.bold = True
if r.get("italic"):
run.italic = True
if r.get("underline"):
run.underline = True
def add_block(doc, block: dict) -> None:
btype = block["type"]
if btype == "heading":
doc.add_heading(block.get("text", ""), level=block.get("level", 1))
elif btype == "paragraph":
para = doc.add_paragraph(style=block.get("style"))
add_runs(para, block)
elif btype == "bullet_list":
for item in block.get("items", []):
doc.add_paragraph(item, style="List Bullet")
elif btype == "numbered_list":
for item in block.get("items", []):
doc.add_paragraph(item, style="List Number")
elif btype == "table":
header = block.get("header", [])
rows = block.get("rows", [])
ncols = len(header) if header else (len(rows[0]) if rows else 1)
table = doc.add_table(rows=0, cols=ncols)
table.style = block.get("style", "Table Grid")
if header:
cells = table.add_row().cells
for i, text in enumerate(header):
cells[i].text = str(text)
if block.get("header_bold", True):
for para in cells[i].paragraphs:
for run in para.runs:
run.bold = True
for row in rows:
cells = table.add_row().cells
for i, text in enumerate(row):
cells[i].text = str(text)
elif btype == "image":
width = Mm(block["width_mm"]) if block.get("width_mm") else None
doc.add_picture(block["path"], width=width)
elif btype == "page_break":
doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE)
elif btype == "toc":
from docx_edit import _add_field
para = doc.add_paragraph()
_add_field(para, r' TOC \o "1-3" \h \z \u ',
"Table of contents - open in Word/LibreOffice and "
"update fields to populate.")
else:
raise ValueError(f"unknown block type: {btype}")
def main() -> int:
ap = argparse.ArgumentParser(
description="Create a .docx from a JSON spec.",
epilog="See the module docstring (top of this file) for the spec format.")
ap.add_argument("spec", help="path to JSON spec file")
ap.add_argument("output", help="path of .docx to write")
args = ap.parse_args()
with open(args.spec, encoding="utf-8") as f:
spec = json.load(f)
doc = Document()
if spec.get("page"):
apply_page(doc, spec["page"])
if spec.get("styles"):
add_styles(doc, spec["styles"])
if spec.get("header"):
doc.sections[0].header.paragraphs[0].text = spec["header"]
if spec.get("footer"):
doc.sections[0].footer.paragraphs[0].text = spec["footer"]
for block in spec.get("blocks", []):
add_block(doc, block)
if spec.get("footer_page_numbers"):
from docx_edit import _add_field
para = doc.sections[0].footer.paragraphs[0]
para.add_run("Page ")
_add_field(para, " PAGE ", "1")
para.add_run(" of ")
_add_field(para, " NUMPAGES ", "1")
doc.save(args.output)
print(json.dumps({"ok": True, "output": args.output,
"blocks": len(spec.get("blocks", []))}))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Edit an existing .docx in place (or to a new file).
Subcommands:
replace find-and-replace text, preserving run formatting
set-cell set the text of a table cell
insert insert a paragraph before a given body paragraph index
delete delete a body paragraph by index
style apply a paragraph style to a body paragraph by index
normalize merge adjacent runs with identical formatting
toc insert a Table of Contents field at a body paragraph index
page-numbers add "Page X of Y" (PAGE/NUMPAGES fields) to the footer
Examples:
docx_edit.py replace in.docx --find old --replace new -o out.docx
docx_edit.py set-cell in.docx --table 0 --row 1 --col 2 --text "42"
docx_edit.py insert in.docx --index 3 --text "New para" --style Normal
docx_edit.py delete in.docx --index 3
docx_edit.py style in.docx --index 0 --style "Heading 1"
docx_edit.py normalize in.docx -o out.docx
docx_edit.py toc in.docx --index 1 -o out.docx
docx_edit.py page-numbers in.docx -o out.docx
Field results (TOC entries, page numbers) are computed by Word or
LibreOffice when the document is opened, not by python-docx; until then
the fields show placeholder text.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx_common import iter_all_paragraphs, replace_in_paragraph
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def _q(tag: str) -> str:
return f"{{{W}}}{tag}"
def cmd_replace(doc, args) -> dict:
n = 0
for para in iter_all_paragraphs(doc):
n += replace_in_paragraph(para, args.find, args.replace)
return {"replacements": n}
def cmd_set_cell(doc, args) -> dict:
cell = doc.tables[args.table].cell(args.row, args.col)
cell.text = args.text
return {"table": args.table, "row": args.row, "col": args.col}
def cmd_insert(doc, args) -> dict:
paras = doc.paragraphs
if args.index < len(paras):
anchor = paras[args.index]
new_para = anchor.insert_paragraph_before(args.text, style=args.style)
else:
new_para = doc.add_paragraph(args.text, style=args.style)
return {"inserted_at": args.index, "text": new_para.text}
def cmd_delete(doc, args) -> dict:
para = doc.paragraphs[args.index]
el = para._element
el.getparent().remove(el)
return {"deleted_index": args.index}
def cmd_style(doc, args) -> dict:
doc.paragraphs[args.index].style = doc.styles[args.style]
return {"index": args.index, "style": args.style}
def _run_format_key(r_el) -> str:
"""Canonical string for a run's w:rPr (None when absent)."""
from lxml import etree
rpr = r_el.find(_q("rPr"))
return "" if rpr is None else etree.tostring(rpr).decode("utf-8")
def cmd_normalize(doc) -> dict:
"""Merge adjacent sibling runs with identical formatting."""
merged = 0
for para in iter_all_paragraphs(doc):
prev = None
for r_el in list(para._p):
if r_el.tag != _q("r"):
prev = None
continue
# only merge plain-text runs (no breaks, tabs, drawings...)
kids = {c.tag for c in r_el} - {_q("rPr"), _q("t")}
if kids:
prev = None
continue
if (prev is not None
and _run_format_key(prev) == _run_format_key(r_el)):
pt = prev.find(_q("t"))
ct = r_el.find(_q("t"))
if pt is None:
pt = prev.makeelement(_q("t"), {})
prev.append(pt)
pt.text = (pt.text or "") + ((ct.text or "")
if ct is not None else "")
pt.set("{http://www.w3.org/XML/1998/namespace}space",
"preserve")
r_el.getparent().remove(r_el)
merged += 1
else:
prev = r_el
return {"runs_merged": merged}
def _add_field(para, instr: str, placeholder: str) -> None:
"""Append a complex field (begin/instrText/separate/result/end)."""
p = para._p
for ftype, extra in (("begin", None), (None, instr),
("separate", None), (None, placeholder),
("end", None)):
r = p.makeelement(_q("r"), {})
p.append(r)
if ftype is not None:
fld = r.makeelement(_q("fldChar"), {_q("fldCharType"): ftype})
r.append(fld)
elif extra is instr:
it = r.makeelement(_q("instrText"), {})
it.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
it.text = instr
r.append(it)
else:
t = r.makeelement(_q("t"), {})
t.text = extra
r.append(t)
def cmd_toc(doc, args) -> dict:
paras = doc.paragraphs
if args.index < len(paras):
para = paras[args.index].insert_paragraph_before("")
else:
para = doc.add_paragraph("")
_add_field(para, r' TOC \o "1-3" \h \z \u ',
"Table of contents - open in Word/LibreOffice and update "
"fields to populate.")
return {"toc_inserted_at": args.index}
def cmd_page_numbers(doc, args) -> dict:
footer = doc.sections[0].footer
para = footer.paragraphs[0] if footer.paragraphs \
else footer.add_paragraph()
para.add_run("Page ")
_add_field(para, " PAGE ", "1")
para.add_run(" of ")
_add_field(para, " NUMPAGES ", "1")
return {"footer_fields": ["PAGE", "NUMPAGES"]}
def main() -> int:
ap = argparse.ArgumentParser(description="Edit a .docx file.")
sub = ap.add_subparsers(dest="cmd", required=True)
def common(p):
p.add_argument("path", help="input .docx")
p.add_argument("-o", "--output",
help="output path (default: overwrite input)")
p = sub.add_parser("replace", help="find-and-replace text")
common(p)
p.add_argument("--find", required=True)
p.add_argument("--replace", required=True)
p.add_argument("--body-only", action="store_true",
help="skip headers/footers")
p = sub.add_parser("set-cell", help="set table cell text")
common(p)
p.add_argument("--table", type=int, required=True, help="table index")
p.add_argument("--row", type=int, required=True)
p.add_argument("--col", type=int, required=True)
p.add_argument("--text", required=True)
p = sub.add_parser("insert", help="insert paragraph at body index")
common(p)
p.add_argument("--index", type=int, required=True)
p.add_argument("--text", required=True)
p.add_argument("--style", default=None)
p = sub.add_parser("delete", help="delete body paragraph by index")
common(p)
p.add_argument("--index", type=int, required=True)
p = sub.add_parser("style", help="apply style to body paragraph")
common(p)
p.add_argument("--index", type=int, required=True)
p.add_argument("--style", required=True)
p = sub.add_parser("normalize",
help="merge adjacent runs with identical formatting")
common(p)
p = sub.add_parser("toc", help="insert a TOC field (Word computes it)")
common(p)
p.add_argument("--index", type=int, default=0,
help="body paragraph index to insert before (default 0)")
p = sub.add_parser("page-numbers",
help="add PAGE/NUMPAGES fields to the footer")
common(p)
args = ap.parse_args()
doc = Document(args.path)
if args.cmd == "replace":
if args.body_only:
n = 0
for para in iter_all_paragraphs(doc, include_headers_footers=False):
n += replace_in_paragraph(para, args.find, args.replace)
result = {"replacements": n}
else:
result = cmd_replace(doc, args)
elif args.cmd == "set-cell":
result = cmd_set_cell(doc, args)
elif args.cmd == "insert":
result = cmd_insert(doc, args)
elif args.cmd == "delete":
result = cmd_delete(doc, args)
elif args.cmd == "normalize":
result = cmd_normalize(doc)
elif args.cmd == "toc":
result = cmd_toc(doc, args)
elif args.cmd == "page-numbers":
result = cmd_page_numbers(doc, args)
else:
result = cmd_style(doc, args)
out = args.output or args.path
doc.save(out)
result.update({"ok": True, "output": out})
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Read a .docx: text, structure outline, styles, images, revision detection.
Usage:
docx_read.py file.docx --text # full text incl. tables + headers/footers
docx_read.py file.docx --structure # JSON outline (headings, tables, counts)
docx_read.py file.docx --styles # JSON list of styles actually used
docx_read.py file.docx --images DIR # extract embedded images into DIR
docx_read.py file.docx --revisions # JSON: tracked changes / comments present?
Text output is JSON: {"body": [...], "tables": [[...rows]], "headers": [...],
"footers": [...]}. Body text is the accepted/as-is text (python-docx ignores
deleted-in-revision text and shows inserted text).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import zipfile
from docx import Document
def table_to_rows(table) -> list:
return [[cell.text for cell in row.cells] for row in table.rows]
def extract_text(doc) -> dict:
out = {"body": [p.text for p in doc.paragraphs],
"tables": [table_to_rows(t) for t in doc.tables],
"headers": [], "footers": []}
for section in doc.sections:
out["headers"].extend(p.text for p in section.header.paragraphs)
out["footers"].extend(p.text for p in section.footer.paragraphs)
for t in section.header.tables:
out["headers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
for t in section.footer.tables:
out["footers"].append(json.dumps(table_to_rows(t), ensure_ascii=False))
return out
def extract_structure(doc) -> dict:
outline = []
for i, para in enumerate(doc.paragraphs):
style = para.style.name if para.style else ""
if style.startswith("Heading"):
try:
level = int(style.split()[-1])
except ValueError:
level = 1
outline.append({"index": i, "level": level, "text": para.text})
return {
"outline": outline,
"paragraph_count": len(doc.paragraphs),
"table_count": len(doc.tables),
"tables": [{"rows": len(t.rows), "cols": len(t.columns)}
for t in doc.tables],
"section_count": len(doc.sections),
}
def styles_used(doc) -> list:
used = set()
for para in doc.paragraphs:
if para.style:
used.add(para.style.name)
for run in para.runs:
if run.style:
used.add(run.style.name)
for table in doc.tables:
if table.style:
used.add(table.style.name)
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
if para.style:
used.add(para.style.name)
return sorted(used)
def extract_images(path: str, outdir: str) -> list:
os.makedirs(outdir, exist_ok=True)
written = []
with zipfile.ZipFile(path) as zf:
for name in zf.namelist():
if name.startswith("word/media/"):
target = os.path.join(outdir, os.path.basename(name))
with open(target, "wb") as f:
f.write(zf.read(name))
written.append(target)
return written
def detect_revisions(path: str) -> dict:
"""Detect tracked changes and comments by scanning the raw XML parts."""
markers = {"insertions": b"<w:ins ", "deletions": b"<w:del ",
"format_changes": b"<w:rPrChange"}
result = {k: False for k in markers}
result["comments"] = False
with zipfile.ZipFile(path) as zf:
names = zf.namelist()
result["comments"] = any(n.startswith("word/comments") for n in names)
for name in names:
if name.startswith("word/") and name.endswith(".xml"):
data = zf.read(name)
for key, marker in markers.items():
if marker in data:
result[key] = True
result["has_tracked_changes"] = any(
result[k] for k in ("insertions", "deletions", "format_changes"))
return result
def main() -> int:
ap = argparse.ArgumentParser(description="Read/inspect a .docx file.")
ap.add_argument("path", help=".docx file to read")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--text", action="store_true", help="extract all text as JSON")
g.add_argument("--structure", action="store_true", help="outline JSON")
g.add_argument("--styles", action="store_true", help="styles used, JSON")
g.add_argument("--images", metavar="DIR", help="extract images to DIR")
g.add_argument("--revisions", action="store_true",
help="detect tracked changes / comments")
args = ap.parse_args()
if args.images:
print(json.dumps({"images": extract_images(args.path, args.images)},
ensure_ascii=False))
return 0
if args.revisions:
print(json.dumps(detect_revisions(args.path), ensure_ascii=False))
return 0
doc = Document(args.path)
if args.text:
out = extract_text(doc)
elif args.structure:
out = extract_structure(doc)
else:
out = {"styles": styles_used(doc)}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Inspect and resolve tracked changes (w:ins / w:del) in a .docx.
Subcommands:
list JSON list of revisions: id, author, date, type, text
accept-all accept every insertion and deletion
reject-all reject every insertion and deletion
accept accept one revision by --id
reject reject one revision by --id
Examples:
docx_revisions.py list report.docx
docx_revisions.py accept-all report.docx -o accepted.docx
docx_revisions.py reject report.docx --id 3 -o out.docx
Semantics (direct XML manipulation, python-docx oxml layer):
accept w:ins -> unwrap (keep inserted runs) reject w:ins -> remove
accept w:del -> remove reject w:del -> restore
(restore = w:delText tags renamed to w:t, wrapper unwrapped)
Covers run-level insertions/deletions anywhere in body, tables (nested
included), headers and footers. Row/paragraph-mark revisions and format
changes (w:rPrChange etc.) are reported by docx_read.py --revisions but
not resolved here.
"""
from __future__ import annotations
import argparse
import json
import sys
from docx import Document
from docx_common import iter_part_roots
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def q(tag: str) -> str:
return f"{{{W}}}{tag}"
INS, DEL = q("ins"), q("del")
def _iter_revision_elements(doc):
"""Yield every w:ins / w:del element across body, headers, footers."""
for root in iter_part_roots(doc):
for el in root.iter(INS, DEL):
yield el
def _rev_text(el) -> str:
tag = q("delText") if el.tag == DEL else q("t")
return "".join(t.text or "" for t in el.iter(tag))
def _rev_record(el) -> dict:
return {
"id": el.get(q("id")),
"author": el.get(q("author")),
"date": el.get(q("date")),
"type": "insertion" if el.tag == INS else "deletion",
"text": _rev_text(el),
}
def _unwrap(el) -> None:
"""Replace `el` with its children, keeping document order."""
parent = el.getparent()
idx = list(parent).index(el)
for child in list(el):
parent.insert(idx, child)
idx += 1
parent.remove(el)
def _apply(el, accept: bool) -> None:
if el.getparent() is None: # already detached via an outer wrapper
return
if el.tag == INS:
if accept:
_unwrap(el)
else:
el.getparent().remove(el)
else: # w:del
if accept:
el.getparent().remove(el)
else:
for dt in list(el.iter(q("delText"))):
dt.tag = q("t")
_unwrap(el)
def resolve(doc, accept: bool, rev_id: str | None = None) -> int:
targets = [el for el in _iter_revision_elements(doc)
if rev_id is None or el.get(q("id")) == rev_id]
for el in targets:
_apply(el, accept)
return len(targets)
def main() -> int:
ap = argparse.ArgumentParser(
description="List, accept, or reject tracked changes in a .docx.")
sub = ap.add_subparsers(dest="cmd", required=True)
def common(p, out=True):
p.add_argument("path", help="input .docx")
if out:
p.add_argument("-o", "--output",
help="output path (default: overwrite input)")
common(sub.add_parser("list", help="list revisions as JSON"), out=False)
common(sub.add_parser("accept-all", help="accept every revision"))
common(sub.add_parser("reject-all", help="reject every revision"))
for name in ("accept", "reject"):
p = sub.add_parser(name, help=f"{name} one revision by id")
common(p)
p.add_argument("--id", required=True, help="revision id (w:id)")
args = ap.parse_args()
doc = Document(args.path)
if args.cmd == "list":
revs = [_rev_record(el) for el in _iter_revision_elements(doc)]
print(json.dumps({"ok": True, "revisions": revs}, ensure_ascii=False))
return 0
accept = args.cmd in ("accept-all", "accept")
rev_id = getattr(args, "id", None)
n = resolve(doc, accept, rev_id)
if rev_id is not None and n == 0:
print(json.dumps({"ok": False,
"error": f"no revision with id {rev_id}"}))
return 1
out = args.output or args.path
doc.save(out)
print(json.dumps({"ok": True, "output": out, "resolved": n,
"action": "accept" if accept else "reject"},
ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Fill {{placeholder}} tokens in a .docx from a JSON mapping.
Tokens are replaced everywhere: body paragraphs, tables (including nested
tables), headers and footers. Run formatting is preserved; tokens split
across runs are handled.
Usage:
docx_template.py template.docx values.json output.docx
docx_template.py template.docx values.json output.docx --strict
values.json: {"name": "Ada", "date": "2026-01-01"} fills {{name}}, {{date}}.
With --strict, exits 1 if any {{token}} remains unfilled after processing.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from docx import Document
from docx_common import iter_all_paragraphs, replace_in_paragraph
TOKEN_RE = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}")
def main() -> int:
ap = argparse.ArgumentParser(
description="Fill {{token}} placeholders in a .docx from JSON.")
ap.add_argument("template", help="input .docx with {{tokens}}")
ap.add_argument("values", help="JSON file of token -> value")
ap.add_argument("output", help="output .docx path")
ap.add_argument("--strict", action="store_true",
help="fail if any token remains unfilled")
args = ap.parse_args()
with open(args.values, encoding="utf-8") as f:
values = json.load(f)
doc = Document(args.template)
filled = {}
for para in iter_all_paragraphs(doc):
# Normalize whitespace variants like {{ name }} first.
for m in set(TOKEN_RE.findall(para.text)):
if m in values:
# Replace any spacing variant with canonical token, then fill.
for variant in set(
t.group(0) for t in TOKEN_RE.finditer(para.text)
if t.group(1) == m):
n = replace_in_paragraph(para, variant, str(values[m]))
filled[m] = filled.get(m, 0) + n
remaining = sorted({m for para in iter_all_paragraphs(doc)
for m in TOKEN_RE.findall(para.text)})
doc.save(args.output)
result = {"ok": True, "output": args.output, "filled": filled,
"unfilled_tokens": remaining}
if args.strict and remaining:
result["ok"] = False
print(json.dumps(result, ensure_ascii=False))
return 1
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# MIT License. Part of the Hermes docx skill.
"""Health-check a .docx package and report issues as JSON.
Usage: docx_validate.py file.docx
Checks (health-check tier, NOT full XSD schema validation):
- the file is a readable zip and python-docx can open it
- required package parts exist ([Content_Types].xml, document.xml)
- every relationship in every .rels file resolves to a part in the
package (dangling image/hyperlink/etc. rels are reported; external
targets such as hyperlinks are skipped)
- r:embed / r:id references in document.xml resolve to relationships
- embedded images are non-empty and start with known magic bytes
(PNG/JPEG/GIF/BMP/TIFF/EMF/WMF/SVG); no PIL required
- paragraph and run style ids referenced by the document exist in
styles.xml
Output: {"ok": bool, "issues": [{"severity": "error"|"warning", ...}]}
Exit code 1 when any error-severity issue is found (warnings exit 0).
"""
from __future__ import annotations
import argparse
import json
import posixpath
import sys
import zipfile
from lxml import etree
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PR = "http://schemas.openxmlformats.org/package/2006/relationships"
IMAGE_MAGIC = (
b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a",
b"BM", b"II*\x00", b"MM\x00*",
b"\x01\x00\x00\x00", # EMF
b"\xd7\xcd\xc6\x9a", b"\x01\x00\x09\x00", # WMF variants
b"<?xml", b"<svg",
)
def _issue(issues, severity, code, detail):
issues.append({"severity": severity, "code": code, "detail": detail})
def _rel_target(base_part: str, target: str) -> str:
base_dir = posixpath.dirname(base_part)
return posixpath.normpath(posixpath.join(base_dir, target)).lstrip("/")
def validate(path: str) -> dict:
issues: list[dict] = []
try:
zf = zipfile.ZipFile(path)
except (OSError, zipfile.BadZipFile) as exc:
_issue(issues, "error", "not-a-zip", str(exc))
return {"ok": False, "issues": issues}
names = set(zf.namelist())
bad = zf.testzip()
if bad is not None:
_issue(issues, "error", "corrupt-member", f"CRC check failed: {bad}")
for required in ("[Content_Types].xml", "word/document.xml"):
if required not in names:
_issue(issues, "error", "missing-part",
f"required part absent: {required}")
if issues and any(i["severity"] == "error" for i in issues):
return {"ok": False, "issues": issues}
# --- relationships resolve ------------------------------------------
rel_ids_by_source: dict[str, dict] = {}
for rels_name in [n for n in names if n.endswith(".rels")]:
try:
root = etree.fromstring(zf.read(rels_name))
except etree.XMLSyntaxError as exc:
_issue(issues, "error", "bad-rels-xml", f"{rels_name}: {exc}")
continue
source_part = posixpath.normpath(
posixpath.join(posixpath.dirname(rels_name), ".."))
source_part = "" if source_part == "." else source_part
ids = {}
for rel in root.iter(f"{{{PR}}}Relationship"):
rid, target = rel.get("Id"), rel.get("Target", "")
mode = rel.get("TargetMode", "Internal")
ids[rid] = target
if mode == "External":
continue
resolved = _rel_target(source_part + "/x" if source_part
else "x", target)
if resolved not in names:
_issue(issues, "error", "dangling-rel",
f"{rels_name}: {rid} -> {target} (missing part)")
rel_ids_by_source[source_part or "_package"] = ids
# --- r:id / r:embed references in document.xml -----------------------
doc_root = etree.fromstring(zf.read("word/document.xml"))
doc_rels = rel_ids_by_source.get("word", {})
for el in doc_root.iter():
for attr in (f"{{{R}}}id", f"{{{R}}}embed", f"{{{R}}}link"):
rid = el.get(attr)
if rid and rid not in doc_rels:
_issue(issues, "error", "unresolved-reference",
f"document.xml references {rid} with no relationship")
# --- embedded images decode ------------------------------------------
for name in [n for n in names if n.startswith("word/media/")]:
data = zf.read(name)
if not data:
_issue(issues, "error", "empty-image", name)
elif not any(data.startswith(m) for m in IMAGE_MAGIC):
_issue(issues, "warning", "unknown-image-format",
f"{name}: unrecognized magic bytes")
# --- styles referenced exist ------------------------------------------
defined = set()
if "word/styles.xml" in names:
styles_root = etree.fromstring(zf.read("word/styles.xml"))
defined = {s.get(f"{{{W}}}styleId")
for s in styles_root.iter(f"{{{W}}}style")}
for tag, attr in ((f"{{{W}}}pStyle", f"{{{W}}}val"),
(f"{{{W}}}rStyle", f"{{{W}}}val"),
(f"{{{W}}}tblStyle", f"{{{W}}}val")):
for el in doc_root.iter(tag):
sid = el.get(attr)
if sid and sid not in defined:
_issue(issues, "error", "missing-style",
f"style id referenced but not defined: {sid}")
# --- python-docx can open it ------------------------------------------
try:
from docx import Document
Document(path)
except Exception as exc: # noqa: BLE001 - triage tool, report anything
_issue(issues, "error", "python-docx-open-failed", str(exc))
ok = not any(i["severity"] == "error" for i in issues)
return {"ok": ok, "issues": issues}
def main() -> int:
ap = argparse.ArgumentParser(
description="Health-check a .docx (not XSD schema validation).")
ap.add_argument("path", help="the .docx file to check")
args = ap.parse_args()
report = validate(args.path)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,525 @@
# MIT License. End-to-end tests for the docx skill.
"""Pytest suite proving create / read / edit / template round-trips.
Runs the scripts as subprocesses (argparse CLIs) and also verifies the
outputs with python-docx directly. Stdlib + python-docx only; all
fixtures are generated on the fly; no network.
"""
from __future__ import annotations
import json
import os
import struct
import subprocess
import sys
import zlib
from pathlib import Path
import pytest
from docx import Document
SKILL = Path(__file__).resolve().parent.parent
SCRIPTS = SKILL / "scripts"
NON_ASCII = "Фамилия — test"
def make_png(path: Path) -> None:
"""Write a tiny valid 2x2 red PNG using only stdlib."""
def chunk(tag: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
ihdr = struct.pack(">IIBBBBB", 2, 2, 8, 2, 0, 0, 0)
raw = b"".join(b"\x00" + b"\xff\x00\x00" * 2 for _ in range(2))
png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
path.write_bytes(png)
def run(script: str, *args: str) -> dict:
env = dict(os.environ)
env["LC_ALL"] = "C" # prove no locale-default text reads
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, str(SCRIPTS / script), *map(str, args)],
capture_output=True, env=env)
assert proc.returncode == 0, proc.stderr.decode("utf-8", "replace")
return json.loads(proc.stdout.decode("utf-8"))
@pytest.fixture(scope="module")
def workdir(tmp_path_factory) -> Path:
return tmp_path_factory.mktemp("docxskill")
@pytest.fixture(scope="module")
def created(workdir: Path) -> Path:
"""Create a document exercising every create feature."""
png = workdir / "pic.png"
make_png(png)
spec = {
"page": {"width_mm": 210, "height_mm": 297,
"margins_mm": {"top": 25, "bottom": 25,
"left": 20, "right": 20}},
"header": "Report header",
"footer": "Page footer",
"styles": [{"name": "FancyNote", "base": "Normal", "font": "Arial",
"size_pt": 11, "italic": True, "color": "1F4E79"}],
"blocks": [
{"type": "heading", "text": "Main Title", "level": 1},
{"type": "heading", "text": "Section One", "level": 2},
{"type": "paragraph", "runs": [
{"text": "plain "},
{"text": "boldbit", "bold": True},
{"text": " italicbit", "italic": True},
{"text": " underbit", "underline": True}]},
{"type": "paragraph", "text": "Styled note.",
"style": "FancyNote"},
{"type": "bullet_list", "items": ["alpha", "beta"]},
{"type": "numbered_list", "items": ["first", "second"]},
{"type": "table", "header": ["Name", "Qty"],
"rows": [["Widget", "3"], ["Gadget", "5"]],
"style": "Table Grid", "header_bold": True},
{"type": "image", "path": str(png), "width_mm": 30},
{"type": "page_break"},
{"type": "paragraph", "text": "After the break."},
],
}
spec_path = workdir / "spec.json"
spec_path.write_text(json.dumps(spec), encoding="utf-8")
out = workdir / "created.docx"
res = run("docx_create.py", spec_path, out)
assert res["ok"] and out.exists()
return out
class TestCreateAndRead:
def test_text_roundtrip(self, created: Path):
text = run("docx_read.py", created, "--text")
body = "\n".join(text["body"])
for expected in ("Main Title", "plain boldbit italicbit underbit",
"Styled note.", "alpha", "second",
"After the break."):
assert expected in body
assert text["tables"] == [[["Name", "Qty"], ["Widget", "3"],
["Gadget", "5"]]]
assert "Report header" in text["headers"]
assert "Page footer" in text["footers"]
def test_structure(self, created: Path):
st = run("docx_read.py", created, "--structure")
outline = [(h["level"], h["text"]) for h in st["outline"]]
assert (1, "Main Title") in outline
assert (2, "Section One") in outline
assert st["table_count"] == 1
assert st["tables"][0] == {"rows": 3, "cols": 2}
def test_styles_used(self, created: Path):
styles = run("docx_read.py", created, "--styles")["styles"]
for s in ("Heading 1", "FancyNote", "List Bullet", "List Number",
"Table Grid"):
assert s in styles
def test_images_extracted(self, created: Path, workdir: Path):
outdir = workdir / "media"
res = run("docx_read.py", created, "--images", outdir)
assert len(res["images"]) == 1
img = Path(res["images"][0])
assert img.read_bytes().startswith(b"\x89PNG")
def test_run_formatting_persisted(self, created: Path):
doc = Document(str(created))
para = next(p for p in doc.paragraphs if "boldbit" in p.text)
flags = {r.text.strip(): (r.bold, r.italic, r.underline)
for r in para.runs if r.text.strip()}
assert flags["boldbit"][0] is True
assert flags["italicbit"][1] is True
assert flags["underbit"][2] is True
def test_page_setup(self, created: Path):
sec = Document(str(created)).sections[0]
assert round(sec.page_width.mm) == 210
assert round(sec.top_margin.mm) == 25
def test_revisions_detection(self, created: Path):
rev = run("docx_read.py", created, "--revisions")
assert rev["has_tracked_changes"] is False
assert rev["comments"] is False
class TestEdit:
def test_replace_preserves_formatting(self, created: Path, workdir: Path):
out = workdir / "edited.docx"
res = run("docx_edit.py", "replace", created, "--find", "boldbit",
"--replace", "REPLACED", "-o", out)
assert res["replacements"] == 1
doc = Document(str(out))
para = next(p for p in doc.paragraphs if "REPLACED" in p.text)
run_ = next(r for r in para.runs if "REPLACED" in r.text)
assert run_.bold is True # formatting survived
def test_set_cell(self, created: Path, workdir: Path):
out = workdir / "cell.docx"
run("docx_edit.py", "set-cell", created, "--table", "0", "--row",
"1", "--col", "1", "--text", "99", "-o", out)
assert Document(str(out)).tables[0].cell(1, 1).text == "99"
def test_insert_and_delete(self, created: Path, workdir: Path):
out = workdir / "ins.docx"
run("docx_edit.py", "insert", created, "--index", "0", "--text",
"Inserted first", "-o", out)
doc = Document(str(out))
assert doc.paragraphs[0].text == "Inserted first"
out2 = workdir / "del.docx"
run("docx_edit.py", "delete", out, "--index", "0", "-o", out2)
assert Document(str(out2)).paragraphs[0].text != "Inserted first"
def test_apply_style(self, created: Path, workdir: Path):
out = workdir / "styled.docx"
doc = Document(str(created))
idx = next(i for i, p in enumerate(doc.paragraphs)
if p.text == "After the break.")
run("docx_edit.py", "style", created, "--index", str(idx),
"--style", "Heading 2", "-o", out)
doc2 = Document(str(out))
assert doc2.paragraphs[idx].style.name == "Heading 2"
class TestTemplate:
def test_fill_everywhere_non_ascii(self, workdir: Path):
# Build a template: tokens in body, split runs, table, header, footer.
tpl = workdir / "tpl.docx"
doc = Document()
doc.sections[0].header.paragraphs[0].text = "H: {{name}}"
doc.sections[0].footer.paragraphs[0].text = "F: {{date}}"
p = doc.add_paragraph()
p.add_run("Dear {{na") # token split across runs
p.add_run("me}}, hello.")
t = doc.add_table(rows=1, cols=2)
t.cell(0, 0).text = "{{name}}"
t.cell(0, 1).text = "{{ date }}" # spaced variant
doc.add_paragraph("Unfilled: {{missing}}")
doc.save(str(tpl))
values = workdir / "values.json"
values.write_text(
json.dumps({"name": NON_ASCII, "date": "2026-08-08"},
ensure_ascii=False), encoding="utf-8")
out = workdir / "filled.docx"
res = run("docx_template.py", tpl, values, out)
assert res["ok"] is True
assert res["unfilled_tokens"] == ["missing"]
text = run("docx_read.py", out, "--text")
assert f"Dear {NON_ASCII}, hello." in text["body"]
assert text["tables"][0][0] == [NON_ASCII, "2026-08-08"]
assert f"H: {NON_ASCII}" in text["headers"]
assert "F: 2026-08-08" in text["footers"]
def test_strict_fails_on_unfilled(self, workdir: Path):
tpl = workdir / "tpl2.docx"
doc = Document()
doc.add_paragraph("{{gone}}")
doc.save(str(tpl))
values = workdir / "empty.json"
values.write_text("{}", encoding="utf-8")
env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")
proc = subprocess.run(
[sys.executable, str(SCRIPTS / "docx_template.py"), str(tpl),
str(values), str(workdir / "out2.docx"), "--strict"],
capture_output=True, env=env)
assert proc.returncode == 1
payload = json.loads(proc.stdout.decode("utf-8"))
assert payload["unfilled_tokens"] == ["gone"]
# --------------------------------------------------------------- new parity
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def q(tag: str) -> str:
return f"{{{W}}}{tag}"
def run_raw(script: str, *args: str):
env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")
return subprocess.run(
[sys.executable, str(SCRIPTS / script), *map(str, args)],
capture_output=True, env=env)
def _add_ins(para, rev_id: int, text: str, author="Editor"):
from lxml import etree
ins = etree.SubElement(para._p, q("ins"))
ins.set(q("id"), str(rev_id))
ins.set(q("author"), author)
ins.set(q("date"), "2026-01-02T03:04:05Z")
r = etree.SubElement(ins, q("r"))
t = etree.SubElement(r, q("t"))
t.text = text
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
def _add_del(para, rev_id: int, text: str, author="Editor"):
from lxml import etree
dele = etree.SubElement(para._p, q("del"))
dele.set(q("id"), str(rev_id))
dele.set(q("author"), author)
dele.set(q("date"), "2026-01-02T03:04:05Z")
r = etree.SubElement(dele, q("r"))
t = etree.SubElement(r, q("delText"))
t.text = text
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
@pytest.fixture()
def tracked(tmp_path: Path) -> Path:
"""Doc with a tracked insertion + deletion in body AND in a table."""
doc = Document()
p = doc.add_paragraph("Base ")
_add_ins(p, 1, "ADDED")
_add_del(p, 2, "REMOVED")
table = doc.add_table(rows=1, cols=1)
cp = table.cell(0, 0).paragraphs[0]
cp.add_run("Cell ")
_add_ins(cp, 3, "CELLADD")
_add_del(cp, 4, "CELLGONE")
path = tmp_path / "tracked.docx"
doc.save(str(path))
return path
class TestRevisions:
def test_list(self, tracked: Path):
res = run("docx_revisions.py", "list", tracked)
revs = {r["id"]: r for r in res["revisions"]}
assert len(revs) == 4
assert revs["1"] == {"id": "1", "author": "Editor",
"date": "2026-01-02T03:04:05Z",
"type": "insertion", "text": "ADDED"}
assert revs["2"]["type"] == "deletion"
assert revs["2"]["text"] == "REMOVED"
assert revs["3"]["text"] == "CELLADD" # inside table
assert revs["4"]["type"] == "deletion"
def test_accept_all(self, tracked: Path, tmp_path: Path):
out = tmp_path / "acc.docx"
res = run("docx_revisions.py", "accept-all", tracked, "-o", out)
assert res["resolved"] == 4
doc = Document(str(out))
assert doc.paragraphs[0].text == "Base ADDED"
assert doc.tables[0].cell(0, 0).text == "Cell CELLADD"
assert run("docx_revisions.py", "list", out)["revisions"] == []
def test_reject_all(self, tracked: Path, tmp_path: Path):
out = tmp_path / "rej.docx"
run("docx_revisions.py", "reject-all", tracked, "-o", out)
doc = Document(str(out))
assert doc.paragraphs[0].text == "Base REMOVED"
assert doc.tables[0].cell(0, 0).text == "Cell CELLGONE"
def test_accept_single_by_id(self, tracked: Path, tmp_path: Path):
out = tmp_path / "one.docx"
res = run("docx_revisions.py", "accept", tracked, "--id", "1",
"-o", out)
assert res["resolved"] == 1
doc = Document(str(out))
assert doc.paragraphs[0].text == "Base ADDED" # del 2 unresolved
remaining = run("docx_revisions.py", "list", out)["revisions"]
assert sorted(r["id"] for r in remaining) == ["2", "3", "4"]
def test_reject_single_by_id(self, tracked: Path, tmp_path: Path):
out = tmp_path / "rone.docx"
run("docx_revisions.py", "reject", tracked, "--id", "2", "-o", out)
doc = Document(str(out))
assert doc.paragraphs[0].text == "Base REMOVED" # ins 1 unresolved
def test_unknown_id_fails(self, tracked: Path, tmp_path: Path):
proc = run_raw("docx_revisions.py", "accept", tracked, "--id",
"999", "-o", tmp_path / "x.docx")
assert proc.returncode == 1
class TestComments:
@pytest.fixture()
def base(self, tmp_path: Path) -> Path:
doc = Document()
doc.add_paragraph("The quarterly revenue rose sharply.")
doc.add_paragraph("Second paragraph.")
path = tmp_path / "base.docx"
doc.save(str(path))
return path
def test_add_list_delete(self, base: Path, tmp_path: Path):
out = tmp_path / "com.docx"
res = run("docx_comments.py", "add", base, "--target",
"quarterly revenue", "--text", "Needs a source",
"--author", "Reviewer", "--initials", "R", "-o", out)
assert res["ok"] is True
cid = res["comment_id"]
listed = run("docx_comments.py", "list", out)["comments"]
assert len(listed) == 1
c = listed[0]
assert c["id"] == cid
assert c["author"] == "Reviewer"
assert c["text"] == "Needs a source"
assert c["anchored_text"] == "quarterly revenue"
assert c["date"]
# document text unchanged by anchoring
text = run("docx_read.py", out, "--text")
assert "The quarterly revenue rose sharply." in text["body"]
out2 = tmp_path / "nocom.docx"
run("docx_comments.py", "delete", out, "--id", cid, "-o", out2)
assert run("docx_comments.py", "list", out2)["comments"] == []
text2 = run("docx_read.py", out2, "--text")
assert "The quarterly revenue rose sharply." in text2["body"]
def test_xml_fallback_path(self, base: Path, tmp_path: Path):
out = tmp_path / "xmlcom.docx"
res = run("docx_comments.py", "add", base, "--target",
"Second paragraph", "--text", "fallback note",
"--author", "Bot", "--xml", "-o", out)
assert res["native_api"] is False
listed = run("docx_comments.py", "list", out)["comments"]
assert listed[0]["text"] == "fallback note"
assert listed[0]["anchored_text"] == "Second paragraph"
# file still opens cleanly
assert Document(str(out)).paragraphs[1].text == "Second paragraph."
def test_missing_target_fails(self, base: Path, tmp_path: Path):
proc = run_raw("docx_comments.py", "add", base, "--target",
"not present", "--text", "x", "-o",
tmp_path / "y.docx")
assert proc.returncode == 1
class TestValidate:
def test_healthy_file_passes(self, created: Path):
res = run("docx_validate.py", created)
assert res["ok"] is True
assert all(i["severity"] != "error" for i in res["issues"])
def test_not_a_zip(self, tmp_path: Path):
bad = tmp_path / "bad.docx"
bad.write_bytes(b"this is not a zip file")
proc = run_raw("docx_validate.py", bad)
assert proc.returncode == 1
rep = json.loads(proc.stdout.decode("utf-8"))
assert rep["issues"][0]["code"] == "not-a-zip"
def test_dangling_rel_and_empty_image(self, created: Path,
tmp_path: Path):
import shutil
import zipfile
broken = tmp_path / "broken.docx"
shutil.copy(created, broken)
# rebuild the zip: drop the image part, zero out nothing else
src = zipfile.ZipFile(str(created))
with zipfile.ZipFile(str(broken), "w") as dst:
for item in src.infolist():
if item.filename.startswith("word/media/"):
dst.writestr(item.filename, b"") # empty image
else:
dst.writestr(item, src.read(item.filename))
proc = run_raw("docx_validate.py", broken)
assert proc.returncode == 1
rep = json.loads(proc.stdout.decode("utf-8"))
codes = {i["code"] for i in rep["issues"]}
assert "empty-image" in codes
def test_missing_style(self, tmp_path: Path):
import zipfile
doc = Document()
doc.add_paragraph("styled", style="Heading 1")
path = tmp_path / "styles.docx"
doc.save(str(path))
# rewrite document.xml to reference a style id that doesn't exist
src = zipfile.ZipFile(str(path))
broken = tmp_path / "badstyle.docx"
with zipfile.ZipFile(str(broken), "w") as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename == "word/document.xml":
data = data.replace(b'w:val="Heading1"',
b'w:val="GhostStyle"')
dst.writestr(item, data)
proc = run_raw("docx_validate.py", broken)
assert proc.returncode == 1
rep = json.loads(proc.stdout.decode("utf-8"))
assert any(i["code"] == "missing-style" and "GhostStyle"
in i["detail"] for i in rep["issues"])
class TestNormalize:
def test_merges_split_runs(self, tmp_path: Path):
doc = Document()
p = doc.add_paragraph()
p.add_run("Hel") # identical (no) formatting, split
p.add_run("lo wo")
p.add_run("rld")
b = p.add_run("BOLD1")
b.bold = True
b2 = p.add_run("BOLD2")
b2.bold = True
i = p.add_run("ital")
i.italic = True
path = tmp_path / "split.docx"
doc.save(str(path))
out = tmp_path / "norm.docx"
res = run("docx_edit.py", "normalize", path, "-o", out)
assert res["runs_merged"] == 3 # 2 plain merges + 1 bold merge
doc2 = Document(str(out))
para = doc2.paragraphs[0]
assert para.text == "Hello worldBOLD1BOLD2ital"
assert [r.text for r in para.runs] == \
["Hello world", "BOLD1BOLD2", "ital"]
assert para.runs[1].bold is True
assert para.runs[2].italic is True
class TestFields:
def test_toc_and_page_numbers_via_edit(self, created: Path,
tmp_path: Path):
out = tmp_path / "fields.docx"
run("docx_edit.py", "toc", created, "--index", "0", "-o", out)
run("docx_edit.py", "page-numbers", out)
import zipfile
doc_xml = zipfile.ZipFile(str(out)).read(
"word/document.xml").decode("utf-8")
assert "TOC \\o" in doc_xml
assert "fldChar" in doc_xml
footer_names = [n for n in zipfile.ZipFile(str(out)).namelist()
if n.startswith("word/footer")]
footers = "".join(zipfile.ZipFile(str(out)).read(n).decode("utf-8")
for n in footer_names)
assert "PAGE" in footers and "NUMPAGES" in footers
# still a valid document
assert run("docx_validate.py", out)["ok"] is True
def test_toc_and_footer_in_create_spec(self, tmp_path: Path):
spec = {
"footer_page_numbers": True,
"blocks": [
{"type": "toc"},
{"type": "heading", "text": "Chapter", "level": 1},
],
}
spec_path = tmp_path / "fspec.json"
spec_path.write_text(json.dumps(spec), encoding="utf-8")
out = tmp_path / "fcreate.docx"
run("docx_create.py", spec_path, out)
import zipfile
z = zipfile.ZipFile(str(out))
assert "TOC \\o" in z.read("word/document.xml").decode("utf-8")
footers = "".join(z.read(n).decode("utf-8") for n in z.namelist()
if n.startswith("word/footer"))
assert "NUMPAGES" in footers