Repository navigation
Description
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_alignmentandshading(andwidth,set_border,set_margins) go throughPyCell::edit, which clones the whole document, edits the clone and callsadopt_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.texton a body table callsDocument::try_set_cell_text, which clones the document a second time, runsreconcile_comment_removal(twoflush_to_package) andprepare_and_reopen_staged, before theCellTextpublication. The cell route for that publication comes fromTableLocation::native, not from the prepared index.- Found while measuring:
Paragraph.texton a cell paragraph takes itsParagraphTextroute fromParagraphLocation::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_documentbuilds 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_mutationclones the document and publishes the replacement throughprepare_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::editsets a body table cell property in place, as a body paragraph property setter does (each of its edits validates before it changesw:tcPr). Writing the cell's firstw: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 andadopt_property_edit.Cell.textedits the cell's first paragraph in the candidate as the story-table branch already did, withouttry_set_cell_text's second clone, comment reconciliation and staged reopen (Paragraph::set_textkeeps every comment, bookmark and permission anchor), and takes its table route from the owner index.Paragraph.texttakes its route fromparagraph_route, asRundoes 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
chore(benchmark): rerun on pin 3707323312e9 (cell setters of tensorbe…
Merge pull request #72 from hadim/try/rdocx-open-prs-2026-11-01
mantissaman commentedon Nov 9, 2026
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.