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
16pub fn points_to_pixels(points: f64) -> i64 {
18 ((points * 96.0) / 72.0).round().max(1.0) as i64
19}
20
21pub 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
74pub 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
142pub 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
249pub 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}