Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/formats/docx/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,92 @@ mod tests {
format!(r#"<w:document {W}><w:body>{para}{para}</w:body></w:document>"#)
}

#[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#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>one</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>two</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>ten</w:t></w:r></w:p>
</w:body></w:document>"#;
let numbering = r#"<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:abstractNum w:abstractNumId="7">
<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:start w:val="1"/></w:lvl>
</w:abstractNum>
<w:num w:numId="1"><w:abstractNumId w:val="7"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="7"/></w:num>
<w:num w:numId="3"><w:abstractNumId w:val="7"/>
<w:lvlOverride w:ilvl="0"><w:startOverride w:val="10"/></w:lvlOverride>
</w:num>
</w:numbering>"#;
let bytes =
docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]);
let doc = parse(&bytes).unwrap();
let starts: Vec<u64> = 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#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="18446744073709551615"/></w:numPr></w:pPr><w:r><w:t>one</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="2"/></w:numPr></w:pPr><w:r><w:t>sub</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="3"/></w:numPr></w:pPr><w:r><w:t>two</w:t></w:r></w:p>
</w:body></w:document>"#;
let numbering = r#"<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:abstractNum w:abstractNumId="0">
<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:start w:val="1"/></w:lvl>
<w:lvl w:ilvl="1"><w:numFmt w:val="lowerLetter"/><w:start w:val="1"/></w:lvl>
</w:abstractNum>
<w:num w:numId="18446744073709551615"><w:abstractNumId w:val="0"/></w:num>
<w:num w:numId="2"><w:abstractNumId w:val="0"/>
<w:lvlOverride w:ilvl="0"><w:startOverride w:val="10"/></w:lvlOverride>
</w:num>
<w:num w:numId="3"><w:abstractNumId w:val="0"/></w:num>
</w:numbering>"#;
let bytes =
docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]);
let doc = parse(&bytes).unwrap();
let mut starts: Vec<Option<u64>> = Vec::new();
fn walk_lists(blocks: &[Block], out: &mut Vec<Option<u64>>) {
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
Expand Down
68 changes: 60 additions & 8 deletions src/formats/docx/numbering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ struct AbstractNum {
pub struct Instance {
pub levels: [LevelDef; LEVELS],
pstyles: [Option<String>; 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 {
Expand Down Expand Up @@ -104,34 +113,59 @@ 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")
.and_then(|e| e.attr(ns::W, "val"))
.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)
}
Expand All @@ -143,7 +177,7 @@ fn resolve_abstract<'n>(
abstracts: &'n HashMap<&str, AbstractNum>,
direct: &HashMap<u64, (&str, &Element)>,
style_num_id: &impl Fn(&str) -> Option<u64>,
) -> Result<Option<&'n AbstractNum>, ConvertError> {
) -> Result<Option<(String, &'n AbstractNum)>, ConvertError> {
let mut seen: Vec<String> = Vec::new();
let mut current = abs_id.to_string();
loop {
Expand All @@ -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))),
}
}
}
Expand Down Expand Up @@ -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<u64>,
/// 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 {
Expand All @@ -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<String>) {
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;
Expand Down
4 changes: 2 additions & 2 deletions tests/snapshots/snapshots__docx__handmade-numbering.docx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,7 +28,7 @@ Interruption paragraph.

Suppressed numbering paragraph

5. Style-numbered paragraph
8. Style-numbered paragraph

1. pStyle-bound level one

Expand Down
2 changes: 1 addition & 1 deletion tests/snapshots/snapshots__docx__text.docx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Interrupting paragraph between lists.

- IV. Roman starting at four

- I. Roman five
- V. Roman five

- Bullet one

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Interrupting paragraph between lists.

- IV. Roman starting at four

- I. Roman five
- V. Roman five

- Bullet one

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Interrupting paragraph between lists.

- IV. Roman starting at four

- I. Roman five
- V. Roman five

- Bullet one

Expand Down