Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user