From 5c8e1b0d829d8fdc25d900019b9488e041fb7e91 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 15:10:43 +0000 Subject: [PATCH] docx: retain explicit numbering level presence --- crates/docx/src/read/styles.rs | 11 +++++-- crates/docx/tests/it/passthrough.rs | 3 ++ crates/wordmodel/src/numbering.rs | 48 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 080eaff21..b393df4d2 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -316,9 +316,11 @@ impl Reader<'_> { for a in root.children("w:abstractNum").take(10_000) { let Some(id) = a.attr("w:abstractNumId").and_then(u32_of) else { continue }; let mut levels: Vec = (0..9).map(default_level).collect(); + let mut explicit_levels = [false; 9]; for l in a.children("w:lvl") { let Some(i) = numbering_level_index(l.attr("w:ilvl")) else { continue }; levels[i] = self.level(l, i); + explicit_levels[i] = true; } if let Some(link) = a.child_val("w:numStyleLink") { links.push((id, link.to_string())); @@ -328,6 +330,7 @@ impl Reader<'_> { id, name: a.child_val("w:name").map(str::to_string), levels, + explicit_levels: Some(explicit_levels), nsid: a.child_val("w:nsid").filter(|v| hex(v)).map(str::to_ascii_uppercase), tmpl: a.child_val("w:tmpl").filter(|v| hex(v)).map(str::to_ascii_uppercase), multi_level_type: a @@ -361,9 +364,13 @@ impl Reader<'_> { for (aid, style) in links { let num = self.doc.styles.get(&self.pc.style_id(&style)).and_then(|s| s.para.numbering).map(|n| n.num); let src = num.and_then(|n| nums.iter().find(|x: &&Num| x.id == n)).map(|x| x.abstract_id); - let levels = src.filter(|s| *s != aid).and_then(|s| abstracts.iter().find(|a: &&AbstractNum| a.id == s)).map(|a| a.levels.clone()); - if let (Some(levels), Some(a)) = (levels, abstracts.iter_mut().find(|a| a.id == aid)) { + let source = src + .filter(|s| *s != aid) + .and_then(|s| abstracts.iter().find(|a: &&AbstractNum| a.id == s)) + .map(|a| (a.levels.clone(), a.explicit_levels)); + if let (Some((levels, explicit_levels)), Some(a)) = (source, abstracts.iter_mut().find(|a| a.id == aid)) { a.levels = levels; + a.explicit_levels = explicit_levels; } } Arc::make_mut(&mut self.doc.numbering).abstracts = abstracts; diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index 9e5f35a9f..9d3176a76 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -335,6 +335,9 @@ fn malformed_numbering_levels_are_skipped_instead_of_becoming_level_zero() { }) .collect(); assert_eq!(paragraphs.len(), 5); + assert!(doc.numbering.level_is_defined(1, 0)); + assert!(!doc.numbering.level_is_defined(1, 2), "effective defaults remain distinguishable from source definitions"); + assert!(doc.numbering.level(1, 2).is_some(), "Word's effective default level remains available"); assert_eq!(paragraphs[0].props.numbering.map(|n| (n.num, n.level)), Some((1, 0)), "an omitted level defaults to zero"); assert_eq!(paragraphs[1].props.numbering.map(|n| (n.num, n.level)), Some((1, 2)), "a valid level is preserved"); for p in ¶graphs[2..] { diff --git a/crates/wordmodel/src/numbering.rs b/crates/wordmodel/src/numbering.rs index 5ab94ea87..dc2c69a23 100644 --- a/crates/wordmodel/src/numbering.rs +++ b/crates/wordmodel/src/numbering.rs @@ -76,6 +76,12 @@ pub struct AbstractNum { pub id: u32, pub name: Option, pub levels: Vec, + /// Which levels were defined in the source file. `None` is used by model-created definitions, + /// where the populated `levels` vector is authoritative. DOCX readers set this when they fill + /// unspecified Word levels with their effective defaults. This reader-only provenance is not + /// serialized as part of the document model. + #[serde(skip)] + pub explicit_levels: Option<[bool; 9]>, /// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across /// documents (paste, compare); kept from the file. #[serde(skip_serializing_if = "Option::is_none")] @@ -236,6 +242,20 @@ impl Numbering { } self.abstract_of(num)?.levels.get(level as usize) } + /// Whether the source definition supplied this level, or a per-numbering override defines it. + /// + /// [`Self::level`] also exposes Word's effective default for omitted levels. This method lets + /// importers preserve formats whose historical behavior distinguishes an explicit level from + /// that effective fallback. Older serialized and model-created definitions have no source + /// presence metadata, so their populated levels count as defined. + pub fn level_is_defined(&self, num: u32, level: u8) -> bool { + let Some(n) = self.num(num) else { return false }; + if n.level_overrides.iter().any(|(i, _)| *i == level) { + return true; + } + let Some(a) = self.abstract_of(num) else { return false }; + a.explicit_levels.map_or_else(|| a.levels.get(level as usize).is_some(), |levels| levels.get(level as usize).copied().unwrap_or(false)) + } /// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX` /// lists, more than memory holds; then nothing is added and the id is 0, "no list". pub fn add_list(&mut self, kind: ListKind) -> u32 { @@ -485,6 +505,34 @@ const MAX_LABEL_BYTES: usize = 256; mod tests { use super::*; + #[test] + fn explicit_level_metadata_preserves_effective_defaults_and_old_models() { + let mut numbering = Numbering::default(); + let id = numbering.add_list(ListKind::Numbered); + assert!(numbering.level_is_defined(id, 0)); + assert!(numbering.level_is_defined(id, 8)); + + let abstract_id = numbering.num(id).unwrap().abstract_id; + let mut explicit = [false; 9]; + explicit[0] = true; + numbering.abstracts.iter_mut().find(|a| a.id == abstract_id).unwrap().explicit_levels = Some(explicit); + + // Effective Word defaults remain available to existing model callers, while importers can + // tell that this source file supplied only level zero. + assert!(numbering.level(id, 1).is_some()); + assert!(!numbering.level_is_defined(id, 1)); + assert!(numbering.level_is_defined(id, 0)); + + // Old serialized definitions have no presence marker; populated levels remain valid. + numbering.abstracts.iter_mut().find(|a| a.id == abstract_id).unwrap().explicit_levels = None; + assert!(numbering.level_is_defined(id, 8)); + + // A per-numbering level definition takes precedence over the abstract's missing level. + numbering.abstracts.iter_mut().find(|a| a.id == abstract_id).unwrap().explicit_levels = Some(explicit); + numbering.nums.iter_mut().find(|n| n.id == id).unwrap().level_overrides.push((1, Level::default())); + assert!(numbering.level_is_defined(id, 1)); + } + /// Read-only counter APIs retain the renderer's bounds and never advance a list. #[test] fn reference_labels_are_bounded_and_do_not_advance_counters() {