Skip to content

rdocx: write schema-valid, Word-like markup for equations, notes, links, cells, numbering, TOC and replace (#432) - #441

Closed
hadim wants to merge 11 commits into
tensorbee:mainfrom
hadim:fix/432-docx-written-markup
Closed

hadim wants to merge 11 commits into
tensorbee:mainfrom
hadim:fix/432-docx-written-markup

Conversation

@hadim

@hadim hadim commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #432

This PR fixes the eight cases of #432. The markup rdocx writes now matches what the schema requires or what Word writes, and each case script prints only PASS on this branch.

  • A square root from add_equation writes an empty m:deg.
  • In the number run of a footnote or endnote, w:rPr comes before w:footnoteRef / w:endnoteRef, and a space run follows it. A new notes part starts with the separator and continuation separator notes.
  • Links written by rdocx take the Hyperlink character style. The built-in style is added when the document has none.
  • Cell.merge does not carry the lone empty paragraph of an empty cell into the merged cell.
  • Cells made by insert_column and add_row copy the paragraph formatting and the paragraph-mark formatting of their source cell.
  • The ids that add_numbering_definition and add_numbering_instance return still name the same definition or instance after a footnote, a picture or any other edit that republishes the package.
  • rebuild_toc() fills a TOC that insert_toc() wrote before the document had headings.
  • replace_text, try_replace_text and rdocx replace leave chart labels alone, so their counts match rdocx text.

Cause

  • Case 1: MathRadical::new (crates/rdocx-oxml/src/math.rs) set degree_present: false, so write_xml emitted m:deg only for an n-th root. CT_Rad is radPr?, deg, e (ECMA-376 Part 1, 22.1.2.88).
  • Case 2: create_footnote_at and create_endnote_at (crates/rdocx/src/document.rs) stored the reference mark as a raw run child at boundary 0. CT_R::to_xml writes that boundary before the typed w:rPr. The note text followed the mark directly, and a new notes part held only the real notes.
  • Case 3: Paragraph::add_hyperlink and add_internal_hyperlink create a plain run. None of the callers that write a link for the user gave it a style: append_internal_hyperlink_staged, add_hyperlink_to_story, the HTML and Markdown importer, and the binding's Paragraph.add_hyperlink.
  • Case 4: Table::merge_cells (crates/rdocx/src/table.rs) concatenated the content of every merged cell, including the empty paragraph of an empty cell. The case 4 script in rdocx: markup the library writes breaks the schema or differs from Word (equations, notes, hyperlinks, cells, numbering, TOC, replace) #432 read <w:p/> and the paragraph after it as one paragraph. That hid the "first cell empty" case, which also kept an empty paragraph (['', 'Sum']). This PR fixes both orders, and the Tests section uses a corrected script.
  • Case 5: formatted_empty_cell copied w:tcPr only, and the new cell held a bare CT_P::new().
  • Case 6: canonicalize_numbering_ids ran in prepare_staged_package and renumbered authored definitions and instances by first use in the body. Saving works on a clone, so the live ids stayed as returned. An edit that publishes its reopened candidate (create_footnote, add_picture and others) then committed the renumbered model, and a held definition id named the other definition.
  • Case 7: with no heading, insert_toc_in_place put the begin, separate and end marks in the title paragraph. rebuild_toc replaces the result with whole paragraphs and refuses a result that stays inside one paragraph. The regression tests pin that refusal (toc_rebuild_accepts_a_single_paragraph_toc and two tests on opaque entry owners), so this PR changes what insert_toc writes and leaves the refusal in place.
  • Case 8: replace_in_xml_parts ran replace_many_in_chart_xml on every chart part. That rewrote only the chart cache, so the cache and the embedded workbook disagreed. The count also included labels that rdocx text does not show.

Change

One commit per case, each with its test, then the archive measurements. A follow-up commit for the grid span and its own measurements commit come after.

  • math.rs: MathRadical::new sets degree_present: true, so a square root writes an empty m:deg after m:radPr. Radicals read from a file keep what they had.
  • document.rs, notes: the reference mark moves to raw boundary 1, after w:rPr, and a " " run follows it, as Word writes. When the notes stream has no entry yet, word_note_separators adds the separator and continuation separator at ids -1 and 0, the ids Word 2010 and later write. They carry Word's w:spacing w:after="0" w:line="240" w:lineRule="auto". Note ids still start at 2, so no id changes, and a notes part that already has entries is left as it is.
  • document.rs, links: the new public Document::ensure_hyperlink_style() returns the id of the character style with the id Hyperlink, or else the one named Hyperlink. When neither exists, it adds Word's built-in definition (colour 0563C1, single underline, priority 99, unhideWhenUsed).
    • The callers that set this style on the link run: append_internal_hyperlink_staged (used by add_internal_hyperlink_at and add_heading_hyperlink_at), add_hyperlink_to_story, the HTML and Markdown importer, and the binding's Paragraph.add_hyperlink.
    • An importer run that already has a character style, such as inline code, keeps it.
    • The low-level Paragraph::add_hyperlink and add_internal_hyperlink are unchanged.
  • table.rs, merge: an empty cell adds nothing to the merged content. "Empty" means one paragraph without content, ignoring its properties and its root attributes. When every merged cell is empty, the first cell's paragraph stays.
  • table.rs, grid span (follow-up to case 4): set_cell_grid_span and the row omissions (w:gridBefore / w:gridAfter) treated a cell as empty only when its paragraph had no properties at all. They refused an empty cell that Word leaves in a right-aligned column, <w:p><w:pPr><w:jc w:val="right"/></w:pPr></w:p>, with "horizontal merge cannot consume a nonempty cell". This failed on main too. cell_is_discardable_for_grid_edit now uses the same emptiness test as merge_cells: one paragraph without content, whatever its paragraph properties and its mark's run properties. The first cell's paragraph stays, and a cell holding text is still refused.
  • table.rs, new cells: new_cell_paragraph_properties gives the new paragraph the w:pPr of the source cell's last paragraph, without list numbering, section properties and tracked changes. The paragraph mark takes the source mark's run properties. If the source mark has none, it takes those of the source paragraph's last run, the formatting that text typed at its end would continue. This covers add_row, insert_column and the cells split_cell adds.
  • document.rs, numbering: canonicalize_numbering_ids now runs in prepare_staged_output only, on the staged copy that is saved, serialized or read through part_xml. Saved packages are unchanged, so equivalent construction orders still save identical packages (equivalent_construction_orders_allocate_declared_stable_identifiers passes). The live model keeps the ids the facade returned.
  • document.rs, TOC: with no heading, insert_toc adds an empty paragraph after the title to hold the end mark. This is the shape it already writes when there are headings.
  • document.rs, replace: the chart pass is removed from replace_in_xml_parts, and the replace_text and replace_regex docs say so. In docs/hld/10-bindings-spec.md, the two paragraphs on the literal replacement scope are updated.

Behaviour changes

  • The text of a note that rdocx creates now starts with the space run, as for a note Word writes. Document::footnotes() and the binding's note.paragraphs[0].text read " note content". Three Rust tests and three Python tests that read note text back are updated for it.
  • Five regression fixtures patched the first entry of footnotes.xml, which is now the separator. They now patch the first normal note, through a small replace_in_first_note helper.
  • Literal replacement no longer reaches chart labels. To rename a chart category, change the chart data, which updates the cache and the workbook together.

Limits

  • Notes: settings.xml does not get the w:footnotePr / w:endnotePr lists that Word writes for the separators. The schema makes them optional, and Word and LibreOffice find the separators by w:type. As before, the note paragraph does not take the FootnoteText or EndnoteText style.
  • Links: a link added with the low-level Rust Paragraph::add_hyperlink stays unstyled unless the caller sets the id from ensure_hyperlink_style() on the returned run.
  • New cells: list numbering is not copied, so a new row under a numbered cell is not numbered. Word copies it.
  • TOC: rebuild_toc still refuses a single-paragraph TOC field written by another producer, as on main. Word writes one when it found no entries.
  • I could not run Word here. The Word behaviour cited comes from the issue and from documents Word wrote.

Tests

  • crates/rdocx/tests/regression_test.rs, one test per case. Each fails on main:
    • issue432_a_square_root_equation_writes_the_degree_its_schema_requires: LaTeX \sqrt, \sqrt[3] and MathML msqrt.
    • issue432_notes_write_the_mark_run_properties_first_with_separators_and_a_space: both parts, and a second note adds no separator.
    • issue432_links_written_by_rdocx_take_the_hyperlink_character_style: a heading link (the style is added), Markdown with a template whose Hyperlink style is kept, HTML, and a story link.
    • issue432_merging_with_an_empty_cell_keeps_no_blank_paragraph: five cases, including both cells empty.
    • issue432_a_cell_holding_only_paragraph_properties_merges_as_an_empty_cell: set_cell_grid_span_checked absorbs a cell whose paragraph carries only w:jc and a mark w:b, and still refuses a cell with text. merge_cells drops that cell horizontally and vertically.
    • issue432_cells_added_by_insert_column_and_add_row_keep_paragraph_formatting.
    • issue432_a_numbering_definition_id_names_its_definition_after_a_republishing_edit: the live ids, and the list format of each paragraph in the saved package.
    • issue432_a_toc_inserted_before_any_heading_rebuilds_once_headings_exist.
    • issue432_literal_replacement_leaves_chart_labels_to_their_workbook.
  • crates/rdocx-py/tests/test_core.py:
    • test_issue432_python_links_take_the_hyperlink_character_style covers the binding's own Paragraph.add_hyperlink path.
    • test_issue432_a_cell_with_only_paragraph_properties_merges_as_empty runs the follow-up repro through Table.set_cell_grid_span and through Cell.merge, horizontally and vertically, and checks that a cell with text is still refused.
  • After the follow-up commit: the table, grid, span, merge and row tests of regression_test (191 passed), the rdocx --lib table tests (52 passed), the matching Python tests in test_core.py and test_formatting_tables.py, clippy, fmt, prose and the hash harness all pass. In Python, 493 passed. The 3 failures are fx200, which needs RDOCX_CLI, and two fx268 cases that also fail on a debug build of main. The harness gives 49 entries matching, and the last commit re-records only the rdocx archive row.
  • Runs on linux-x86_64, debug, RUST_MIN_STACK=8388608:
    • cargo test -p rdocx-oxml: 760 passed. -p rdocx-layout: 355 passed, 1 ignored.
    • cargo test -p rdocx --test regression_test: 2477 passed, 7 ignored.
    • --test integration_test: 467 passed, 4 failed. All four fail on this machine's LibreOffice 26.2.6.3, against the pinned 26.2.5.2.
    • --lib: 821 passed, 1 failed. The failure is fx195_variable_svg_paint_matches_independent_static_instances, which needs hb-subset 14.4.0. body_lookup_work_is_linear_at_250_and_1000 was skipped, as it runs for more than half an hour here.
    • cargo clippy -p rdocx-oxml -p rdocx -p rdocx-py --all-targets -- -D warnings, cargo fmt --all --check and scripts/prose_check.py: clean.
    • Python 3.13 on a maturin debug build, -n 8, with python-docx 1.2.0, over test_core.py, test_formatting_tables.py, test_python_docx_parity.py, test_shared.py and test_rendering_threads.py: 1154 passed, 2 skipped, 62 failed. None of the failures comes from this branch:
      • 57 are fx195 tests that need the F_X195_* oracle environment.
      • test_fx200_python_style_saved_output_paints_exact_expected_colours_with_deterministic_native_cli needs the coordinator-authenticated RDOCX_CLI.
      • The four test_fx268_picture_normalization_reuses_actual_checked_rounds cases fail on a debug build of main as well (KeyError: 'producer.seed_eligible').
  • scripts/hash_harness.py --check: 49 entries match after every commit. The one intentional delta is in the TOC commit, which also updates the baseline:
    • feature_showcase and report call insert_toc before they add their headings, so their word/document.xml gains the empty paragraph and their pdf/bytes entry changes with it.
    • Their pdf/pages and pdf/resources entries and their page-one PNG are unchanged, since the paragraph draws nothing.
  • scripts/readme_doctests.py --record-measurements in the last commit: only the rdocx and rdocx-oxml archive rows change.
  • The eight case scripts of rdocx: markup the library writes breaks the schema or differs from Word (equations, notes, hyperlinks, cells, numbering, TOC, replace) #432, on a debug build of this branch (binding and CLI), print PASS on every line. The case 4 script reads cell.paragraphs instead of the regular expression, as explained under Cause.

hadim added 9 commits October 31, 2026 16:41
MathRadical::new left degree_present unset, so a square root from add_equation, from LaTeX
\sqrt{x} or MathML <msqrt>, wrote an m:rad without m:deg. CT_Rad requires it (ECMA-376
Part 1, 22.1.2.88), and Word writes it empty. A radical read from a file keeps what it had.

Part of tensorbee#432, case 1.
…d a space

create_footnote_at and create_endnote_at stored w:footnoteRef / w:endnoteRef as a raw run
child at boundary 0, which the run writer puts before w:rPr, so footnotes.xml and
endnotes.xml broke CT_R. The mark now sits after w:rPr and a space run follows it, as Word
writes. A notes stream with no entry yet starts with the separator and continuation
separator notes at ids -1 and 0, as Word 2010 and later write them. Note ids still start at
2, and a notes part that already has entries is left as it is.

The text of a note rdocx creates now starts with that space, so tests reading it back, and
fixtures that patched the first entry of the notes part, are updated.

Part of tensorbee#432, case 2.
…tyle

Links written for the caller had no run properties, so they read as plain text in Word and
LibreOffice. Document::ensure_hyperlink_style returns the character style with the id
Hyperlink, or else named Hyperlink, and adds Word's built-in definition (colour 0563C1,
single underline) when neither exists. Internal and heading links, add_hyperlink_to_story,
the HTML and Markdown importer and the binding's Paragraph.add_hyperlink set it on the link
run. The low-level Paragraph::add_hyperlink is unchanged.

Part of tensorbee#432, case 3.
merge_cells concatenated the content of every merged cell, so the lone empty paragraph of an
empty cell stayed as a blank line above or below the text. As Word does, an empty cell now
adds nothing. When every merged cell is empty, the first cell's paragraph stays.

Part of tensorbee#432, case 4.
…rt_column make

formatted_empty_cell copied w:tcPr only, so text set into a new cell lost the alignment and
the bold of its row or column. The new paragraph now takes the w:pPr of the source cell's
last paragraph, without list numbering and tracked changes, and its mark takes the mark's
run properties, or else those of the paragraph's last run, as Word's Insert Row and Insert
Column do. The cells split_cell adds follow the same rule.

Part of tensorbee#432, case 5.
prepare_staged_package renumbered authored definitions and instances by first use. A
mutation that publishes its reopened candidate, such as create_footnote or add_picture,
then committed the renumbered model, and an id add_numbering_definition had returned named
the other definition, so a later bullet list came out numbered. The renumbering now runs in
prepare_staged_output only, on the staged copy that is written, so equivalent construction
orders still save identical packages and the live ids stay as returned.

Part of tensorbee#432, case 6.
With no heading yet, insert_toc wrote the begin, separate and end marks in the title
paragraph, and rebuild_toc, which replaces the result with whole paragraphs, refused it once
headings existed. An empty paragraph after the title now holds the end mark, the shape
insert_toc writes when there are headings.

Harness: feature_showcase and report insert their TOC before their headings. Their
word/document.xml gains that empty paragraph and their pdf/bytes entry changes with it. The
pdf/pages and pdf/resources entries and the page-one PNG are unchanged, as the paragraph draws
nothing. The baseline is updated in this commit for these four entries.

Part of tensorbee#432, case 7.
replace_text, try_replace_text and rdocx replace also rewrote the strings of each chart's
cache, which rdocx text does not show, so their count differed from the text and an
--expect taken from it was refused. The cache then disagreed with the embedded workbook,
which kept the old label. Literal replacement now searches what regex replacement already
searched, and chart data stays with replace_chart_data.

Part of tensorbee#432, case 8.
The rdocx and rdocx-oxml archives change with tensorbee#432. The other crates keep their rows.
hadim added a commit to hadim/rdocx that referenced this pull request Oct 31, 2026
hadim added 2 commits October 31, 2026 22:10
…roperties

set_cell_grid_span, and the row omissions that share its check, took a cell as empty only
when its paragraph equalled a bare CT_P, so an empty cell Word leaves in a right-aligned
column, <w:p><w:pPr><w:jc w:val="right"/></w:pPr></w:p>, was refused with "horizontal merge
cannot consume a nonempty cell". The check now uses the emptiness merge_cells applies since
case 4: one paragraph without content, whatever its paragraph properties and its mark's run
properties. The first cell's paragraph stays. A cell holding text is still refused.

Part of tensorbee#432, case 4.
The rdocx archive changes with the grid span fix. The other crates keep their rows.
hadim added a commit to hadim/rdocx that referenced this pull request Oct 31, 2026
The rdocx archive with the grid span follow-up of tensorbee#441 on top of the integration.

Tests, python3 scripts/readme_doctests.py --record-measurements
Harness, unchanged, measurements only
hadim added a commit to hadim/rdocx-skills that referenced this pull request Oct 31, 2026
…n empty cell with properties)

The absorbed cell of set_cell_grid_span or Cell.merge may hold one empty
paragraph that carries properties, as Word leaves in an empty cell of a
right-aligned column or as insert_column now copies them: rdocx refused it
("horizontal merge cannot consume a nonempty cell", also on v0.16.4). Carried
as a test of tensorbee/rdocx#441, failing on e00c8f3c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mantissaman

Copy link
Copy Markdown
Contributor

Released in v0.17.0 (tag).

The Word release includes adapted markup/import repairs from this reference work. It preserves schema-valid radical order, authored note markers and special records, supported hyperlink/numbering identities, new-cell paragraph-mark formatting. TOC insertion order and chart-cache/workbook text-replacement exclusions are also covered under the documented bounded contracts.

Thank you @hadim for contributing the reference implementation.

@mantissaman

Copy link
Copy Markdown
Contributor

Thank you @hadim for the schema/authoring reference work. All eight #432 cases were integrated through F-X277 contract and evidence and its children, with ordered notes and radicals, stable numbering identities, preserved table paragraph formatting, corrected TOC lifecycle and visible-text replacement boundaries. The installed consumers, native/schema checks and successful integrated CI qualified the integrated result.

Closing this PR as superseded by eb980e94 in Word 0.17.0, not merged unchanged. Imported unsupported omissions keep their preservation contracts rather than being normalized during unrelated edits.

@mantissaman mantissaman closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rdocx: markup the library writes breaks the schema or differs from Word (equations, notes, hyperlinks, cells, numbering, TOC, replace)

2 participants