Merge pull request #1325 from storytold/codex/docx-level-presence

docx: retain explicitly defined numbering levels
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-14 11:48:48 -05:00
commit 7ad4765ee5
3 files changed
+83 -3

No files matched your search

+9 -2
View File
@@ -331,9 +331,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<Level> = (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()));
@@ -343,6 +345,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
@@ -376,9 +379,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;
+3
View File
@@ -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 &paragraphs[2..] {
+71 -1
View File
@@ -70,12 +70,18 @@ impl Default for Level {
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AbstractNum {
pub id: u32,
pub name: Option<String>,
pub levels: Vec<Level>,
/// 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")]
@@ -89,6 +95,20 @@ pub struct AbstractNum {
pub multi_level_type: Option<String>,
}
// Explicit source-level presence is reader provenance used to distinguish an omitted Word level
// from an effective default. It is intentionally not part of the document model's structural
// equality, just as it is not serialized.
impl PartialEq for AbstractNum {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.name == other.name
&& self.levels == other.levels
&& self.nsid == other.nsid
&& self.tmpl == other.tmpl
&& self.multi_level_type == other.multi_level_type
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Num {
@@ -236,6 +256,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 +519,42 @@ 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));
}
#[test]
fn explicit_level_metadata_does_not_change_model_equality() {
let mut with_metadata = AbstractNum::default();
let without_metadata = with_metadata.clone();
with_metadata.explicit_levels = Some([true; 9]);
assert_eq!(with_metadata, without_metadata);
}
/// 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() {