diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs
index eee5317e..7911cabf 100644
--- a/src/formats/docx/mod.rs
+++ b/src/formats/docx/mod.rs
@@ -191,6 +191,92 @@ 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 override_restart_does_not_leak_into_the_next_instance() {
+ // Entering an overriding instance schedules ITS levels' restarts; a
+ // following plain instance of the same abstract must keep counting
+ // instead of consuming a restart it never earned. Also exercises a
+ // numId that would collide with a u64::MAX sentinel if one existed.
+ let document = r#"
+
+ one
+ sub
+ two
+ "#;
+ let numbering = r#"
+
+
+
+
+
+
+
+
+
+ "#;
+ let bytes =
+ docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]);
+ let doc = parse(&bytes).unwrap();
+ let mut starts: Vec> = Vec::new();
+ fn walk_lists(blocks: &[Block], out: &mut Vec >) {
+ for b in blocks {
+ if let Block::List(list) = b {
+ out.push(list.ordered().then_some(list.start));
+ for item in &list.items {
+ walk_lists(&item.blocks, out);
+ }
+ }
+ }
+ }
+ walk_lists(&doc.blocks, &mut starts);
+ assert_eq!(
+ starts,
+ [Some(1), Some(1), Some(2)],
+ "sub-list starts at a; the outer sequence continues at two across the overriding instance instead of restarting"
+ );
+ }
+
#[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..61637a7d 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, ConvertError> {
+) -> Result , ConvertError> {
let mut seen: Vec = Vec::new();
let mut current = abs_id.to_string();
loop {
@@ -157,14 +191,14 @@ fn resolve_abstract<'n>(
return Ok(None);
};
let Some(style_id) = &abs.num_style_link else {
- return Ok(Some(abs));
+ return Ok(Some((current.clone(), abs)));
};
let linked = style_num_id(style_id)
.and_then(|num_id| direct.get(&num_id))
.map(|(abs_id, _)| abs_id.to_string());
match linked {
Some(next) => current = next,
- None => return Ok(Some(abs)),
+ None => return Ok(Some((current.clone(), abs))),
}
}
}
@@ -226,6 +260,18 @@ struct InstanceState {
value: [u64; LEVELS],
initialized: [bool; LEVELS],
restart_pending: [bool; LEVELS],
+ /// The instance currently driving this abstract (`u64::MAX` = none yet;
+ /// real `numId`s never reach the counters as `u64::MAX`). Switching
+ /// instances inside one logical list restarts only the levels the
+ /// entering instance overrides (#96). `None` until the list's first
+ /// paragraph; `Some` never collides with a real id because it is
+ /// compared as an `Option`, not against a sentinel value.
+ last_num: Option,
+ /// The overridden levels of the currently active instance. Each restarts
+ /// on its first USE under this instance — not eagerly at the switch, so a
+ /// following plain instance cannot consume a restart that was never
+ /// earned (cubic review of #129).
+ fresh_overrides: [bool; LEVELS],
}
impl Counters {
@@ -234,9 +280,15 @@ impl Counters {
/// reproducible from the marker kind alone, the composite label.
pub fn next(&mut self, num_id: u64, ilvl: usize, instance: &Instance) -> (u64, Option) {
let ilvl = ilvl.min(LEVELS - 1);
- let state = self.state.entry(num_id).or_default();
+ let state = self.state.entry(instance.abstract_key).or_default();
+ if state.last_num != Some(num_id) {
+ // First paragraph of this list, or an instance switch within it.
+ state.fresh_overrides = instance.overrides;
+ state.last_num = Some(num_id);
+ }
let def = &instance.levels[ilvl];
- if !state.initialized[ilvl] || state.restart_pending[ilvl] {
+ let override_restart = std::mem::take(&mut state.fresh_overrides[ilvl]);
+ if !state.initialized[ilvl] || state.restart_pending[ilvl] || override_restart {
state.value[ilvl] = def.start;
state.initialized[ilvl] = true;
state.restart_pending[ilvl] = false;
diff --git a/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap b/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap
index 6a19b3e2..4a760316 100644
--- a/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap
+++ b/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap
@@ -18,7 +18,7 @@ Interruption paragraph.
4. One-four continues the count
-1. Two-one independent counter
+5. Two-one independent counter
10. Ten-start via override
@@ -28,7 +28,7 @@ Interruption paragraph.
Suppressed numbering paragraph
-5. Style-numbered paragraph
+8. Style-numbered paragraph
1. pStyle-bound level one
diff --git a/tests/snapshots/snapshots__docx__text.docx.snap b/tests/snapshots/snapshots__docx__text.docx.snap
index 655b643e..2eee1b87 100644
--- a/tests/snapshots/snapshots__docx__text.docx.snap
+++ b/tests/snapshots/snapshots__docx__text.docx.snap
@@ -28,7 +28,7 @@ Interrupting paragraph between lists.
- IV. Roman starting at four
-- I. Roman five
+- V. Roman five
- Bullet one
diff --git a/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap b/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap
index 750148a5..1ce7a0ab 100644
--- a/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap
+++ b/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap
@@ -28,7 +28,7 @@ Interrupting paragraph between lists.
- IV. Roman starting at four
-- I. Roman five
+- V. Roman five
- Bullet one
diff --git a/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap b/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap
index 750148a5..1ce7a0ab 100644
--- a/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap
+++ b/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap
@@ -28,7 +28,7 @@ Interrupting paragraph between lists.
- IV. Roman starting at four
-- I. Roman five
+- V. Roman five
- Bullet one