Repository navigation
Description
Found while comparing rdocx and rpptx with other Office libraries on synthetic tasks, all on main 24af95d (v0.16.4, rpptx 0.14.4), Linux x86_64. Each case has a script that prints PASS or FAIL.
The output is right in every case. Only the time grows. The checks are on growth (the ratio of two timings on the same machine), not on absolute times, with generous limits.
| Case | What happens | Expected |
|---|---|---|
| 1 | run.text = ..., try_replace_text, add_row, clone_row and a cell paragraph setter cost 12 to 54 times more when styles.xml grows from 6 KB to 419 KB of unused styles |
The cost of an edit does not depend on the size of the styles part (at most x3) |
| 2 | A setter on a table cell's paragraph or run (alignment, style, runs[0].font.size) costs 12 to 17 times more in row 40 than in row 1, and 6 to 8 times more with 2000 body paragraphs after the table. The same setter on a body paragraph costs nothing measurable |
The cost does not depend on the cell's place or on the body (at most x3) |
| 3 | Filling a table one cell at a time costs time in the square of its cells: the time per call is 4.3 times higher at 120 cells than at 30 | A flat time per call (at most x2), so a time linear in the cells |
Taken together this makes building a long document by filling tables slow. A synthetic 198-page report with tables in every chapter takes about 209 s with rdocx, against under 2 s with other Office libraries. By cProfile, 170 s of a 214 s run go to table cells, already with each chapter built in its own document and each table's cell styles written by one replace_xml.
1. An edit costs time in proportion to styles.xml
With rdocx's own 6 KB styles.xml, run.text = ..., try_replace_text, table.add_row() and table.clone_row() take 0.5 to 4 ms a call. With 2000 unused paragraph styles added to the same document (419 KB), they take 26 to 53 ms, the body being the same. A cell paragraph's alignment goes from 3 ms to 116 ms. add_paragraph is not affected. Real templates reach that size: a template that defines every built-in style has a styles part of several hundred KB.
From reading the binding, each of these edits clones the whole document (Document::clone_for_staging) and publishes the candidate through prepare_word_revision, which enumerates every story owner of the candidate (word_owner_locations, story_owner_snapshots). Run.text also calls Document::saves_as first, which saves both packages to compare them. One of these steps reads or rewrites the styles part on every call. We did not pin down which one.
Acceptance criterion: each edit costs at most 3 times more with the 419 KB styles part than with the 6 KB one.
"""Case 1: an edit costs time in proportion to styles.xml. The same one-table document is opened with its own
styles part (6 KB) and with 2000 unused paragraph styles added (419 KB). Each edit should cost about the same in
both. Builds its own input with rdocx and zipfile."""
import io, sys, time, zipfile
import rdocx
LIMIT = 3.0 # allowed ratio between the large and the small styles part
def ms(fn, n):
t0 = time.perf_counter()
for i in range(n):
fn(i)
return 1000 * (time.perf_counter() - t0) / n
def with_styles(data, n):
zin, out = zipfile.ZipFile(io.BytesIO(data)), io.BytesIO()
extra = "".join(
f'<w:style w:type="paragraph" w:customStyle="1" w:styleId="Extra{i}"><w:name w:val="Extra {i}"/>'
f'<w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="{i}"/></w:pPr>'
f'<w:rPr><w:sz w:val="{20 + i % 10}"/></w:rPr></w:style>' for i in range(n)).encode()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
for item in zin.infolist():
x = zin.read(item.filename)
if item.filename == "word/styles.xml":
x = x.replace(b"</w:styles>", extra + b"</w:styles>")
z.writestr(item, x)
return out.getvalue()
def document(styles):
doc = rdocx.Document()
doc.add_paragraph("Intro text")
table = doc.add_table(3, 6)
for r in range(3):
for c in range(6):
table.cell(r, c).text = f"r{r}c{c}"
return rdocx.Document.from_bytes(with_styles(doc.to_bytes(), styles))
OPS = {
"run.text = ...": lambda d, i: setattr(d.paragraphs[0].runs[0], "text", f"Intro {i}"),
"try_replace_text": lambda d, i: d.try_replace_text(f"r0c{i % 3}", f"r0c{i % 3}"),
"table.add_row()": lambda d, i: d.tables[0].add_row(),
"table.clone_row(1)": lambda d, i: d.tables[0].clone_row(1),
"cell paragraph alignment": lambda d, i: setattr(d.tables[0].cell(0, 5).paragraphs[0], "alignment", i % 2),
}
sizes = [len(zipfile.ZipFile(io.BytesIO(document(n).to_bytes())).read("word/styles.xml")) for n in (0, 2000)]
print(f"styles.xml: {sizes[0]} bytes, then {sizes[1]} bytes")
failed = False
for name, op in OPS.items():
small, large = (ms(lambda i: op(d, i), 10) for d in (document(0), document(2000)))
ratio = large / small
if ratio <= LIMIT:
print(f"PASS {name}: {small:.1f} ms, then {large:.1f} ms a call (x{ratio:.1f})")
else:
failed = True
print(f"FAIL {name} grows with styles.xml: got {small:.1f} ms, then {large:.1f} ms a call "
f"(x{ratio:.1f}), expected at most x{LIMIT:.0f}")
sys.exit(1 if failed else 0)Output on 24af95d:
styles.xml: 6466 bytes, then 419136 bytes
FAIL run.text = ... grows with styles.xml: got 1.5 ms, then 52.6 ms a call (x36.0), expected at most x3
FAIL try_replace_text grows with styles.xml: got 0.5 ms, then 26.6 ms a call (x53.7), expected at most x3
FAIL table.add_row() grows with styles.xml: got 2.9 ms, then 44.3 ms a call (x15.1), expected at most x3
FAIL table.clone_row(1) grows with styles.xml: got 3.7 ms, then 46.9 ms a call (x12.6), expected at most x3
FAIL cell paragraph alignment grows with styles.xml: got 2.9 ms, then 115.8 ms a call (x39.5), expected at most x3
exit=1
2. A setter through a table cell grows with the cell's place and with the body
In a 40 x 6 table, setting alignment or style on the paragraph of cell (0, 5) takes 11 ms, and on cell (39, 5) 129 ms. runs[0].font.size goes from 15 ms to 250 ms. With 2000 body paragraphs after the table, the same alignment on cell (0, 5) takes 87 ms. These times are from a quiet run. The output below was measured under heavier load, so its times are about twice as high and the body ratio reads x5.7. The same setter on a body paragraph takes under 0.01 ms. cell.text and cell.vertical_alignment do not grow with the cell's place but grow with the body.
From reading the code, the cause looks like this. A body paragraph resolves to ParagraphLocation::Body and is edited in place through paragraph_mut. A cell paragraph resolves to ParagraphLocation::Cell, and ParagraphLocation::edit calls Document::cell_paragraph_story_location on every call. That function clones the document, serializes the whole body with to_xml, then walks every table-cell owner of the body in order until it finds the selected cell. For each owner it walks, modeled_main_cell_route scans the story owners of the whole body twice more. So one call costs about (cell's place) x (body size). edit_story_paragraph then parses the paragraph from its snapshot and writes it back with replace_story_content, which clones and flushes the document and reopens the staged package.
Acceptance criterion: each setter costs at most 3 times more on cell (39, 5) than on cell (0, 5), and at most 3 times more with 2000 body paragraphs than without.
"""Case 2: a setter reached through a table cell's paragraph or run costs time in proportion to the cell's place
in the table and to the body around the table. The same setter on a body paragraph is constant. Builds its own
input with rdocx: a 40 x 6 table, then 2000 body paragraphs after it."""
import sys, time
import rdocx
from rdocx.shared import Pt
LIMIT = 3.0 # allowed ratio between the two sides of each check
def ms(fn, n=3):
t0 = time.perf_counter()
for i in range(n):
fn(i)
return 1000 * (time.perf_counter() - t0) / n
def document(body):
doc = rdocx.Document()
doc.add_paragraph("Intro text")
table = doc.add_table(40, 6)
for r in range(40):
for c in range(6):
table.cell(r, c).text = f"r{r}c{c}"
for i in range(body):
doc.add_paragraph(f"Body paragraph {i} with a few words.")
doc.add_style("TT", based_on="Normal", font_size=Pt(9))
return doc
SETTERS = {
"paragraph.alignment": lambda p, i: setattr(p, "alignment", i % 2),
"paragraph.style": lambda p, i: setattr(p, "style", "TT" if i % 2 else "Normal"),
"runs[0].font.size": lambda p, i: setattr(p.runs[0].font, "size", Pt(9 + i % 2)),
}
failed = False
def check(what, small, large):
global failed
ratio = large / small
if ratio <= LIMIT:
print(f"PASS {what}: {small:.1f} ms, then {large:.1f} ms (x{ratio:.1f})")
else:
failed = True
print(f"FAIL {what}: got {small:.1f} ms, then {large:.1f} ms (x{ratio:.1f}), expected at most x{LIMIT:.0f}")
doc = document(0)
body = ms(lambda i: setattr(doc.paragraphs[0], "alignment", i % 2), 20)
print(f"body paragraph alignment: {body:.2f} ms a call")
for name, setter in SETTERS.items():
first = ms(lambda i: setter(doc.tables[0].cell(0, 5).paragraphs[0], i))
last = ms(lambda i: setter(doc.tables[0].cell(39, 5).paragraphs[0], i))
check(f"{name} in cell (39, 5) against cell (0, 5)", first, last)
# One untimed call on each document first, so both sides time warm calls.
ms(lambda i: setattr(doc.tables[0].cell(0, 5).paragraphs[0], "alignment", i % 2), 1)
alone = ms(lambda i: setattr(doc.tables[0].cell(0, 5).paragraphs[0], "alignment", i % 2))
big = document(2000)
ms(lambda i: setattr(big.tables[0].cell(0, 5).paragraphs[0], "alignment", i % 2), 1)
around = ms(lambda i: setattr(big.tables[0].cell(0, 5).paragraphs[0], "alignment", i % 2))
check("paragraph.alignment in cell (0, 5) with 2000 body paragraphs against none", alone, around)
sys.exit(1 if failed else 0)Output on 24af95d:
body paragraph alignment: 0.00 ms a call
FAIL paragraph.alignment in cell (39, 5) against cell (0, 5): got 20.4 ms, then 213.8 ms (x10.5), expected at most x3
FAIL paragraph.style in cell (39, 5) against cell (0, 5): got 20.0 ms, then 213.5 ms (x10.7), expected at most x3
FAIL runs[0].font.size in cell (39, 5) against cell (0, 5): got 26.4 ms, then 416.8 ms (x15.8), expected at most x3
FAIL paragraph.alignment in cell (0, 5) with 2000 body paragraphs against none: got 17.0 ms, then 97.1 ms (x5.7), expected at most x3
exit=1
3. Filling a table is quadratic in its cells
Because the cost of a cell setter grows with the cell's place (case 2), filling a fresh table cell by cell costs time in the square of its cells. Setting a paragraph style on every cell paragraph takes 3.5 ms a call for a 30-cell table and 15.4 ms a call for a 120-cell table: 0.11 s against 1.85 s for the whole table. With body text around the tables, as in a real report, the constant grows too (case 2).
Acceptance criterion: the time per call for the 120-cell table is at most 2 times the time per call for the 30-cell table.
"""Case 3: filling a table cell by cell costs time in the square of its cells. A paragraph style is set on every
cell paragraph of a fresh table, one call per cell. The time per call should not depend on the table's size.
Builds its own input with rdocx."""
import sys, time
import rdocx
from rdocx.shared import Pt
LIMIT = 2.0 # allowed ratio of the time per call between the 120-cell and the 30-cell table
def per_call(rows):
doc = rdocx.Document()
doc.add_paragraph("Intro text")
doc.add_table(rows, 6)
doc.add_style("TT", based_on="Normal", font_size=Pt(9))
cells = [(r, c) for r in range(rows) for c in range(6)]
t0 = time.perf_counter()
for r, c in cells:
doc.tables[0].cell(r, c).paragraphs[0].style = "TT"
seconds = time.perf_counter() - t0
print(f"{len(cells):3} cells: {1000 * seconds / len(cells):5.1f} ms per call, {seconds:5.2f} s for the table")
return seconds / len(cells)
small, large = per_call(5), per_call(20)
ratio = large / small
if ratio <= LIMIT:
print(f"PASS time per call is flat: x{ratio:.1f} from 30 to 120 cells")
sys.exit(0)
print(f"FAIL time per call grows with the table: got x{ratio:.1f} from 30 to 120 cells, expected at most x{LIMIT:.0f}")
sys.exit(1)Output on 24af95d:
30 cells: 3.5 ms per call, 0.11 s for the table
120 cells: 15.4 ms per call, 1.85 s for the table
FAIL time per call grows with the table: got x4.3 from 30 to 120 cells, expected at most x2
exit=1
Activity
fix(rdocx-py): set table cell properties and text without restaging
mantissaman commentedon Nov 9, 2026
Released in rpptx-v0.15.0 (tag), v0.17.0 (tag).
The release includes bounded styles/accessor and checked body/cell edit work, preserving saved bytes, refusal and held-handle behavior. Shared OPC also exposes the borrowed get_part_shared accessor without changing get_part. That accessor is not a measured PowerPoint speedup.
This issue stays open. Original numerical targets and unfinished memory/RSS, retention-history and calibration qualification move to S96.1 F-X285/F-X286, starting with the F-X284 review. The prior 76/79 flat snapshot is historical evidence, not final-artifact acceptance, and no 6 MiB/68 MiB claim is made.
The accessor and edit paths adapt PR443.
Thank you @hadim for the report and reference work.