Skip to content

rdocx: edit table cell paragraphs in place and keep the styles parse across edits (#437) - #443

Closed
hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:fix/437-edit-cost-cells-styles
Closed

hadim wants to merge 5 commits into
tensorbee:mainfrom
hadim:fix/437-edit-cost-cells-styles

Conversation

@hadim

@hadim hadim commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #437
Part of #445 (case 1; case 2 is left to upstream, diagnosis in the issue)

How long an edit takes now depends on what the edit changes, not on the size of styles.xml, the body, or the cell's place in the table. A setter on a table cell paragraph or one of its runs edits the paragraph in place, as the same setter on a body paragraph does. So filling a table cell by cell takes time linear in its cells. run.text = ..., try_replace_text, add_row and clone_row no longer parse and serialize the styles part on every call. The saved bytes do not change.

Following #445, the cell setters themselves no longer restage the package: vertical_alignment, shading, width, set_border and set_margins on a body table cell edit the cell in place (0.02 ms a call at 2000 body paragraphs, 13 ms before), and Cell.text and a cell paragraph's text cost what a body Paragraph.text costs (about 10 ms at 2000 paragraphs, 43 ms and 160 ms before).

Cause

  • Cases 2 and 3: ParagraphLocation::edit (crates/rdocx-py/src/paragraph.rs) sent every cell paragraph edit through Document::cell_paragraph_story_location. That function clones the document, serializes the whole body with to_xml, then walks the table-cell owners in order until it reaches the target. For each owner on the way, modeled_main_cell_route scans the whole body again. edit_story_paragraph then rewrote the paragraph through replace_story_content, which clones, flushes and reopens the staged package. One call cost about (cell's place) x (body size). F-X260 chose this route because Cell::paragraph_mut counts only direct paragraphs while the reader counts controlled ones too, so with a content control in the cell the two ordinals could name different paragraphs. Getting runs[0] on a cell paragraph paid the same lookup through ParagraphLocation::native in PyRun::bind_current.
  • Case 1: every staged edit cloned the CT_Styles model (clone_for_staging, several times per publication). Every flush_to_package serialized it with to_xml and parsed the styles part again to compare the two. A staged reopen (from_package_reusing) parsed the part once more. With a profiler on a release build with symbols, styles parse, serialization, clone and drop took about 88% of a run.text set with a 419 KB styles part.

Change

  • fix(rdocx-py): edit table cell paragraphs in place
    • ParagraphLocation::edit: when the cell holds no controlled paragraph, the reader's and the mutable ordinals agree. The edit then goes through table_mut(t).cell(r, c).paragraph_mut(p), as a body paragraph setter goes through paragraph_mut. The test is that the mutable accessor reaches the reader's last paragraph. A cell with a content control keeps the checked route of F-X260 unchanged.
    • PyRun::bind_current takes the paragraph route from PyDocument::paragraph_route, which reads the prepared index first and falls back to the native lookup, as paragraph handles already do.
    • CT_Body::tables (crates/rdocx-oxml/src/document.rs) is lazy over the body children, same order, so Document::table(0) stops at the first table instead of collecting every table of the body.
    • docs/hld/10-bindings-spec.md: one paragraph on the cell edit route.
  • perf(rdocx): keep the styles parse across edits
    • Document::styles is an Arc<CT_Styles>. A staged clone shares it, and style edits go through Arc::make_mut, which copies once when shared. The other changes in document.rs, epub.rs, field.rs, html.rs and odt.rs are those make_mut and Arc::new call sites, mostly in tests.
    • The document keeps styles_part_parse: the styles part bytes it last read with their parse, as two Arc values. styles_differ_from_part answers what parsing the part and comparing would answer. With the same part bytes (Arc::ptr_eq) it reuses the parse. With the same model Arc it compares two pointers. Otherwise it compares by value. A model equal to the part takes the parsed Arc. flush_to_package serializes the model only when it differs.
    • from_package_reusing gets the carried memo from reopen_staged and reuses the parse when the reopened styles part holds the same bytes.
    • OpcPackage::get_part_shared (crates/oxml-opc) returns the shared Arc of a part, found as get_part finds it, for that pointer proof.
    • docs/hld/03-architecture.md: one paragraph on the shared styles model.
  • record the archive measurements: rdocx, rdocx-oxml and oxml-opc only.
  • fix(rdocx-py): set table cell properties and text without restaging (rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445, case 1)
    • PyCell::edit (crates/rdocx-py/src/table.rs) sets a body table cell property in place, as a body paragraph property setter does: each of its edits validates before it changes w:tcPr. A cell of a table in another story keeps the staged candidate and adopt_property_edit.
    • A new cell has no w:tcPr, and that element comes first in w:tc, so writing it (or removing it) moves every item of the cell story by one. PyDocument::move_cell_story_items then moves the owners of that story one item and moves each to its new route bucket (WordOwnerIndexes::move_route). Debug builds check the result with assert_word_inventory_rebuilds. CellRef::has_properties (crates/rdocx/src/table.rs) tells whether the cell writes w:tcPr, through CT_TcPr::is_empty, now public.
    • Cell.text edits the cell's first paragraph in the candidate, as the story-table branch already did, without Document::try_set_cell_text's second clone, comment reconciliation (two flush_to_package) and staged reopen. Paragraph::set_text keeps every comment, bookmark and permission anchor, so the reconciliation had nothing to remove. Its table route comes from indexed_table_location before the native lookup.
    • Paragraph.text takes its route from paragraph_route, as Run does above.
    • docs/hld/10-bindings-spec.md: one paragraph on the cell property route.
  • record the archive measurements: rdocx and rdocx-oxml.

Limits

  • flush_to_package no longer calls CT_Styles::to_xml when the model equals the part, so a model whose serialization fails while it equals its own part no longer fails the flush. I could not build such a model: a model parsed from bytes serializes.
  • The checked cell route remains for a cell that holds a content control, so that case keeps the old cost.
  • Getting a cell or a table handle still walks the body children up to the table (CT_Body::tables), about a nanosecond per child. A body paragraph handle walks the same way.
  • Cell.text and Paragraph.text still grow with the body, linearly: what remains is the revision publication every text setter shares (clone_for_staging, WordPieces::assemble, the owner indexes). rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445 keeps that open with the binding's open + replace cost, which has the same cause.
  • The first property edit of a new cell scans the owners for its story (about 0.02 ms at 2000 body paragraphs). Later edits of that cell move nothing.
  • The last check of the issue's case 2 script times the setter three times on a fresh 2000-paragraph document against a document already warmed by the earlier checks. On this branch both are a few microseconds, so the first call on the fresh document (about 30 us, the same with 0, 2000 or 8000 body paragraphs) weighs on the ratio. It reads x2.6 to x4.1 across runs. With one untimed call on each document first, it reads x1.2 to x1.4 on this branch and x6.0 to x6.6 on 24af95d.

Tests

  • crates/rdocx-py/tests/test_core.py test_issue437_cell_paragraph_setters_do_not_restage_the_package: in cells of rows 0 and 19 of a 20 x 3 table, with 0 and 200 body paragraphs after it, alignment, style, paragraph_format.space_after and runs[0].font.size do no staging work, no reopen work and no main-body scan (the debug counters _staging_work, _reopen_work, _main_body_block_scans). The values survive save and reopen, and the neighbour cell is untouched. Without the in-place edit it fails with [0, 0, 0, 0, 4, 4] == [0, 0, 0, 0, 0, 0]. test_issue398_controlled_cell_edit_selects_read_owner still covers the content-control route.
  • crates/rdocx-py/tests/test_core.py test_issue437_cell_property_setters_do_not_grow_with_the_body (vertical_alignment, shading, width): per call, at most 1.5 times the empty-body cost plus 0.5 ms with 400 body paragraphs, and at most 3 times Paragraph.alignment in the same body plus 0.2 ms. On b3be9b6 (this PR before rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445) it fails with Cell.vertical_alignment took 2.27 ms per call against 0.00 ms for Paragraph.alignment in the same body. Release builds only: debug builds rebuild the owner inventory after each first w:tcPr.
  • crates/rdocx-py/tests/test_core.py test_issue437_cell_text_costs_what_body_paragraph_text_costs: with 400 body paragraphs, Cell.text and a cell Paragraph.text cost at most 1.5 times a body Paragraph.text plus 2 ms. On b3be9b6 it fails with Cell.text took 5.75 ms per call against 1.57 ms for a body Paragraph.text. Debug builds check the work counters instead (no reopen, no more main-body scans than a body Paragraph.text). test_issue388_* and test_issue398_* cover the live owners across the moved cell routes.
  • crates/rdocx/src/document.rs issue437_edits_keep_the_styles_parse_and_the_styles_part: on a document opened from bytes, try_replace_text, add_empty_table_row and to_bytes keep the same styles model Arc and the same styles part bytes. A style added afterwards is in the saved part. Without the reuse in from_package_reusing it fails with add_empty_table_row parsed the styles again.
  • Runs on linux-x86_64:
    • cargo test -p oxml-opc: 72 passed. -p rdocx-oxml: 761 passed.
    • cargo test -p rdocx (debug, RUST_MIN_STACK=8388608): --lib 823 passed, 1 failed, 13 ignored. The failure is fx195_variable_svg_paint_matches_independent_static_instances, which needs hb-subset, not installed here. --test regression_test: 2469 passed, 7 ignored. --test integration_test: 466 passed, 5 failed, 8 ignored. Four of the failures are LibreOffice oracles pinned to 26.2.5.2 against this machine's 26.2.6.3 (every_conditional_table_region_matches_word, section_page_semantics_match_pinned_libreoffice_render, odt_reader_matches_pinned_libreoffice_structure, public_authored_theme_and_fonts_match_pinned_word_resolution). The fifth, cross_consumer_render_fallback_before_empty_assignment, runs target/debug/rdocx and passes once rdocx-cli is built.
    • cargo clippy -p oxml-opc -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, debug maturin build, with Pillow 12.3.0 and python-docx 1.2.0 as in CI: test_core.py, test_formatting_tables.py, test_python_docx_parity.py, test_shared.py and test_rendering_threads.py with -n 8: 1157 passed, 2 skipped, 62 failed. 57 of the failures are fx195 tests in test_rendering_threads.py that need the F_X195_* oracle environment. The other five fail the same way on a debug build of 24af95d: test_fx268_picture_normalization_reuses_actual_checked_rounds (4, KeyError: 'producer.seed_eligible'), test_fx200_python_style_saved_output_paints_exact_expected_colours_with_deterministic_native_cli (needs a coordinator-authenticated RDOCX_CLI), and test_fx195_word_empty_fonts_preserve_bundled_measurement_fallback.
    • scripts/hash_harness.py --check: 49 entries match after each commit.
    • scripts/readme_doctests.py passes after re-recording the rdocx, rdocx-oxml and oxml-opc archives, in the last commit.
    • After the rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445 commit: the same Python run gives 1157 passed, 6 skipped and the same 62 environment failures. cargo clippy -p rdocx-oxml -p rdocx -p rdocx-py --all-targets --all-features -- -D warnings, cargo fmt --all --check, scripts/prose_check.py and scripts/hash_harness.py --check (49 entries match) are clean.

Repro

The issue's three scripts on a release build of this branch:

styles.xml: 6466 bytes, then 419136 bytes
PASS run.text = ...: 0.9 ms, then 0.9 ms a call (x1.0)
PASS try_replace_text: 0.2 ms, then 0.2 ms a call (x0.9)
PASS table.add_row(): 3.2 ms, then 3.0 ms a call (x1.0)
PASS table.clone_row(1): 4.6 ms, then 6.1 ms a call (x1.3)
PASS cell paragraph alignment: 0.0 ms, then 0.0 ms a call (x0.5)
body paragraph alignment: 0.00 ms a call
PASS paragraph.alignment in cell (39, 5) against cell (0, 5): 0.0 ms, then 0.0 ms (x0.4)
PASS paragraph.style in cell (39, 5) against cell (0, 5): 0.0 ms, then 0.0 ms (x0.5)
PASS runs[0].font.size in cell (39, 5) against cell (0, 5): 0.8 ms, then 0.7 ms (x1.0)
PASS paragraph.alignment in cell (0, 5) with 2000 body paragraphs against none: 0.0 ms, then 0.0 ms (x3.0)
 30 cells:   0.0 ms per call,  0.00 s for the table
120 cells:   0.0 ms per call,  0.00 s for the table
PASS time per call is flat: x0.7 from 30 to 120 cells

#445 case 1 on a local release build of this branch (no LTO):

FAIL Cell.text: 3.46 ms at 0, 5.30 ms at 500, 12.75 ms at 2000 paragraphs (x3.7)
PASS Cell.vertical_alignment: 0.00 ms at 0, 0.01 ms at 500, 0.02 ms at 2000 paragraphs (x13.4)
PASS Cell.shading: 0.00 ms at 0, 0.00 ms at 500, 0.01 ms at 2000 paragraphs (x22.2)

On 24af95d the same scripts give x12 to x54 for case 1, x12 to x17 by row and x8.5 by body for case 2, and 3.5 ms then 15.4 ms per call (x4.3) for case 3. See Limits for the last line of case 2.

hadim added 3 commits October 31, 2026 17:38
A setter on a table cell paragraph or its run (alignment, style,
paragraph_format, font) resolved the checked physical route of the cell
on every call: clone the document, serialize the whole body, walk every
cell owner before the target and rescan the body for each one, then
rewrite the paragraph through a staged reopen. Its cost grew with the
cell's place in the table and with the body, so filling a table cell by
cell was quadratic.

When the cell holds no content control, the mutable cell ordinal and the
reader's ordinal name the same paragraph, so the edit now goes through
the modeled paragraph in place, as a body paragraph setter does. A cell
with a content control keeps the checked route. A run handle takes its
paragraph route from the prepared index before the native lookup, and
CT_Body::tables is lazy, so selecting an early table stops there.

Part of tensorbee#437.
Every staged edit cloned the styles model, serialized it and parsed the
styles part again to compare the two, and a staged reopen parsed the
part once more. Each edit then cost time in proportion to styles.xml,
about 40 ms with a 419 KB styles part where the same edit takes 1.5 ms
with rdocx's own 6 KB part.

The styles model is now one Arc<CT_Styles>, so a staged clone shares
it, and style edits copy it once through Arc::make_mut. The document
keeps the styles part bytes it last read with their parse. A flush that
finds the same part bytes and the same model Arc compares two pointers
and writes nothing. A model that differs is compared by value and
serialized when it really changed, as before. A staged reopen whose
styles part keeps those bytes takes the kept parse.
OpcPackage::get_part_shared returns the shared part bytes for that
proof.

Part of tensorbee#437.
The rdocx, rdocx-oxml and oxml-opc archives change with the cell edit
and styles parse changes and their tests, on main 24af95d. Their
measurement date stays as recorded.

Tests, python3 scripts/readme_doctests.py
Harness, unchanged, measurements only
hadim added a commit to hadim/rdocx that referenced this pull request Oct 31, 2026
hadim added a commit to hadim/rdocx-skills that referenced this pull request Oct 31, 2026
SectionHandle.replace_xml, Document.remove_endnote and Document.set_picture_size
went over RATIO on the macOS runner (2.8 to 3.3 times), and
Document.set_content_control_lock on Windows (3.0 times). All four were linear on
v0.16.4 too and are cheaper on this pin: tensorbee/rdocx#443 cut their fixed part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hadim added 2 commits November 1, 2026 09:03
A cell property setter (vertical_alignment, shading, width, set_border,
set_margins) cloned the document, edited the clone and adopted it,
assembling the pieces again and rebuilding every owner location: about
4 ms per call in an empty document, growing with the body. Cell.text on a
body table cloned the document a second time, reconciled comment removal
through two package flushes and reopened the staged package before its
publication, and took the cell route from the physical walk: 43 ms per
call at 2000 body paragraphs. Paragraph.text on a cell paragraph took its
route from the physical walk too, 160 ms per call at 2000 paragraphs.

A body table cell property is now set in place, as a body paragraph
property is, since each edit validates before it changes w:tcPr. Only
writing the cell's first w:tcPr (or removing it) moves the cell's items,
as that element comes first: the owners of that cell story then move one
item and change route bucket, and debug builds check the moved inventory
against a rebuild. CellRef::has_properties tells whether the cell writes
w:tcPr. A cell of a table in another story keeps the staged candidate.

Cell.text edits the first paragraph in the candidate, as the story table
branch already did (Paragraph::set_text keeps every comment, bookmark and
permission anchor), and takes its table route from the owner index.
Paragraph.text takes its route from paragraph_route, as Run does.

At 2000 body paragraphs a property setter costs 0.02 ms, and Cell.text
and a cell Paragraph.text cost what a body Paragraph.text costs (about
10 ms). That remaining cost is the revision publication every text setter
shares.

Part of tensorbee#437.
The rdocx and rdocx-oxml archives change with the cell setter change and
its tests. Their measurement date stays as recorded.

Tests, python3 scripts/readme_doctests.py
Harness, unchanged, measurements only
@mantissaman

Copy link
Copy Markdown
Contributor

Released in rpptx-v0.15.0 (tag), v0.17.0 (tag).

The releases include adapted accessor and edit-cost work, including the whole-cell and Python open/replace/save follow-up. This is one contribution record covering those distinct paths. Refusal, saved-byte and held-handle contracts remain in force. Shared get_part_shared is additive and does not establish a measured PowerPoint speedup.

Issues 437 and 445 stay open. Original numerical criteria and unfinished memory/RSS, retention-history and calibration qualification are carried to the S96.1 review and optimization work. No final 79/79, 6 MiB or 68 MiB acceptance claim is made.

Thank you @hadim for contributing the reference implementation.

@mantissaman

Copy link
Copy Markdown
Contributor

Thank you @hadim for the style-sharing and cell-edit reference implementation and the #445 follow-up diagnosis. Adapted checked publication paths are integrated in F-X282 contract and evidence and F-X283 contract and evidence at eb980e94, available in Word 0.17.0. Functional source-preservation, held-handle, refusal and setter checks passed with successful integrated CI.

Closing this PR as superseded, not merged unchanged. #437 and #445 remain open: their original timing ratios and unfinished memory/RSS/history qualification are carried to S96.1 F-X285/F-X286 after F-X284's review. This closure does not claim every numerical target passed.

@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 Python: the cost of an edit grows with styles.xml, the body and the cell's place in the table (filling a table is quadratic)

2 participants