Skip to main content

easydoc_reader/extractor/
numbering.rs

1//! Parser for `word/numbering.xml` in OOXML documents.
2//!
3//! Extracts the mapping from `numId` to abstract numbering definitions,
4//! including whether each list level is ordered (decimal, roman, etc.) or
5//! unordered (bullet), and the start value for ordered lists.
6
7use std::borrow::Cow;
8use std::collections::HashMap;
9
10use easydoc_core::{DocError, Result};
11use quick_xml::Reader as XmlReader;
12use quick_xml::events::Event;
13
14/// Top-level numbering definitions parsed from `word/numbering.xml`.
15///
16/// OOXML numbering has two layers:
17/// - **abstractNum** defines the formatting for each indentation level.
18/// - **num** maps a `numId` (referenced by paragraphs) to an `abstractNumId`.
19///
20/// This struct provides [`lookup`](Self::lookup) to resolve a `(numId, ilvl)`
21/// pair to the corresponding [`Level`] information.
22#[derive(Debug, Clone, Default)]
23pub struct Numbering {
24    /// Maps `numId` -> `abstractNumId`.
25    pub num_to_abstract: HashMap<u32, u32>,
26    /// Maps `abstractNumId` -> its level definitions.
27    pub abstract_nums: HashMap<u32, AbstractNum>,
28}
29
30/// An abstract numbering definition containing per-level formatting.
31#[derive(Debug, Clone, Default)]
32pub struct AbstractNum {
33    /// Level definitions keyed by indentation level (`ilvl`).
34    pub levels: HashMap<u8, Level>,
35}
36
37/// Formatting information for a single indentation level in a list.
38#[derive(Debug, Clone, Default)]
39pub struct Level {
40    /// Whether this level uses an ordered format (e.g. decimal, roman).
41    /// `false` means bullet/unordered.
42    pub ordered: bool,
43    /// Start value for ordered lists (e.g. `Some(1)` for numbering starting
44    /// at 1).  `None` for bullets or when no `<w:start>` is specified.
45    pub start: Option<u32>,
46    /// The indentation level (`ilvl`), 0-based.
47    pub ilvl: u8,
48}
49
50impl Numbering {
51    /// Parses a `word/numbering.xml` string into a [`Numbering`] mapping.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`DocError::Format`] on XML parse failure.
56    pub fn parse(xml: &str) -> Result<Self> {
57        let mut numbering = Numbering::default();
58        let mut reader = XmlReader::from_reader(xml.as_bytes());
59        reader.config_mut().trim_text(true);
60        let mut buf = Vec::new();
61
62        // State for parsing abstractNum levels.
63        let mut current_abstract_id: Option<u32> = None;
64        let mut current_ilvl: Option<u8> = None;
65        let mut current_num_fmt: Option<String> = None;
66        let mut current_start: Option<u32> = None;
67
68        // State for parsing num -> abstractNumId mapping.
69        let mut current_num_id: Option<u32> = None;
70        let mut in_num: bool = false;
71
72        loop {
73            match reader.read_event_into(&mut buf) {
74                Ok(Event::Eof) => break,
75                Ok(Event::Start(ref start)) => {
76                    let name = start.name();
77                    let local = name.as_ref();
78                    match local {
79                        b"w:abstractNum" => {
80                            current_abstract_id = extract_u32_attr(start, b"w:abstractNumId");
81                        }
82                        b"w:lvl" => {
83                            current_ilvl = extract_u8_attr(start, b"w:ilvl");
84                            current_num_fmt = None;
85                            current_start = None;
86                        }
87                        b"w:numFmt" => {
88                            current_num_fmt = extract_val_attr(start);
89                        }
90                        b"w:start" => {
91                            current_start =
92                                extract_val_attr(start).and_then(|v| v.parse::<u32>().ok());
93                        }
94                        b"w:num" => {
95                            current_num_id = extract_u32_attr(start, b"w:numId");
96                            in_num = true;
97                        }
98                        b"w:abstractNumId" if in_num => {
99                            // This is inside <w:num> -- read the val attribute.
100                            if let (Some(abstract_id), Some(num_id)) =
101                                (extract_val_attr_u32(start), current_num_id)
102                            {
103                                numbering.num_to_abstract.insert(num_id, abstract_id);
104                            }
105                        }
106                        _ => {}
107                    }
108                }
109                Ok(Event::Empty(ref empty)) => {
110                    let name = empty.name();
111                    let local = name.as_ref();
112                    match local {
113                        b"w:numFmt" => {
114                            current_num_fmt = extract_val_attr(empty);
115                        }
116                        b"w:start" => {
117                            current_start =
118                                extract_val_attr(empty).and_then(|v| v.parse::<u32>().ok());
119                        }
120                        b"w:abstractNumId" if in_num => {
121                            if let (Some(abstract_id), Some(num_id)) =
122                                (extract_val_attr_u32(empty), current_num_id)
123                            {
124                                numbering.num_to_abstract.insert(num_id, abstract_id);
125                            }
126                        }
127                        _ => {}
128                    }
129                }
130                Ok(Event::End(ref end)) => {
131                    let name = end.name();
132                    let local = name.as_ref();
133                    match local {
134                        b"w:lvl" => {
135                            // Finalize the level we were parsing.
136                            if let (Some(abstract_id), Some(ilvl)) =
137                                (current_abstract_id, current_ilvl)
138                            {
139                                let ordered =
140                                    !matches!(current_num_fmt.as_deref(), Some("bullet") | None);
141                                let level = Level {
142                                    ordered,
143                                    start: current_start,
144                                    ilvl,
145                                };
146                                numbering
147                                    .abstract_nums
148                                    .entry(abstract_id)
149                                    .or_default()
150                                    .levels
151                                    .insert(ilvl, level);
152                            }
153                            current_ilvl = None;
154                            current_num_fmt = None;
155                            current_start = None;
156                        }
157                        b"w:num" => {
158                            current_num_id = None;
159                            in_num = false;
160                        }
161                        _ => {}
162                    }
163                }
164                Err(e) => {
165                    return Err(DocError::Format(format!(
166                        "XML parse error in numbering: {e}"
167                    )));
168                }
169                _ => {}
170            }
171            buf.clear();
172        }
173
174        Ok(numbering)
175    }
176
177    /// Looks up the level information for a given `numId` and `ilvl`.
178    ///
179    /// Returns `None` if the `numId` is not mapped, the abstract definition
180    /// is missing, or the specific level is not defined.
181    #[must_use]
182    pub fn lookup(&self, num_id: u32, ilvl: u8) -> Option<&Level> {
183        let abstract_id = self.num_to_abstract.get(&num_id)?;
184        let abstract_num = self.abstract_nums.get(abstract_id)?;
185        abstract_num.levels.get(&ilvl)
186    }
187}
188
189/// Extracts a `u32` value from a named attribute (e.g. `w:abstractNumId="0"`).
190fn extract_u32_attr(tag: &quick_xml::events::BytesStart, attr_name: &[u8]) -> Option<u32> {
191    for attr in tag.attributes().flatten() {
192        if attr.key.as_ref() == attr_name {
193            let val = attr
194                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
195                .ok()?;
196            return val.parse::<u32>().ok();
197        }
198    }
199    None
200}
201
202/// Extracts a `u8` value from a named attribute (e.g. `w:ilvl="0"`).
203fn extract_u8_attr(tag: &quick_xml::events::BytesStart, attr_name: &[u8]) -> Option<u8> {
204    for attr in tag.attributes().flatten() {
205        if attr.key.as_ref() == attr_name {
206            let val = attr
207                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
208                .ok()?;
209            return val.parse::<u8>().ok();
210        }
211    }
212    None
213}
214
215/// Extracts the `w:val` attribute as a `String`.
216fn extract_val_attr(tag: &quick_xml::events::BytesStart) -> Option<String> {
217    for attr in tag.attributes().flatten() {
218        if attr.key.as_ref() == b"w:val" {
219            return attr
220                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
221                .ok()
222                .map(Cow::into_owned);
223        }
224    }
225    None
226}
227
228/// Extracts the `w:val` attribute as a `u32`.
229fn extract_val_attr_u32(tag: &quick_xml::events::BytesStart) -> Option<u32> {
230    extract_val_attr(tag).and_then(|v| v.parse::<u32>().ok())
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn parse_numbering_bullet_lists_unordered() {
239        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
240<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
241  <w:abstractNum w:abstractNumId="0">
242    <w:lvl w:ilvl="0">
243      <w:numFmt w:val="bullet"/>
244      <w:lvlText w:val="&#x2022;"/>
245    </w:lvl>
246    <w:lvl w:ilvl="1">
247      <w:numFmt w:val="bullet"/>
248      <w:lvlText w:val="&#x2013;"/>
249    </w:lvl>
250  </w:abstractNum>
251  <w:num w:numId="1">
252    <w:abstractNumId w:val="0"/>
253  </w:num>
254</w:numbering>"#;
255
256        let numbering = Numbering::parse(xml).unwrap();
257        let level0 = numbering.lookup(1, 0).unwrap();
258        assert!(!level0.ordered, "bullet should be unordered");
259        let level1 = numbering.lookup(1, 1).unwrap();
260        assert!(!level1.ordered, "bullet should be unordered");
261    }
262
263    #[test]
264    fn parse_numbering_decimal_lists_ordered() {
265        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
266<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
267  <w:abstractNum w:abstractNumId="1">
268    <w:lvl w:ilvl="0">
269      <w:start w:val="1"/>
270      <w:numFmt w:val="decimal"/>
271      <w:lvlText w:val="%1."/>
272    </w:lvl>
273  </w:abstractNum>
274  <w:num w:numId="2">
275    <w:abstractNumId w:val="1"/>
276  </w:num>
277</w:numbering>"#;
278
279        let numbering = Numbering::parse(xml).unwrap();
280        let level = numbering.lookup(2, 0).unwrap();
281        assert!(level.ordered, "decimal should be ordered");
282        assert_eq!(level.start, Some(1));
283    }
284
285    #[test]
286    fn parse_numbering_decimal_with_start_value() {
287        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
288<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
289  <w:abstractNum w:abstractNumId="0">
290    <w:lvl w:ilvl="0">
291      <w:start w:val="5"/>
292      <w:numFmt w:val="decimal"/>
293      <w:lvlText w:val="%1."/>
294    </w:lvl>
295  </w:abstractNum>
296  <w:num w:numId="1">
297    <w:abstractNumId w:val="0"/>
298  </w:num>
299</w:numbering>"#;
300
301        let numbering = Numbering::parse(xml).unwrap();
302        let level = numbering.lookup(1, 0).unwrap();
303        assert!(level.ordered);
304        assert_eq!(level.start, Some(5), "start should be 5");
305    }
306
307    #[test]
308    fn lookup_returns_correct_format_for_num_id() {
309        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
310<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
311  <w:abstractNum w:abstractNumId="0">
312    <w:lvl w:ilvl="0">
313      <w:numFmt w:val="bullet"/>
314    </w:lvl>
315  </w:abstractNum>
316  <w:abstractNum w:abstractNumId="1">
317    <w:lvl w:ilvl="0">
318      <w:start w:val="1"/>
319      <w:numFmt w:val="decimal"/>
320    </w:lvl>
321  </w:abstractNum>
322  <w:num w:numId="1">
323    <w:abstractNumId w:val="0"/>
324  </w:num>
325  <w:num w:numId="2">
326    <w:abstractNumId w:val="1"/>
327  </w:num>
328</w:numbering>"#;
329
330        let numbering = Numbering::parse(xml).unwrap();
331        // numId=1 -> abstractNumId=0 -> bullet (unordered)
332        assert!(!numbering.lookup(1, 0).unwrap().ordered);
333        // numId=2 -> abstractNumId=1 -> decimal (ordered)
334        assert!(numbering.lookup(2, 0).unwrap().ordered);
335        // Non-existent numId
336        assert!(numbering.lookup(99, 0).is_none());
337    }
338
339    #[test]
340    fn parse_numbering_roman_is_ordered() {
341        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
342<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
343  <w:abstractNum w:abstractNumId="0">
344    <w:lvl w:ilvl="0">
345      <w:start w:val="1"/>
346      <w:numFmt w:val="upperRoman"/>
347      <w:lvlText w:val="%1."/>
348    </w:lvl>
349    <w:lvl w:ilvl="1">
350      <w:start w:val="1"/>
351      <w:numFmt w:val="lowerLetter"/>
352      <w:lvlText w:val="%2)"/>
353    </w:lvl>
354  </w:abstractNum>
355  <w:num w:numId="1">
356    <w:abstractNumId w:val="0"/>
357  </w:num>
358</w:numbering>"#;
359
360        let numbering = Numbering::parse(xml).unwrap();
361        assert!(numbering.lookup(1, 0).unwrap().ordered);
362        assert!(numbering.lookup(1, 1).unwrap().ordered);
363    }
364
365    #[test]
366    fn parse_empty_numbering() {
367        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
368<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
369</w:numbering>"#;
370
371        let numbering = Numbering::parse(xml).unwrap();
372        assert!(numbering.num_to_abstract.is_empty());
373        assert!(numbering.abstract_nums.is_empty());
374        assert!(numbering.lookup(1, 0).is_none());
375    }
376}