Skip to content

rdocx Python: cell setters still grow with the body after #443, and open + try_replace_text costs twice the CLI #445

Open

Description

@hadim

Follows #437 and #443. Two Python costs that a benchmark of long generated reports still finds after #443, which made the cell paragraph setters flat but not the cell setters below. Measured on main 24af95d (v0.16.4) and on hadim/rdocx integration/open-prs-2026-10-31 b3be9b6 (v0.16.4 plus #427, #428, #438 to #444), release wheels and CLI, Linux x86_64: the same numbers on both.

Case 1: Cell.text, Cell.vertical_alignment and Cell.shading cost grows with the body

Each call costs 4 to 5 ms in an empty document and grows with the paragraphs of the body, so filling the tables of a long document is quadratic. A 52-page report with 18 tables (about 1,000 cells, each given a text, an alignment and sometimes a fill) takes about 34 s to build, 27 s of it in these setters, against 0.3 s with docx-js for the same content.

"""Case 1: Cell.text, Cell.vertical_alignment and Cell.shading cost grows with the body.

Builds its own input: a body of N paragraphs, then one 20 x 5 table whose 100 cells are set.
PASS when each setter costs under 1 ms per call at 2000 paragraphs and at most 1.5 times its empty-body cost
(plus 0.05 ms, as timings that small are noise).
"""
import platform
from importlib.metadata import version
import sys
import time

import rdocx

print(f"rdocx {version('rdocx')}, Python {platform.python_version()}, {platform.system()} {platform.machine()}")
SETTERS = {
    "Cell.text": lambda cell: setattr(cell, "text", "12.50"),
    "Cell.vertical_alignment": lambda cell: setattr(
        cell, "vertical_alignment", rdocx.WD_CELL_VERTICAL_ALIGNMENT.CENTER
    ),
    "Cell.shading": lambda cell: setattr(cell, "shading", "D9E2F3"),
}
costs = {name: {} for name in SETTERS}
for body in (0, 500, 2000):
    doc = rdocx.Document()
    for index in range(body):
        doc.add_paragraph(f"Body paragraph {index} with some text in it to look like a report.")
    table = doc.add_table(20, 5)
    cells = [table.cell(row, column) for row in range(20) for column in range(5)]
    for name, setter in SETTERS.items():
        started = time.perf_counter()
        for cell in cells:
            setter(cell)
        costs[name][body] = 1000 * (time.perf_counter() - started) / len(cells)
failed = False
for name, cost in costs.items():
    ok = cost[2000] < 1.0 and cost[2000] <= 1.5 * cost[0] + 0.05
    failed |= not ok
    print(f"{'PASS' if ok else 'FAIL'} {name}: "
          + ", ".join(f"{cost[body]:.2f} ms at {body}" for body in cost)
          + f" paragraphs (x{cost[2000] / cost[0]:.1f})")
sys.exit(1 if failed else 0)

Output on b3be9b6 (24af95d within 15 %):

rdocx 0.16.4, Python 3.15.0, Linux x86_64
FAIL Cell.text: 4.79 ms at 0, 11.83 ms at 500, 43.17 ms at 2000 paragraphs (x9.0)
FAIL Cell.vertical_alignment: 4.15 ms at 0, 5.50 ms at 500, 13.23 ms at 2000 paragraphs (x3.2)
FAIL Cell.shading: 4.80 ms at 0, 6.16 ms at 500, 14.10 ms at 2000 paragraphs (x2.9)

Acceptance: the three lines print PASS.

Where the time goes:

  • vertical_alignment and shading (and width, set_border, set_margins) go through PyCell::edit, which clones the whole document, edits the clone and calls adopt_property_edit, which assembles the pieces again and rebuilds every owner location. A paragraph property setter edits the paragraph in place since rdocx: edit table cell paragraphs in place and keep the styles parse across edits (#437) #443.
  • Cell.text on a body table calls Document::try_set_cell_text, which clones the document a second time, runs reconcile_comment_removal (two flush_to_package) and prepare_and_reopen_staged, before the CellText publication. The cell route for that publication comes from TableLocation::native, not from the prepared index.
  • Found while measuring: Paragraph.text on a cell paragraph takes its ParagraphText route from ParagraphLocation::native, the checked physical walk, so it costs 160 ms per call at 2000 paragraphs (10 ms for a body paragraph).

Case 2: Document.open + try_replace_text + save in Python cost about twice rdocx replace

Agents use the binding for counted replacements, so this is the cost they pay. On synthetic reports with tables, footnotes, comments and tracked changes the gap is 3 to 4 times: Document.open + try_replace_text take 0.8 + 1.6 s for 111 replacements in a 52-page report (the CLI 0.67 s in all), and 3.6 + 7.8 s for 352 replacements in a 198-page report (the CLI 2.8 s).

"""Case 2: Document.open + try_replace_text + save in Python costs about twice `rdocx replace`.

Builds its own input: 4000 paragraphs of three runs each, "water" in every fourth one.
Run with RDOCX_CLI set to the rdocx CLI of the same build.
PASS when the Python total (open, replace, save) is at most 1.2 times the CLI's time, process start included.
"""
import os
import platform
from importlib.metadata import version
import subprocess
import sys
import tempfile
import time

import rdocx

print(f"rdocx {version('rdocx')}, Python {platform.python_version()}, {platform.system()} {platform.machine()}")
cli = os.environ.get("RDOCX_CLI", "rdocx")
with tempfile.TemporaryDirectory() as folder:
    doc = rdocx.Document()
    for index in range(4000):
        paragraph = doc.add_paragraph()
        paragraph.add_run(f"Section {index}: ").font.bold = True
        paragraph.add_run("the plant treats " + ("water" if index % 4 == 0 else "air") + " for the town, ")
        paragraph.add_run("as the report explains.").font.italic = True
    source = os.path.join(folder, "in.docx")
    doc.save(source)
    python, command = [], []
    for trial in range(3):
        t0 = time.perf_counter()
        doc = rdocx.Document.open(source)
        t1 = time.perf_counter()
        count = doc.try_replace_text("water", "WATER")
        t2 = time.perf_counter()
        doc.save(os.path.join(folder, f"py{trial}.docx"))
        t3 = time.perf_counter()
        python.append((t3 - t0, t1 - t0, t2 - t1, t3 - t2))
        started = time.perf_counter()
        subprocess.run([cli, "replace", source, "-p", "water", "-v", "WATER",
                        "-o", os.path.join(folder, f"cli{trial}.docx")], check=True, capture_output=True)
        command.append(time.perf_counter() - started)
total, opened, replaced, saved = min(python)
best_cli = min(command)
ok = total <= 1.2 * best_cli
print(f"{'PASS' if ok else 'FAIL'} {count} replaced: Python open {opened:.2f} s + replace {replaced:.2f} s "
      f"+ save {saved:.2f} s = {total:.2f} s; CLI {best_cli:.2f} s (x{total / best_cli:.1f})")
sys.exit(0 if ok else 1)

Output on b3be9b6 (the same on 24af95d):

rdocx 0.16.4, Python 3.15.0, Linux x86_64
FAIL 1000 replaced: Python open 0.33 s + replace 0.40 s + save 0.10 s = 0.83 s; CLI 0.44 s (x1.9)

Acceptance: the line prints PASS.

Where the time goes, timed in Rust on the same file: Document::open 0.10 s, try_replace_text 0.21 s, to_bytes 0.12 s, so 0.43 s, what the CLI takes. The binding adds two passes over every owner of the document:

  • PyDocument::from_document builds the whole owner inventory when the document opens (WordPieces::assemble, the owner snapshots read again from the part XML, one revision scope per owner, WordOwnerIndexes::prepare): about 0.29 s.
  • counted_mutation clones the document and publishes the replacement through prepare_word_revision(Conservative), which builds that inventory again for the candidate: about 0.28 s beyond the replacement itself.

A script that opens, replaces and saves takes no handle, so neither pass serves it. Building the owner inventory lazily, on the first handle a script takes, would let such a script pay what the CLI pays. That is a change to how PyDocument keeps its owners, so it is left to you. #443 does not touch it.

In #443

#443 (fix/437-edit-cost-cells-styles) now also carries case 1:

  • PyCell::edit 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). Writing the cell's first w:tcPr, which a new cell lacks, moves every item of the cell story by one, so the owners of that story move one item and change route bucket (move_cell_story_items), and debug builds check them against a rebuilt inventory. A cell of a table in another story keeps the staged candidate and adopt_property_edit.
  • Cell.text edits the cell's first paragraph in the candidate as the story-table branch already did, without try_set_cell_text's second clone, comment reconciliation and staged reopen (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 since rdocx: edit table cell paragraphs in place and keep the styles parse across edits (#437) #443.

Case 1 on #443 (local release build without LTO, so a little slower than a release wheel):

rdocx 0.16.4, Python 3.13.16, Linux x86_64
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)

Cell.text now costs what a body Paragraph.text costs in the same document (about 10 ms at 2000 paragraphs), but it still grows with the body: what remains is the revision publication every text setter shares (clone_for_staging, WordPieces::assemble, the owner indexes). Making it local is the same kind of change as case 2, so Cell.text keeps its FAIL until then.

Activity

  1. added 2 commits that reference this issue on Nov 1, 2026
  2. mantissaman commented on Nov 9, 2026

    @mantissaman
    Contributor

    Released in v0.17.0 (tag).

    The Word release includes the whole-cell setter and Python open/replace/save improvements prompted by this follow-up. This issue stays open because the original numerical criteria remain carried to S96.1 F-X285, with unfinished memory and retention-history qualification separately in F-X286. No universal CLI speedup or completed ratio claim is made.

    The follow-up paths adapt the reference work in PR443.

    Thank you @hadim for the report and reference work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions