From 58f282ab0a7d4a04795b11e2b7c28b1516b174dd Mon Sep 17 00:00:00 2001
From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:07:30 +0000
Subject: [PATCH 1/2] fix(docx): continue counters across numIds that share an
abstract (#96)
Word treats every w:num referencing one w:abstractNum as a single logical
list; startOverride exists precisely to break away from it. Counters were
keyed per numId instead, so documents whose outline is split across
several instances of one abstract restarted at every switch: headings
came out as Section 1, 2, 1(!), 1.1(!), 3 ... exactly the reported
multiple-section-1 / 4.x-to-1.1 symptom in #96. Reapplying the paragraph
style clears it because it drops the direct numPr pointing at the second
instance, collapsing the document back to one numId.
Counters are now keyed by the resolved abstract definition. Entering a
list through an instance that carries lvlOverride restarts exactly its
overridden levels (nested w:lvl or startOverride); everything else keeps
counting. The real-world text.docx fixture shows why this direction is
right: consecutive items use numId 5 then numId 4 and the source itself
labels them four, five -- previously rendered as IV, I.
Snapshot updates: handmade-numbering's "Two-one independent counter"
now continues the shared sequence (5.) rather than restarting (1.), and
the style-referenced paragraph follows the merged sequence (8.). The
override-driven cases (10., 7)) still restart as before. If the intent
was that bare shared instances stay independent in anydoc despite Word,
this is easy to flip behind a flag -- but Word parity is what #96 asks
for.
---
src/formats/docx/mod.rs | 41 +++++++++++
src/formats/docx/numbering.rs | 68 +++++++++++++++++--
...pshots__docx__handmade-numbering.docx.snap | 4 +-
.../snapshots/snapshots__docx__text.docx.snap | 2 +-
...malformed__corrupt-styles--skips.docx.snap | 2 +-
...malformed__missing-styles--skips.docx.snap | 2 +-
6 files changed, 107 insertions(+), 12 deletions(-)
diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs
index eee5317e..02c2e801 100644
--- a/src/formats/docx/mod.rs
+++ b/src/formats/docx/mod.rs
@@ -191,6 +191,47 @@ mod tests {
format!(r#"{para}{para}"#)
}
+ #[test]
+ fn instances_sharing_an_abstract_continue_one_sequence() {
+ // #96: Word treats every w:num that references one w:abstractNum as
+ // the same logical list; converters emit exactly this shape for a
+ // single outline split across several numIds. Counters must continue
+ // across the instance switches, and a startOverride must restart.
+ let document = r#"
+
+ one
+ two
+ three
+ ten
+ "#;
+ let numbering = r#"
+
+
+
+
+
+
+
+
+ "#;
+ let bytes =
+ docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]);
+ let doc = parse(&bytes).unwrap();
+ let starts: Vec = doc
+ .blocks
+ .iter()
+ .filter_map(|b| match b {
+ Block::List(list) if list.ordered() => Some(list.start),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ starts,
+ [1, 3, 10],
+ "instance 2 continues at three; the override restarts at ten"
+ );
+ }
+
#[test]
fn huge_numbering_start_values_cannot_overflow() {
// H2: w:start is ST_DecimalNumber (xsd:int); out-of-range values are
diff --git a/src/formats/docx/numbering.rs b/src/formats/docx/numbering.rs
index a6334008..bb892cd4 100644
--- a/src/formats/docx/numbering.rs
+++ b/src/formats/docx/numbering.rs
@@ -46,6 +46,15 @@ struct AbstractNum {
pub struct Instance {
pub levels: [LevelDef; LEVELS],
pstyles: [Option; LEVELS],
+ /// Identity of the abstract definition this instance resolves to (after
+ /// any numStyleLink indirection). Instances sharing an abstract are one
+ /// logical list in Word, so their counters continue across instance
+ /// switches (#96).
+ pub(crate) abstract_key: u64,
+ /// Levels this instance overrides (`lvlOverride` with a nested `w:lvl` or
+ /// a `startOverride`). Entering the list through such an instance restarts
+ /// exactly those levels; every other level keeps counting (#96).
+ pub(crate) overrides: [bool; LEVELS],
}
impl Instance {
@@ -104,24 +113,39 @@ pub fn parse(
direct.insert(num_id, (abs_id, num));
}
+ // Stable identity per abstract definition (document order). Counters key
+ // on this so instances that share an abstract continue one logical list.
+ let mut abstract_keys: HashMap<&str, u64> = HashMap::new();
+ for abs in root.find_all(ns::W, "abstractNum") {
+ let Some(id) = abs.attr(ns::W, "abstractNumId") else { continue };
+ let key = abstract_keys.len() as u64;
+ abstract_keys.entry(id).or_insert(key);
+ }
+
let mut numbering = Numbering::default();
for (&num_id, &(abs_id, num_elem)) in &direct {
- let Some(abs) = resolve_abstract(abs_id, &abstracts, &direct, style_num_id)? else {
+ let Some((resolved_id, abs)) = resolve_abstract(abs_id, &abstracts, &direct, style_num_id)?
+ else {
log::warn!("numbering instance {num_id} references unknown abstract {abs_id:?}");
continue;
};
let mut levels = abs.levels.clone();
let mut pstyles = abs.pstyles.clone();
+ let mut overrides = [false; LEVELS];
for over in num_elem.find_all(ns::W, "lvlOverride") {
let ilvl: usize = over.attr(ns::W, "ilvl").and_then(|v| v.parse().ok()).unwrap_or(0);
if ilvl >= LEVELS {
continue;
}
// A nested w:lvl replaces the level wholesale; startOverride is
- // applied last so it survives the replacement.
+ // applied last so it survives the replacement. Both count as an
+ // override: entering the list through this instance restarts the
+ // level instead of continuing the shared sequence (#96).
+ let mut overridden = false;
if let Some(lvl) = over.find(ns::W, "lvl") {
levels[ilvl] = parse_level(lvl);
pstyles[ilvl] = level_pstyle(lvl);
+ overridden = true;
}
if let Some(start) = over
.find(ns::W, "startOverride")
@@ -129,9 +153,19 @@ pub fn parse(
.and_then(parse_start)
{
levels[ilvl].start = start;
+ overridden = true;
}
+ overrides[ilvl] = overridden;
}
- numbering.instances.insert(num_id, Instance { levels, pstyles });
+ numbering.instances.insert(
+ num_id,
+ Instance {
+ levels,
+ pstyles,
+ abstract_key: abstract_keys.get(resolved_id.as_str()).copied().unwrap_or(u64::MAX),
+ overrides,
+ },
+ );
}
Ok(numbering)
}
@@ -143,7 +177,7 @@ fn resolve_abstract<'n>(
abstracts: &'n HashMap<&str, AbstractNum>,
direct: &HashMap,
style_num_id: &impl Fn(&str) -> Option,
-) -> Result