Skip to main content

extend_docx/parse/
numbering.rs

1use std::collections::HashMap;
2
3use crate::model::{
4    NumberingAbstractDefinition, NumberingDefinitionSet, NumberingInstanceDefinition,
5    NumberingLevelDefinition, NumberingLevelSuffix, NumberingPictureBulletDefinition,
6};
7use crate::package::OoxmlPackage;
8use super::colors::normalize_hex_color;
9use crate::xml::{get_attribute, parse_integer_attribute};
10
11use super::context::ContentTypeLookup;
12use super::relationships::{bytes_to_base64, content_type_for_part, parse_part_relationships};
13use super::scan::{decode_xml_attribute, starts_with_ignore_ascii_case};
14use super::styles::{parse_paragraph_indent_from_xml, parse_text_style_from_xml};
15
16/// Mirrors TypeScript `pointsToPixels`.
17pub fn points_to_pixels(points: f64) -> i64 {
18    ((points * 96.0) / 72.0).round().max(1.0) as i64
19}
20
21/// Mirrors TypeScript `parseCssPointValue`.
22pub fn parse_css_point_value(style_value: Option<&str>, css_property: &str) -> Option<i64> {
23    let style_value = style_value?;
24    let property = css_property.to_ascii_lowercase();
25    let mut index = 0;
26    let bytes = style_value.as_bytes();
27
28    while index < bytes.len() {
29        if starts_with_ignore_ascii_case(&style_value[index..], &property) {
30            let mut cursor = index + property.len();
31            while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
32                cursor += 1;
33            }
34            if bytes.get(cursor) != Some(&b':') {
35                index += 1;
36                continue;
37            }
38            cursor += 1;
39            while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
40                cursor += 1;
41            }
42
43            let value_start = cursor;
44            let mut value_end = value_start;
45            while value_end < bytes.len()
46                && (bytes[value_end].is_ascii_digit() || bytes[value_end] == b'.')
47            {
48                value_end += 1;
49            }
50
51            let number_text = &style_value[value_start..value_end];
52            if number_text.is_empty() {
53                return None;
54            }
55
56            if !starts_with_ignore_ascii_case(&style_value[value_end..], "pt") {
57                return None;
58            }
59
60            let parsed = number_text.parse::<f64>().ok()?;
61            if !parsed.is_finite() || parsed <= 0.0 {
62                return None;
63            }
64
65            return Some(points_to_pixels(parsed));
66        }
67
68        index += 1;
69    }
70
71    None
72}
73
74/// Mirrors TypeScript `parseNumberingPictureBulletDefinitions`.
75pub fn parse_numbering_picture_bullet_definitions(
76    pkg: &OoxmlPackage,
77    numbering_xml: &str,
78    content_types: &ContentTypeLookup,
79) -> HashMap<i64, NumberingPictureBulletDefinition> {
80    parse_picture_bullets_with_assets(pkg, numbering_xml, content_types, &pkg.binary_assets)
81}
82
83fn parse_picture_bullets_with_assets(
84    pkg: &OoxmlPackage,
85    numbering_xml: &str,
86    content_types: &ContentTypeLookup,
87    binary_assets: &HashMap<String, Vec<u8>>,
88) -> HashMap<i64, NumberingPictureBulletDefinition> {
89    let mut picture_bullets = HashMap::new();
90    let numbering_relationships = parse_part_relationships(pkg, &crate::package::related_document_part_name(pkg, "numbering"));
91
92    for num_pic_bullet_xml in crate::xml::extract_balanced_tag_slices(numbering_xml, "w:numPicBullet") {
93        let num_pic_bullet_tag = super::scan::find_tag_token_ref(&num_pic_bullet_xml, "w:numPicBullet").unwrap_or_default();
94        let num_pic_bullet_id = get_attribute(&num_pic_bullet_tag, "w:numPicBulletId")
95            .and_then(|raw| raw.parse::<f64>().ok())
96            .filter(|value| value.is_finite());
97        let Some(num_pic_bullet_id) = num_pic_bullet_id else {
98            continue;
99        };
100
101        let shape_tag = super::scan::find_tag_token_ref(&num_pic_bullet_xml, "v:shape");
102        let shape_style = shape_tag.as_deref().and_then(|tag| get_attribute(tag, "style"));
103        let width_px = parse_css_point_value(shape_style.as_deref(), "width").map(|value| value as f64);
104        let height_px =
105            parse_css_point_value(shape_style.as_deref(), "height").map(|value| value as f64);
106
107        let image_tag = super::scan::find_tag_token_ref(&num_pic_bullet_xml, "v:imagedata");
108        let relationship_id = image_tag.as_deref().and_then(|tag| get_attribute(tag, "r:id"));
109        let image_part_name = relationship_id
110            .as_ref()
111            .and_then(|id| numbering_relationships.get(id).cloned());
112        let image_bytes = image_part_name
113            .as_ref()
114            .and_then(|part_name| binary_assets.get(part_name));
115        let image_content_type = image_part_name
116            .as_ref()
117            .and_then(|part_name| content_type_for_part(part_name, content_types));
118        let src = match (&image_part_name, &image_bytes, &image_content_type) {
119            (Some(_), Some(bytes), Some(content_type)) => Some(format!(
120                "data:{content_type};base64,{}",
121                bytes_to_base64(bytes)
122            )),
123            _ => None,
124        };
125
126        picture_bullets.insert(
127            num_pic_bullet_id.round() as i64,
128            NumberingPictureBulletDefinition {
129                num_pic_bullet_id: num_pic_bullet_id.round() as i64,
130                src,
131                width_px,
132                height_px,
133                part_name: image_part_name,
134                content_type: image_content_type,
135            },
136        );
137    }
138
139    picture_bullets
140}
141
142/// Mirrors TypeScript `parseNumberingLevelDefinition`.
143pub fn parse_numbering_level_definition(
144    level_xml: &str,
145    picture_bullets_by_id: &HashMap<i64, NumberingPictureBulletDefinition>,
146) -> Option<NumberingLevelDefinition> {
147    if level_xml.is_empty() {
148        return None;
149    }
150
151    let level_tag = super::scan::find_tag_token_ref(level_xml, "w:lvl").unwrap_or_default();
152    let ilvl = get_attribute(&level_tag, "w:ilvl")
153        .and_then(|raw| raw.parse::<f64>().ok())
154        .filter(|value| value.is_finite())?;
155
156    let start_tag = super::scan::find_tag_token_ref(level_xml, "w:start");
157    let lvl_restart_tag = super::scan::find_tag_token_ref(level_xml, "w:lvlRestart");
158    let is_lgl_tag = super::scan::find_tag_token_ref(level_xml, "w:isLgl");
159    let num_fmt_tag = super::scan::find_tag_token_ref(level_xml, "w:numFmt");
160    let lvl_text_tag = super::scan::find_tag_token_ref(level_xml, "w:lvlText");
161    let suffix_tag = super::scan::find_tag_token_ref(level_xml, "w:suff");
162    let level_run_properties_xml = crate::xml::extract_balanced_tag_slices(level_xml, "w:rPr")
163        .into_iter()
164        .next()
165        .or_else(|| super::scan::find_tag_token_ref(level_xml, "w:rPr"))
166        .unwrap_or_default();
167    let level_paragraph_properties_xml = crate::xml::extract_balanced_tag_slices(level_xml, "w:pPr")
168        .into_iter()
169        .next()
170        .or_else(|| super::scan::find_tag_token_ref(level_xml, "w:pPr"))
171        .unwrap_or_default();
172    let level_fonts_tag = super::scan::find_tag_token_ref(&level_run_properties_xml, "w:rFonts");
173    let level_color_tag = super::scan::find_tag_token_ref(&level_run_properties_xml, "w:color");
174    let level_run_style = parse_text_style_from_xml(&level_run_properties_xml, &Default::default());
175    let picture_bullet_tag = super::scan::find_tag_token_ref(level_xml, "w:lvlPicBulletId");
176    let suffix_raw = suffix_tag
177        .as_deref()
178        .and_then(|tag| get_attribute(tag, "w:val"))
179        .map(|value| value.to_ascii_lowercase());
180    let picture_bullet_id = picture_bullet_tag
181        .as_deref()
182        .and_then(|tag| get_attribute(tag, "w:val"))
183        .and_then(|raw| raw.parse::<f64>().ok())
184        .filter(|value| value.is_finite());
185
186    let bullet_font_family = level_fonts_tag
187        .as_deref()
188        .and_then(|tag| {
189            get_attribute(tag, "w:ascii")
190                .or_else(|| get_attribute(tag, "w:hAnsi"))
191                .or_else(|| get_attribute(tag, "w:eastAsia"))
192                .or_else(|| get_attribute(tag, "w:cs"))
193        })
194        .and_then(|value| decode_xml_attribute(Some(&value)))
195        .map(|value| value.trim().to_string())
196        .filter(|value| !value.is_empty());
197
198    let bullet_color = level_color_tag
199        .as_deref()
200        .and_then(|tag| get_attribute(tag, "w:val"))
201        .as_deref()
202        .and_then(|value| normalize_hex_color(Some(value)));
203
204    let suffix = match suffix_raw.as_deref() {
205        Some("tab") => Some(NumberingLevelSuffix::Tab),
206        Some("space") => Some(NumberingLevelSuffix::Space),
207        Some("nothing") => Some(NumberingLevelSuffix::Nothing),
208        _ => None,
209    };
210
211    let indent = if level_paragraph_properties_xml.is_empty() {
212        None
213    } else {
214        parse_paragraph_indent_from_xml(&level_paragraph_properties_xml)
215    };
216
217    let rounded_picture_bullet_id = picture_bullet_id.map(|value| value.round() as i64);
218
219    Some(NumberingLevelDefinition {
220        ilvl: ilvl.round().max(0.0) as i64,
221        start: start_tag
222            .as_deref()
223            .and_then(|tag| parse_integer_attribute(tag, "w:val")),
224        lvl_restart: lvl_restart_tag
225            .as_deref()
226            .and_then(|tag| parse_integer_attribute(tag, "w:val")),
227        is_lgl: is_lgl_tag.as_deref().map(|tag| {
228            !matches!(
229                get_attribute(tag, "w:val").as_deref(),
230                Some("0") | Some("false") | Some("off")
231            )
232        }),
233        format: num_fmt_tag.as_deref().and_then(|tag| get_attribute(tag, "w:val")),
234        text: lvl_text_tag
235            .as_deref()
236            .and_then(|tag| get_attribute(tag, "w:val"))
237            .and_then(|value| decode_xml_attribute(Some(&value))),
238        suffix,
239        indent,
240        run_style: level_run_style,
241        bullet_font_family,
242        bullet_color,
243        picture_bullet_id: rounded_picture_bullet_id,
244        picture_bullet: rounded_picture_bullet_id
245            .and_then(|id| picture_bullets_by_id.get(&id).cloned()),
246    })
247}
248
249/// Mirrors TypeScript `parseNumberingDefinitions`.
250pub fn parse_numbering_definitions(
251    pkg: &OoxmlPackage,
252    content_types: &ContentTypeLookup,
253) -> Option<NumberingDefinitionSet> {
254    parse_numbering_with_assets(pkg, content_types, &pkg.binary_assets)
255}
256
257pub(crate) fn parse_numbering_with_assets(
258    pkg: &OoxmlPackage,
259    content_types: &ContentTypeLookup,
260    binary_assets: &HashMap<String, Vec<u8>>,
261) -> Option<NumberingDefinitionSet> {
262    let numbering_xml = crate::package::related_document_part(pkg, "numbering")?.content.as_str();
263    #[cfg(not(target_arch = "wasm32"))]
264    if numbering_xml.len() <= 128 * 1024
265        && !super::scan::contains_tag(numbering_xml, "w:numPicBullet")
266    {
267        return numbering_templates::parse(numbering_xml);
268    }
269    let picture_bullets_by_id =
270        parse_picture_bullets_with_assets(pkg, numbering_xml, content_types, binary_assets);
271    parse_numbering_xml(numbering_xml, &picture_bullets_by_id)
272}
273
274fn parse_numbering_xml(
275    numbering_xml: &str,
276    picture_bullets_by_id: &HashMap<i64, NumberingPictureBulletDefinition>,
277) -> Option<NumberingDefinitionSet> {
278    let parsed_abstracts: Vec<IntermediateAbstractDefinition> =
279        crate::xml::extract_balanced_tag_slices(numbering_xml, "w:abstractNum")
280            .into_iter()
281            .filter_map(|abstract_xml| {
282                let abstract_tag =
283                    super::scan::find_tag_token_ref(&abstract_xml, "w:abstractNum").unwrap_or_default();
284                let abstract_num_id = get_attribute(&abstract_tag, "w:abstractNumId")
285                    .and_then(|raw| raw.parse::<f64>().ok())
286                    .filter(|value| value.is_finite())?;
287
288                let style_link_tag = super::scan::find_tag_token_ref(&abstract_xml, "w:styleLink");
289                let num_style_link_tag = super::scan::find_tag_token_ref(&abstract_xml, "w:numStyleLink");
290                let mut levels: Vec<NumberingLevelDefinition> =
291                    crate::xml::extract_balanced_tag_slices(&abstract_xml, "w:lvl")
292                        .into_iter()
293                        .filter_map(|level_xml| {
294                            parse_numbering_level_definition(&level_xml, picture_bullets_by_id)
295                        })
296                        .collect();
297                levels.sort_by_key(|level| level.ilvl);
298
299                Some(IntermediateAbstractDefinition {
300                    abstract_num_id: abstract_num_id.round() as i64,
301                    style_link: style_link_tag
302                        .as_deref()
303                        .and_then(|tag| get_attribute(tag, "w:val")),
304                    num_style_link: num_style_link_tag
305                        .as_deref()
306                        .and_then(|tag| get_attribute(tag, "w:val")),
307                    levels,
308                })
309            })
310            .collect();
311
312    let mut style_linked_levels = HashMap::new();
313    for abstract_definition in &parsed_abstracts {
314        if let Some(style_link) = abstract_definition.style_link.as_deref() {
315            if !abstract_definition.levels.is_empty() {
316                style_linked_levels.insert(style_link.to_string(), abstract_definition.levels.clone());
317            }
318        }
319    }
320
321    let abstracts: Vec<NumberingAbstractDefinition> = parsed_abstracts
322        .into_iter()
323        .map(|abstract_definition| {
324            let linked_levels = if !abstract_definition.levels.is_empty() {
325                abstract_definition.levels
326            } else {
327                abstract_definition
328                    .num_style_link
329                    .as_deref()
330                    .and_then(|style_link| style_linked_levels.get(style_link).cloned())
331                    .unwrap_or_default()
332            };
333
334            NumberingAbstractDefinition {
335                abstract_num_id: abstract_definition.abstract_num_id,
336                levels: linked_levels,
337            }
338        })
339        .collect();
340
341    let instances: Vec<NumberingInstanceDefinition> =
342        crate::xml::extract_balanced_tag_slices(numbering_xml, "w:num")
343            .into_iter()
344            .filter_map(|num_xml| {
345                let num_tag = super::scan::find_tag_token_ref(&num_xml, "w:num").unwrap_or_default();
346                let num_id = get_attribute(&num_tag, "w:numId")
347                    .and_then(|raw| raw.parse::<f64>().ok())
348                    .filter(|value| value.is_finite())?;
349
350                let abstract_num_id_tag = super::scan::find_tag_token_ref(&num_xml, "w:abstractNumId");
351                let abstract_num_id = abstract_num_id_tag
352                    .as_deref()
353                    .and_then(|tag| get_attribute(tag, "w:val"))
354                    .and_then(|raw| raw.parse::<f64>().ok())
355                    .filter(|value| value.is_finite())?;
356
357                let mut level_start_overrides = HashMap::new();
358                let mut level_overrides = Vec::new();
359
360                for override_xml in crate::xml::extract_balanced_tag_slices(&num_xml, "w:lvlOverride") {
361                    let override_tag =
362                        super::scan::find_tag_token_ref(&override_xml, "w:lvlOverride").unwrap_or_default();
363                    let override_level = get_attribute(&override_tag, "w:ilvl")
364                        .and_then(|raw| raw.parse::<f64>().ok())
365                        .filter(|value| value.is_finite())?;
366
367                    let start_override_tag = super::scan::find_tag_token_ref(&override_xml, "w:startOverride");
368                    let start_override = start_override_tag
369                        .as_deref()
370                        .and_then(|tag| parse_integer_attribute(tag, "w:val"));
371                    if let Some(start_override) = start_override.filter(|value| *value > 0) {
372                        level_start_overrides
373                            .insert(override_level.round().max(0.0) as i64, start_override);
374                    }
375
376                    if let Some(level_xml) =
377                        crate::xml::extract_balanced_tag_slices(&override_xml, "w:lvl").into_iter().next()
378                    {
379                        if let Some(parsed_level) =
380                            parse_numbering_level_definition(&level_xml, picture_bullets_by_id)
381                        {
382                            level_overrides.push(parsed_level);
383                        }
384                    }
385                }
386
387                Some(NumberingInstanceDefinition {
388                    num_id: num_id.round() as i64,
389                    abstract_num_id: abstract_num_id.round() as i64,
390                    level_start_overrides: if level_start_overrides.is_empty() {
391                        None
392                    } else {
393                        Some(
394                            level_start_overrides
395                                .into_iter()
396                                .map(|(key, value)| (key.to_string(), value))
397                                .collect(),
398                        )
399                    },
400                    level_overrides: if level_overrides.is_empty() {
401                        None
402                    } else {
403                        Some(level_overrides)
404                    },
405                })
406            })
407            .collect();
408
409    if abstracts.is_empty() && instances.is_empty() {
410        return None;
411    }
412
413    Some(NumberingDefinitionSet {
414        abstracts,
415        instances,
416    })
417}
418
419struct IntermediateAbstractDefinition {
420    abstract_num_id: i64,
421    style_link: Option<String>,
422    num_style_link: Option<String>,
423    levels: Vec<NumberingLevelDefinition>,
424}
425
426#[cfg(not(target_arch = "wasm32"))]
427mod numbering_templates {
428    use super::*;
429    use std::collections::VecDeque;
430    use std::sync::{Arc, LazyLock};
431
432    type Entry = Arc<(String, Arc<Option<NumberingDefinitionSet>>)>;
433    static TEMPLATES: LazyLock<super::super::cache::Snapshot<VecDeque<Entry>>> =
434        LazyLock::new(|| super::super::cache::Snapshot::new(VecDeque::new()));
435
436    pub(super) fn parse(xml: &str) -> Option<NumberingDefinitionSet> {
437        if let Some(value) = TEMPLATES.read(|entries| entries.iter()
438            .find(|entry| entry.0 == xml).map(|entry| Arc::clone(&entry.1))) {
439            return (*value).clone();
440        }
441        let parsed = parse_numbering_xml(xml, &HashMap::new());
442        let entry = Arc::new((xml.to_owned(), Arc::new(parsed.clone())));
443        TEMPLATES.publish(move |entries| {
444            let mut next = entries.clone();
445            if !next.iter().any(|existing| existing.0 == entry.0) {
446                if next.len() == 4 { next.pop_front(); }
447                next.push_back(entry);
448            }
449            next
450        });
451        parsed
452    }
453}
454
455#[cfg(all(test, not(target_arch = "wasm32")))]
456mod template_tests {
457    use super::*;
458    use crate::package::OoxmlPart;
459
460    fn package(xml: String) -> OoxmlPackage {
461        let name = "word/numbering.xml".to_owned();
462        OoxmlPackage {
463            parts: HashMap::from([(name.clone(), OoxmlPart { name, content: xml })]),
464            binary_assets: HashMap::new(),
465            warnings: Vec::new(),
466        }
467    }
468
469    #[test]
470    fn definition_reuse_preserves_changes_and_owned_results() {
471        for value in (1..13).chain((1..13).rev()) {
472            let xml = format!(r#"<w:numbering><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:start w:val="{value}"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1.{value}"/><w:rPr><w:color w:val="123456"/></w:rPr></w:lvl></w:abstractNum><w:num w:numId="2"><w:abstractNumId w:val="1"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="{value}"/></w:lvlOverride></w:num></w:numbering>"#);
473            let expected = parse_numbering_xml(&xml, &HashMap::new());
474            let pkg = package(xml);
475            let mut actual = parse_numbering_definitions(&pkg, &ContentTypeLookup::default());
476            assert_eq!(actual, expected);
477            actual.as_mut().unwrap().abstracts.clear();
478            assert_eq!(parse_numbering_definitions(&pkg, &ContentTypeLookup::default()), expected);
479        }
480    }
481
482    #[test]
483    fn picture_bullet_assets_are_resolved_for_each_package() {
484        let xml = r#"<w:numbering><w:numPicBullet w:numPicBulletId="5"><w:pict><v:shape><v:imagedata r:id="rId1"/></v:shape></w:pict></w:numPicBullet><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:lvlPicBulletId w:val="5"/></w:lvl></w:abstractNum></w:numbering>"#;
485        let mut pkg = package(xml.to_owned());
486        let name = "word/_rels/numbering.xml.rels".to_owned();
487        pkg.parts.insert(name.clone(), OoxmlPart { name, content: r#"<Relationships><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/bullet.png"/></Relationships>"#.to_owned() });
488        let mut types = ContentTypeLookup::default();
489        types.default_by_extension.insert("png".to_owned(), "image/png".to_owned());
490        pkg.binary_assets.insert("word/media/bullet.png".to_owned(), vec![1, 2, 3]);
491        let before = parse_numbering_definitions(&pkg, &types).unwrap();
492        pkg.binary_assets.insert("word/media/bullet.png".to_owned(), vec![4, 5, 6]);
493        let after = parse_numbering_definitions(&pkg, &types).unwrap();
494        assert_ne!(before.abstracts[0].levels[0].picture_bullet, after.abstracts[0].levels[0].picture_bullet);
495    }
496}