Files
ironclaw/docs
Illia Polosukhin 7d77a752f2 feat(documents): edit docx/xlsx/pptx structurally, render PDF from HTML, and fix the #7109 text-log regression (#7163)
* fix(coding): stop the binary-write backstop rejecting ordinary text logs

Follow-up to #7109, which shipped with the binary backstop in
`verify_read_before_edit` using the STRICT probe (any NUL in the first
8KiB) while `read_file` admits text carrying a few stray NULs via
`reject_binary_probe_lenient`.

The two disagree about what "binary" means, so a syslog the model had
just read successfully became unwritable — and was reported as a "binary
document", which it is not. `read_file_tolerates_stray_nul_and_invalid_utf8_in_text_logs`
pins the lenient read; this makes the write path agree with it.
`apply_patch` keeps applying the strict probe itself, where byte-fidelity
for write-back demands it.

Also adds the coverage #7109 landed without:

- `builtin_write_file_still_overwrites_text_log_with_stray_nul` — fails
  before this fix, and is the regression above.
- `builtin_write_file_rejects_extracted_read_representation_at_unlisted_extension`
  — the recorded-read-representation guard had no test at all. The
  existing docx test returns at the extension guard before
  `verify_read_before_edit` is ever reached, so `ReadState`, the
  `read_file` plumbing and the representation check were untested. `.rtf`
  is the one format `read_file` extracts that the extension lists omit,
  making it that guard's only live surface.
- `builtin_apply_patch_rejects_a_binary_document_with_an_actionable_reason`
  — #6898 named apply_patch's opaque failure as part of the bug, and the
  fix addresses it, but nothing pinned it.
- `reborn_integration_document_edit` — the whole journey at the
  integration tier: upload a .docx, ask for an edit, and the bytes served
  by the production `InboundAttachmentReader` (the WebUI download path)
  are byte-identical to the upload.

Refs #6898, #7109

* feat(documents): structure-preserving docx/xlsx/pptx editing and HTML-to-PDF

Issue #6898 item 3: the deferred "real document round-trip capability".
This is the library layer; capability wiring follows.

The governing rule is copy-through: rewrite only the parts an edit
targets, copy every other zip entry byte-for-byte. A generator that
rebuilds a document from the text a model saw drops everything the model
never saw — styles, numbering, headers, images, embedded objects — and
the file still opens, so the loss is invisible. Copy-through makes that
impossible by construction rather than by diligence, which is why this
is not "add docx-rs and write a docx".

- `ooxml`: package read/write plus an event-level XML transform. An
  event the handler does not claim is re-emitted exactly as read, so
  attribute order, namespace prefixes and self-closing forms survive; a
  DOM round trip normalizes all three and churns untouched parts.
- `docx`: paragraphs with `w:ins`/`w:del` surfaced as typed revisions
  (flat extraction shows deleted text as if it were still in the
  contract), and accept/reject. Rejecting a deletion converts `w:delText`
  back to `w:t` — without that Word renders the restored run empty.
  Revisions split across runs coalesce into one span.
- `xlsx`: shared strings resolved so headers read as text, formula edits
  that drop the stale cached `<v>` and set `fullCalcOnLoad`, and new
  cells inserted in ascending column order (Excel repairs, and silently
  drops content from, an out-of-order row).
- `pptx`: slide clone that duplicates the slide's rels part, so the copy
  inherits its layout — style lives in the layout chain, not the slide,
  which is why constructing a slide cannot preserve it. Registers the new
  part in content-types, presentation rels and slide order.
- `html_pdf`: PDF is deliberately not edited. A documented HTML subset
  renders via printpdf's standard-14 fonts. printpdf's `html` feature is
  left off on purpose: it resolves fonts through rust-fontconfig, which
  scans system fonts and would make pagination depend on the host.

51 tests, including the copy-through invariants (unrelated parts stay
bit-identical, a no-op edit is byte-stable) and every trap named above.

Refs #6898

* feat(coding): document_edit and html_to_pdf, and read_file reads OOXML structurally

Wires issue #6898 item 3's library layer to the model. Reads unify into
read_file; writes stay typed. That asymmetry is the design, not a
compromise:

- read_file on a .docx/.xlsx/.pptx now returns the ADDRESSABLE STRUCTURE
  (paragraphs with tracked-change spans, cells with resolved headers and
  formulas, slides) and records `ReadRepresentation::Structured`. Folded
  in rather than offered as a `document_read` tool because a model
  reaches for read_file on whatever path it is handed — a tool it had to
  know to prefer would go unused while read_file kept returning
  tag-stripped text that shows a redline's DELETED words as if they were
  still in the contract.
- write_file cannot be folded: its contract is (path, content: &str), so
  it cannot express "accept the revision in p3". Overloading it means
  regenerating the document from text — the corruption #6898 banned — or
  a sometimes-JSON `content`. apply_patch fails for the same reason plus
  ambiguity: its anchors match extracted text, and mapping a match back
  to runs is undefined when a string spans a revision boundary. The
  binary-write ban stays permanent.
- document_edit takes typed ops and always writes to a NEW path, so a
  bad edit can never cost the user the original. It requires a prior
  Structured read of the source, keeping write_file's mid-air-collision
  guarantee on a fingerprint over the same raw bytes.
- html_to_pdf renders; it refuses to overwrite an existing file, because
  silently replacing a PDF the user uploaded is the same class of loss
  the binary-write guard prevents.

Two findings from writing the tests, both fixed here:

1. The surface test caught that a builtin capability is invisible to the
   model without a published input schema — registration alone is not
   enough. Both new tools now publish one.
2. The xlsx journey caught that set_cell_formula could not create a row.
   A totals row sits just below the data, so "the row is not there yet"
   is the ordinary case, not an edge case; the crate fixture happened to
   have the row already and masked it. Rows are now created in ascending
   order, with regression tests.

Tests: 6 capability tests through real dispatch (including that a
Structured read still does NOT authorize a raw write_file overwrite —
the two guards must not cancel each other), and four integration
journeys on real OOXML fixtures: docx redlines resolved into a clean
copy, an xlsx total under a named column, a pptx slide cloned with the
source's layout, and a PDF produced by authoring HTML and rendering it.

Refs #6898

* test(reborn): register e2e coverage for document_edit and html_to_pdf

`reborn_builtin_first_party_capability_e2e_coverage_is_complete` requires
every always-on first-party capability to name where its Reborn e2e
coverage lives. The two new document capabilities had that coverage —
`reborn_integration_document_edit` drives all four journeys — but were
not registered, so the guardrail failed.

Caught by the pre-push hook, which runs the full workspace suite; the
per-crate runs used while developing never touch this test.

* ci(planner): classify tests/fixtures document fixtures

`Detect Reborn test scope` failed with "unmapped test or CI path:
tests/fixtures/contract.docx". The planner deliberately hard-errors on
any unclassified path under tests/ to force a per-file decision, and
binary document fixtures had no arm — only recorded LLM traces under
tests/fixtures/llm_traces/ were mapped.

These fixtures are consumed by integration tests through `include_bytes!`,
so a changed fixture changes what those tests assert; it schedules a
representative integration lane, matching how shared integration support
is treated.

Adds the matching planner test.

* ci: satisfy the panic check in the test-only fixture builder

`Fast deterministic checks` flagged four unwraps in
`ironclaw_documents::test_fixtures`. The module is `#[cfg(test)]`-gated
(`has_cfg_test_module_declaration` agrees), but the checker's main scan
path does not consult that for crate-root modules, so it reads them as
production code.

Uses the inline `// safety:` suppression the checker documents rather
than relaxing the checker, which guards a real invariant for everything
else. The comments must be on the same line as the call to take effect.

* fix(documents): nested paragraphs and leaked revision flags corrupted docx edits

Two critical defects from CodeRabbit's review of #7163, both confirmed by
tests that fail before the fix.

1. Nested `w:p` lost the outer paragraph and desynchronised ids.
   Word nests a paragraph inside a paragraph when a run holds a text box
   (`w:txbxContent`). The reader kept one `current` slot, so the inner
   Start overwrote the outer and the outer was never emitted; the writer
   meanwhile counted EVERY `w:p` Start. Read ids and write ids therefore
   addressed different paragraphs, so an edit landed on the wrong one.
   The reader now keeps a stack and assigns ids in Start order, matching
   how the writer counts, and both write paths keep a target stack so a
   nested paragraph's End restores the enclosing paragraph's state
   instead of clearing it.

   Note the mechanism: table cells do NOT reproduce this — `w:tbl`/`w:tc`
   paragraphs are siblings in document order. Only a text box nests.

2. Revision flags leaked past a dropped subtree and unbalanced the XML.
   Reject-insert and accept-delete set `dropping = 1` AND the
   `in_insert`/`in_delete` flag. The dropping branch then consumed the
   matching End, so the flag was never cleared and stayed set for the rest
   of the document — deleting a LATER paragraph's `</w:ins>`. Resolving
   revisions in one paragraph emitted `word/document.xml` with an
   unclosed element, which Word rejects.

   The flag is now set only on the unwrap paths, where the End genuinely
   must be dropped by the handler. On the drop-subtree paths the End is
   consumed by the dropping branch and no flag is needed.

Both defects are invisible to single-paragraph, flat fixtures — which is
what the crate's own fixtures were.

* fix(documents): preserve cell styles, resolve sheets by relationship, keep text around comments

Four more defects from the #7163 review, each with a test that fails
before its fix.

xlsx:
- Replacing a cell dropped its `s` style index, silently reverting a
  currency or date column to General. That is the precise "preserve what
  you did not touch" promise the crate exists for, so the style now rides
  across the replacement.
- Sheet names were paired to worksheet parts POSITIONALLY. I shortcut
  this deliberately and said so in a comment; the reviewer was right that
  it is wrong. Sheet declaration order does not have to match worksheet
  file numbering, so an edit could land in the wrong worksheet. Names now
  resolve through `r:id` in `xl/_rels/workbook.xml.rels`, falling back to
  positional pairing only when the rels part is absent.

html_pdf:
- A comment or declaration cleared the buffered text before it, so
  `<p>hello <!-- note --> world</p>` rendered as `world`. This directly
  contradicted the module's claim that wrapping markup never swallows
  content. Whitespace now also collapses across the resulting span join,
  so the repaired text reads `hello world` rather than `hello  world`.
- A stray `&` consumed up to ten following characters looking for `;`,
  swallowing a real entity behind it. The scan now stops at any character
  that cannot appear in an entity name.

* fix(documents): reject duplicate zip entries, stop self-closing tags corrupting reads and rows

Three more from the #7163 review.

- A duplicate zip entry name kept both names but only the last bytes, so
  `write()` emitted the same content under both and silently rewrote a
  package we were asked to preserve. Now rejected at read.
- `Event::Empty` latched `in_value`/`in_formula`. A self-closing `<v/>`
  has no matching `End`, so the flag stayed set and the NEXT cell's text
  was attributed to the empty one, corrupting every later value in the
  row.
- A self-closing `<row r="N"/>` target produced a SECOND row with the
  same `r`, which Excel repairs by dropping content. The existing empty
  row is now replaced in place.

* chore: retrigger Railway preview

* fix(documents): address review findings

* test(reborn): refresh read-file golden payloads

---------

Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-13 22:05:44 +00:00
..