Skip to content

numbering order fixes and performance updates - #137

Open
JasonRJFleischer wants to merge 6 commits into
merge-allfrom
jason/num-order-fixes
Open

numbering order fixes and performance updates#137
JasonRJFleischer wants to merge 6 commits into
merge-allfrom
jason/num-order-fixes

Conversation

@JasonRJFleischer

Copy link
Copy Markdown

Description (e.g. "Related to ...", "Closes ...", etc.)

Two numbering fixes and a caching overhaul for get_num_for_p, released as 0.8.10.41

Fixes

  • Multi-component lvlText (%1.%2) is computed per-level from the family's counts and <w:start> values instead of splicing the previous paragraph's rendered string — no more wrong components or ValueError when the neighbor list uses letter formats.
  • An unrelated list's startOverride no longer bleeds into a list carrying its own ((9)(5)).

Performance
Numbering resolution was O(n² × m): every paragraph re-walks all preceding paragraphs, resolving abstract nums and paragraph properties from scratch each comparison. Memoize on CT_Numbering (abstractNum, startOverride, per-paragraph ilvl/numId/pStyle), invalidated by add_num/set_li_lvl. Output is byte-identical; a 15k-paragraph docx drops from ~40 min to 35 s.

Tests (fixture tests for both fixes, cache-invalidation tests, golden updates) land in platform with the version bump.

Written by Claude

Code review checklist

  • Private platform tests at core/tests/test_python-docx are updated and passing
  • If this change is going to be deployed on Cloudsmith after merge, python-docx version number should be increased

Multi-component lvlText (e.g. `%1.%2.%3`) was assembled by splicing
apart the previous paragraph's rendered number string. That heuristic
worked when a same-list precedent existed but produced incorrect
output otherwise — and crashed with `ValueError: invalid literal for
int() with base 10: 'F'` when the previous paragraph belonged to an
unrelated list whose number contained letters (upperLetter /
upperRoman formats).

Resolve each %N independently from the numbering state of the same
numbering family:

- Walk preceding paragraphs in the same list (matched by numId,
  abstractNumId, or linked style), count those at the target ilvl,
  and stop at any reset (lower ilvl).
- Combine the count with the level's <w:start> value (and any
  lvlOverride/startOverride). Word uses these start values to seed
  parent-level context — e.g. a chapter number stored as the ilvl=1
  start value lets `%2` render as the chapter number in a section's
  multi-component lvlText, even when no chapter paragraph is itself
  in the numbering tree.
- Format every %N substitution with the current level's numFmt, not
  the referenced level's. An ilvl=0 with upperRoman, seen via `%1`
  from an ilvl=1 decimal lvlText, renders as decimal "2", not "II" —
  matching what Word displays.

Removes the dead `get_preceding_paragraph` helper, which only the
old splicing path used.
count_same_numIds blindly added a preceding list's startOverride to
the current paragraph's running count whenever the two paragraphs
were in different numbering families. That worked for documents
that re-key the numId at each section (the previous list's
startOverride doubles as the continuation point for ours), but
produced wrong numbers when the current list also carries its own
startOverride. The previous list's offset got added on top of our
own start, e.g. a paragraph whose numId has startOverride=5
appearing after one whose numId has startOverride=4 rendered as
(9) instead of (5) — both are independent lists in Word and each
should display its own startOverride.

Only borrow the preceding list's startOverride when the current
list does not carry an explicit reset of its own. If both
paragraphs sit on numIds with startOverride > 1, treat them as
independent counters: stop counting and leave num at our own
startOverride. The skip-across-an-interleaved-short-list path is
unchanged.
Since e966ee1, same_abstract_num() resolves abstract numbering
definitions per preceding-paragraph comparison, each an uncached xpath
scan over w:num plus a linear scan over w:abstractNum — making
get_num_for_p O(n² × m) on large documents.

Memoize get_abstractNum(numId) and hoist the get_start_override
closure into a cached get_startOverride(numId, ilvl) method. Caches
live on the CT_Numbering lxml proxy (kept alive by NumberingPart) and
are invalidated by add_num, the sole mutation path used by set_li_lvl.
Exception contracts are unchanged and nothing is cached when a lookup
raises.

Numbering output is byte-identical on real documents; a 5,900-paragraph
docx drops from 377s to 48s (7.8×).
Paragraph numbering resolution was O(n² × m): for every paragraph,
get_num_for_p walks all preceding paragraphs, and since e966ee1 each
comparison resolved abstract numbering definitions via uncached xpath
scans, plus a full pPr/numPr/style resolution per visited paragraph.

Memoize on the CT_Numbering instance: get_abstractNum(numId), the new
get_startOverride(numId, ilvl) (hoisted from the get_start_override
closure), and per-paragraph (ilvl, numId) and pPr/pStyle keyed by the
paragraph element. Documents are treated as immutable during a read —
the same assumption Paragraph._number and the text cache already make;
add_num and set_li_lvl invalidate. AttributeError contracts are
preserved so the walk loops' skip logic is unchanged.

Numbering output is byte-identical on real documents. A 15,000-
paragraph docx drops from ~40min to 35s; a 6,000-paragraph one from
377s to 3.5s.
value_at_ilvl walks all preceding paragraphs once per %N component of
a multi-component lvlText, reading pPr/pStyle directly and re-scanning
the num's lvlOverrides via xpath for every component. Use the cached
get_pStyle and get_startOverride instead — semantics are identical,
including AttributeError propagation for a missing pPr or a
lvlOverride without startOverride, but repeat lookups become dict
hits, keeping multi-component documents on the same fast path as
single-component ones.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant