| name | rebind-office |
| description | Pipeline stage 3 (the Office/docx surgical output engine) for report-regeneration — the Office analogue of rebind-html. Applies a Binding Manifest to a COPY of a Word .docx template and emits the regenerated .docx: frozen OPC parts + nodes stay byte-identical, surgical/regenerate nodes are rebuilt under the zero-literal construction rule (strip the old value, THEN write the new), rasters are re-captured (never transplanted), needs-review nodes are left untouched but visibly marked + logged. Stdlib-only (zipfile/xml.etree), edits via the shared rr_anchor OOXML resolver, python-docx/docxtpl optional, runs on Python 3.9. NOT for structure inference (infer-office, an earlier stage), the fidelity harness (a separate downstream track), or HTML output (the rebind-html lane). |
Skill: rebind-office
What this is
The Office (Word/.docx) surgical output engine for report-regeneration — pipeline
stage 3, the exact analogue of rebind-html. Given a .docx
template, a Binding Manifest
(../../knowledge/binding-manifest.schema.json,
format: "office"), and resolved new-source data, it produces a same-format review-ready
draft by performing schema-validated surgery on a copy of the template — never by
re-rendering from an abstract model. This is the "surgeon, not a renderer" model from
../../knowledge/core-architecture-spec.md
§1, applied to the Office/Word→PDF output format.
The engine works on a copy the whole way: it reads word/document.xml out of the OPC/ZIP
package with stdlib zipfile, edits only those bytes via the shared OOXML resolver
in ../../scripts/rr_anchor.py, then re-zips — every other OPC
part is copied through byte-for-byte. It consumes a manifest; it does not infer structure
or propose bindings (that is infer-office, an earlier stage) and
it does not run the fidelity harness (V1–V6 + period-coherence — a separate, downstream
track). Its one job: apply exactly what the manifest says, per node class, and prove the
frozen ones (parts and nodes) didn't move.
It never re-implements anchor resolution. Every anchor is resolved by rr_anchor's
ooxml_resolve / ooxml_try_resolve, which OWNS the shared OOXML grammar (body-rooted
body/p[3]/r[1] paths and bookmark(NAME) paths) that infer-office produces — so producer
and consumer cannot drift.
The four node classes (§4 of the architecture spec)
| Class | What this engine does |
|---|
frozen | No mutation. After every other binding is applied, the engine re-resolves the node in both template and output and proves its outer OOXML byte span is identical (_assert_frozen_unchanged) — a defensive proof, not an assumption. frozen is EARNED upstream (the manifest's job); rebind only guarantees a frozen node did not move. |
surgical | Strip, then write — two separate byte splices, in that order. _strip_value empties every w:t under the anchored run/cell (zero-literal); _write_value writes the new value into the first w:t. At the instant between the two calls the node provably carries no old instance value — by construction, not a downstream check. Run properties (w:rPr) and every other child are preserved; the edit touches only w:t text. |
regenerate | Same strip-then-write zero-literal invariant. A prose/run node is rebuilt from new data via stdlib string.Template (dotted-placeholder subclass — jinja2 is optional acceleration for native {{ }}/{% %} control syntax). A raster/w:drawing node is force-regenerated by replacing the referenced word/media/* binary with a fresh capture supplied in new-data — never by transplanting the old pixels, per the spec's construction rule: "a transplanted binary blob cannot be proven data-free." A regenerate raster with no fresh capture is a loud error, never a silent transplant. |
needs-review | Left completely untouched — content byte-for-byte unchanged — but a visible OOXML marker run (or a marker paragraph, for a table cell) carrying a machine-readable token ([[REBIND-NEEDS-REVIEW]]) is inserted adjacent to it, and it is logged in the change-manifest. Never ships silently — this is guarantee #2 made mechanical for Office output. |
Bindings are applied in reverse document order (RT1-F10): each anchor is a stable node
identity re-resolved on the current bytes just before its edit, so processing from the end of
the document backward means an earlier edit's byte/index shift can never invalidate a
not-yet-applied anchor.
Usage
python3 plugins/report-regeneration/skills/rebind-office/rebind_office.py \
--template tests/fixtures/report-regeneration/sample-report.docx \
--manifest path/to/binding-manifest.json \
--new-data path/to/new-data.json \
--out tests/fixtures/report-regeneration/_out/regenerated.docx
Add --pretty to pretty-print the printed JSON change-manifest. Every invocation prints a
JSON object to stdout: {"schema": "report-regeneration/rebind-office@1", "ok": true|false, ..., "changes": [...]}. Exit code 0 = success; 2 = usage / path-guard / manifest-schema /
anchor-not-found / missing-data-query / missing-new-data-key / invalid-docx error (never a
silent partial write — a failure aborts the whole run and touches --out not at all).
--template/--manifest/--new-data/--out are relative (or in-repo absolute) paths,
path-guarded (mirrors ../rebind-html/rebind_html.py and
../../scripts/seed_defects.py): no .. traversal, must
resolve inside the repo root. --out may never equal --template — this script works on a
copy; the template file on disk is never mutated (verified in the CLI test via a
before/after SHA-256 hash of the template file).
The new-data lookup + template contract
A binding's data_query.expression is a dot-path into the --new-data JSON (e.g.
"revenue.total" looks up {"revenue": {"total": ...}}). surgical writes the resolved value
(XML-escaped) directly into the run's w:t. regenerate treats the resolved value as either:
- a plain string — a template, rendered with
${dotted.path} placeholders resolved against
the SAME new-data object (stdlib-only, always works); or a native Jinja template
({{ }}/{% %}) — rendered via jinja2 if importable, else a loud error (never a silent
stdlib mis-render of control-flow syntax); or
- an object — for a raster/
w:drawing node, {"media_base64": "<fresh png/jpeg bytes>"}
(the fresh capture, base64-encoded); for a text node with an explicit template key,
{"template": "..."}.
A missing dot-path segment, an unresolvable anchor, or a class/data_query mismatch against the
schema's rule (frozen carries no data_query; every other class must) is a loud error → exit
2 — never a guess.
Anchor support (this is an Office-only engine)
Only anchor.kind == "ooxml_path" is resolvable — resolved via rr_anchor.ooxml_resolve
against the exact grammar infer-office emits (body/p[3]/r[1],
body/tbl[1]/tr[2]/tc[2]/p[1]/r[1], bookmark(revenue_total)). json_pointer /
element_id / css_selector anchors are HTML-lane anchors from the same manifest schema;
this engine rejects them loudly rather than guessing — HTML output is the separate
rebind-html lane.
Security
Stdlib XML parsers are XXE / billion-laughs vulnerable and defusedxml is deliberately off
this plugin's dependency path, so this engine — like infer-office and rr_anchor — rejects
any DOCTYPE/DTD/ENTITY in any OOXML part it parses (word/document.xml, the .rels) before
xml.etree ever expands an entity. A valid OOXML part never carries one; the rejection closes
external-entity and entity-expansion attacks at the source and treats a hostile template as
data, never instructions (§6).
Tests
tests/test_rebind_office.py — stdlib unittest, no pytest
required, run end-to-end against the real
tests/fixtures/report-regeneration/sample-report.docx
corpus fixture:
TestRebindOffice — a hand-built manifest covering one binding of every class (frozen
heading run, surgical bookmarked currency value, regenerate narrative run, regenerate
raster, needs-review percent run): frozen OPC parts byte-identical (everything but the
edited word/document.xml + regenerated media) and [Content_Types].xml intact; a frozen
node byte-identical (+ a must-fail check that a genuinely mutated frozen node IS caught);
surgical value replaced with the OLD value absent from that run and the whole document;
strip-then-write asserted at the midpoint; regenerate text reflects new data; regenerate
raster swaps the media binary (old bytes gone, fresh in) and refuses a transplant with no
fresh capture; needs-review flagged + logged, content untouched; the output is a valid
docx (re-openable via zipfile + xml.etree, testzip() clean); manifest-schema
rejections (non-office format, non-ooxml anchor, frozen-with-data_query); DTD/XXE rejection.
TestCLI — process-boundary: happy path (exit 0, valid docx written, $new present /
$old absent, template SHA-256 unchanged), --out == --template rejection, path-traversal
rejection, absolute-path rejection (all exit 2).
Run: python3 plugins/report-regeneration/skills/rebind-office/tests/test_rebind_office.py -v
The producer↔resolver anchor cross-check lives in
../../scripts/tests/test_rr_anchor.py.
Purity contract
- Stdlib-only —
argparse, base64, io, json, posixpath, re, string, sys,
zipfile, xml.etree. No pip installs required. python-docx / docxtpl are detected for
parity only (the byte-splice engine is the sole code path); jinja2 is imported via a
graceful try/except and is optional acceleration for regenerate templates that use native
Jinja control syntax — every ${dotted.path}-only template works with jinja2 absent.
- Runs unmodified on Python 3.9.6 —
from __future__ import annotations; no PEP 604 union
syntax (X | Y), no match statement.
- No network, no subprocess inside the engine.
- Path-guarded — see Usage above.
- Exit-coded — deterministic
0/2; no partial write on failure.
- Zero-literal construction, by construction — every surgical/regenerate value mutation is
a strip call followed by a write call; there is no code path that writes a new value without
first stripping the old one at that anchor, and no old instance value survives a
surgical/regenerate node.