1use std::collections::HashMap;
2
3use crate::model::{
4 ImageHorizontalAlign, ImageVerticalAlign, ParagraphAlignment, ParagraphBorderSet,
5 ParagraphBorderStyle, ParagraphIndent, ParagraphLineRule, ParagraphSpacing, ParagraphTabStop,
6 ParagraphTabStopAlignment, ParagraphTabStopLeader, TableBorderSet, TableBorderStyle,
7 TextRunBorderStyle, TextStyle,
8};
9use crate::package::OoxmlPart;
10use crate::parse::context::{ContentTypeLookup, ThemeColorMap, ThemeFontMap};
11use crate::parse::re;
12use crate::xml::{
13 decode_xml_entities, extract_balanced_tag_blocks, get_attribute, normalize_alignment,
14 parse_integer_attribute,
15};
16#[cfg(target_arch = "wasm32")]
17use base64::{engine::general_purpose::STANDARD, Engine as _};
18
19pub fn regex_capture(xml: &str, pattern: &str) -> Option<String> {
20 if xml.is_empty() {
21 return None;
22 }
23 re::get(pattern)?
24 .captures(xml)
25 .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
26}
27
28pub fn regex_capture_tag(xml: &str, pattern: &str) -> Option<String> {
29 if xml.is_empty() {
30 return None;
31 }
32 re::get(pattern)?.find(xml).map(|m| m.as_str().to_string())
33}
34
35pub fn normalize_hex_color(value: Option<&str>) -> Option<String> {
36 let value = value?.trim();
37 if value.is_empty() || value.eq_ignore_ascii_case("auto") {
38 return None;
39 }
40
41 if re::get(r"^[0-9a-fA-F]{6}$")?.is_match(value) {
42 return Some(format!("#{value}"));
43 }
44
45 if re::get(r"^[0-9a-fA-F]{3}$")?.is_match(value) {
46 let expanded: String = value.chars().flat_map(|c| [c, c]).collect();
47 return Some(format!("#{expanded}"));
48 }
49
50 if re::get(r"^#[0-9a-fA-F]{6}$")?.is_match(value) {
51 return Some(value.to_string());
52 }
53
54 None
55}
56
57pub fn emu_to_pixels(value: Option<&str>) -> Option<f64> {
58 let value = value?;
59 if value.is_empty() {
60 return None;
61 }
62 let parsed: f64 = value.parse().ok()?;
63 if !parsed.is_finite() {
64 return None;
65 }
66 Some(((parsed / 9525.0) * 1000.0).round() / 1000.0)
67}
68
69pub fn to_model_alignment(raw: Option<&str>) -> Option<ParagraphAlignment> {
70 match normalize_alignment(raw)? {
71 "left" => Some(ParagraphAlignment::Left),
72 "center" => Some(ParagraphAlignment::Center),
73 "right" => Some(ParagraphAlignment::Right),
74 "justify" => Some(ParagraphAlignment::Justify),
75 _ => None,
76 }
77}
78
79pub fn normalize_heading_level(value: Option<&str>) -> Option<crate::model::HeadingLevel> {
80 let value = value?;
81 let caps = re::get(r"(?i)heading\s*([1-9])")?.captures(value)?;
82 let level: i64 = caps.get(1)?.as_str().parse().ok()?;
83 match level {
84 1 => Some(crate::model::HeadingLevel::One),
85 2 => Some(crate::model::HeadingLevel::Two),
86 3 => Some(crate::model::HeadingLevel::Three),
87 4 => Some(crate::model::HeadingLevel::Four),
88 5 => Some(crate::model::HeadingLevel::Five),
89 6 => Some(crate::model::HeadingLevel::Six),
90 7 => Some(crate::model::HeadingLevel::Seven),
91 8 => Some(crate::model::HeadingLevel::Eight),
92 9 => Some(crate::model::HeadingLevel::Nine),
93 _ => None,
94 }
95}
96
97pub fn prefer_alternate_content_choice(xml: &str) -> String {
98 active_xml(xml).into_owned()
99}
100
101pub(crate) fn active_xml(xml: &str) -> std::borrow::Cow<'_, str> {
102 static ALTERNATE_CONTENT: std::sync::LazyLock<memchr::memmem::Finder<'static>> =
103 std::sync::LazyLock::new(|| memchr::memmem::Finder::new(b"<mc:AlternateContent"));
104 if ALTERNATE_CONTENT.find(xml.as_bytes()).is_none() {
105 return std::borrow::Cow::Borrowed(xml);
106 }
107
108 if let Some(choices) = crate::xml_index::alternate_choices(xml) {
109 if choices.is_empty() {
110 return std::borrow::Cow::Borrowed(xml);
111 }
112 let mut output = String::with_capacity(xml.len());
113 let mut cursor = 0;
114 for (alternate, content) in choices {
115 output.push_str(&xml[cursor..alternate.start]);
116 output.push_str(&xml[content.start..content.end]);
117 cursor = alternate.end;
118 }
119 output.push_str(&xml[cursor..]);
120 return std::borrow::Cow::Owned(output);
121 }
122
123 if let Some(output) = balanced_active_xml(xml) {
124 return std::borrow::Cow::Owned(output);
125 }
126
127 if let Some(output) = scan_active_xml(xml) {
128 return output;
129 }
130
131 let re = re::get_unchecked(r"(?is)<mc:AlternateContent\b[\s\S]*?</mc:AlternateContent>");
132 re.replace_all(xml, |caps: ®ex::Captures| {
133 let alternate_xml = &caps[0];
134 let choice_re = re::get_unchecked(r"(?is)<mc:Choice\b[\s\S]*?</mc:Choice>");
135 let Some(choice_xml) = choice_re.find(alternate_xml) else {
136 return alternate_xml.to_string();
137 };
138 let choice = choice_xml.as_str();
139 let without_open = re::get_unchecked(r"(?i)<mc:Choice\b[^>]*>")
140 .replace(choice, "")
141 .to_string();
142 re::get_unchecked(r"(?i)</mc:Choice>")
143 .replace(&without_open, "")
144 .to_string()
145 })
146}
147
148fn balanced_active_xml(xml: &str) -> Option<String> {
149 let mut active = xml.to_string();
150 loop {
151 let ranges = crate::xml::extract_balanced_tag_ranges(&active, "mc:AlternateContent");
152 if ranges.is_empty() {
153 return Some(active);
154 }
155 let mut output = String::with_capacity(active.len());
156 let mut cursor = 0;
157 for range in ranges {
158 let alternate = &active[range.start..range.end];
159 let branch = crate::xml::direct_child_tag(alternate, "mc:Choice")
160 .or_else(|| crate::xml::direct_child_tag(alternate, "mc:Fallback"))?;
161 let begin = crate::xml::tag_token_end(branch, 0)?;
162 let end = branch.rfind("</")?;
163 if begin > end {
164 return None;
165 }
166 output.push_str(&active[cursor..range.start]);
167 output.push_str(&branch[begin..end]);
168 cursor = range.end;
169 }
170 output.push_str(&active[cursor..]);
171 if output.len() >= active.len() {
172 return None;
173 }
174 active = output;
175 }
176}
177
178fn scan_active_xml(xml: &str) -> Option<std::borrow::Cow<'_, str>> {
179 fn opening(xml: &str, mut start: usize, prefix: &str) -> Result<Option<usize>, ()> {
180 while let Some(position) = crate::xml::find_ignore_ascii_case(xml, start, prefix) {
181 let after = position + prefix.len();
182 match xml.as_bytes().get(after) {
183 Some(byte) if !byte.is_ascii() => return Err(()),
184 Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_' => start = after,
185 _ => return Ok(Some(position)),
186 }
187 }
188 Ok(None)
189 }
190 const ALTERNATE_OPEN: &str = "<mc:AlternateContent";
191 const ALTERNATE_CLOSE: &str = "</mc:AlternateContent>";
192 const CHOICE_OPEN: &str = "<mc:Choice";
193 const CHOICE_CLOSE: &str = "</mc:Choice>";
194 let mut output: Option<String> = None;
195 let mut cursor = 0;
196 while let Some(start) = opening(xml, cursor, ALTERNATE_OPEN).ok()? {
197 let Some(close) = crate::xml::find_ignore_ascii_case(xml, start + ALTERNATE_OPEN.len(), ALTERNATE_CLOSE) else {
198 break;
199 };
200 let end = close + ALTERNATE_CLOSE.len();
201 let alternate = &xml[start..end];
202 let target = output.get_or_insert_with(|| String::with_capacity(xml.len()));
203 target.push_str(&xml[cursor..start]);
204 let choice = opening(alternate, 0, CHOICE_OPEN).ok()?.and_then(|start| {
205 let close = crate::xml::find_ignore_ascii_case(alternate, start + CHOICE_OPEN.len(), CHOICE_CLOSE)?;
206 let content = start + alternate[start..close + CHOICE_CLOSE.len()].find('>')? + 1;
207 Some((content, close))
208 });
209 if let Some((content, close)) = choice {
210 if content < close {
211 target.push_str(&alternate[content..close]);
212 }
213 } else {
214 target.push_str(alternate);
215 }
216 cursor = end;
217 }
218 Some(match output {
219 Some(mut output) => {
220 output.push_str(&xml[cursor..]);
221 std::borrow::Cow::Owned(output)
222 }
223 None => std::borrow::Cow::Borrowed(xml),
224 })
225}
226
227pub fn merge_text_styles(styles: &[Option<TextStyle>]) -> Option<TextStyle> {
228 merge_text_style_refs(&styles.iter().map(Option::as_ref).collect::<Vec<_>>())
229}
230
231pub(crate) fn merge_text_style_refs(styles: &[Option<&TextStyle>]) -> Option<TextStyle> {
232 let mut present = styles.iter().flatten();
233 let first = *present.next()?;
234 if present.next().is_none() {
235 return Some(first.clone());
236 }
237 let mut merged = TextStyle {
238 bold: None,
239 italic: None,
240 underline: None,
241 strike: None,
242 all_caps: None,
243 small_caps: None,
244 color: None,
245 highlight: None,
246 background_color: None,
247 font_size_pt: None,
248 font_size_cs_pt: None,
249 font_family: None,
250 source_font_family: None,
251 font_family_ascii: None,
252 font_family_h_ansi: None,
253 font_family_east_asia: None,
254 font_family_cs: None,
255 font_theme_ascii: None,
256 font_theme_h_ansi: None,
257 font_theme_east_asia: None,
258 font_theme_cs: None,
259 resolved_font_family_ascii: None,
260 resolved_font_family_h_ansi: None,
261 resolved_font_family_east_asia: None,
262 resolved_font_family_cs: None,
263 font_hint: None,
264 language: None,
265 language_east_asia: None,
266 language_bidi: None,
267 right_to_left: None,
268 complex_script: None,
269 character_spacing_twips: None,
270 character_scale_percent: None,
271 source_character_scale: None,
272 kerning_min_font_size_pt: None,
273 ligatures: None,
274 contextual_alternates: None,
275 vertical_align: None,
276 run_border: None,
277 text_shadow: None,
278 text_outline: None,
279 };
280 let mut has_any = false;
281
282 for style in styles.iter().rev().flatten() {
283 has_any = true;
284 if merged.bold.is_none() {
285 merged.bold = style.bold;
286 }
287 if merged.italic.is_none() {
288 merged.italic = style.italic;
289 }
290 if merged.underline.is_none() {
291 merged.underline = style.underline;
292 }
293 if merged.strike.is_none() {
294 merged.strike = style.strike;
295 }
296 if merged.all_caps.is_none() {
297 merged.all_caps = style.all_caps;
298 }
299 if merged.small_caps.is_none() {
300 merged.small_caps = style.small_caps;
301 }
302 if merged.color.is_none() {
303 merged.color = style.color.clone();
304 }
305 if merged.highlight.is_none() {
306 merged.highlight = style.highlight.clone();
307 }
308 if merged.background_color.is_none() {
309 merged.background_color = style.background_color.clone();
310 }
311 if merged.font_size_pt.is_none() {
312 merged.font_size_pt = style.font_size_pt;
313 }
314 if merged.font_size_cs_pt.is_none() {
315 merged.font_size_cs_pt = style.font_size_cs_pt;
316 }
317 if merged.font_family.is_none() {
318 merged.font_family = style.font_family.clone();
319 }
320 if merged.source_font_family.is_none() {
321 merged.source_font_family = style.source_font_family.clone();
322 }
323 if merged.font_family_ascii.is_none() {
324 merged.font_family_ascii = style.font_family_ascii.clone();
325 }
326 if merged.font_family_h_ansi.is_none() {
327 merged.font_family_h_ansi = style.font_family_h_ansi.clone();
328 }
329 if merged.font_family_east_asia.is_none() {
330 merged.font_family_east_asia = style.font_family_east_asia.clone();
331 }
332 if merged.font_family_cs.is_none() {
333 merged.font_family_cs = style.font_family_cs.clone();
334 }
335 if merged.font_theme_ascii.is_none() {
336 merged.font_theme_ascii = style.font_theme_ascii.clone();
337 }
338 if merged.font_theme_h_ansi.is_none() {
339 merged.font_theme_h_ansi = style.font_theme_h_ansi.clone();
340 }
341 if merged.font_theme_east_asia.is_none() {
342 merged.font_theme_east_asia = style.font_theme_east_asia.clone();
343 }
344 if merged.font_theme_cs.is_none() {
345 merged.font_theme_cs = style.font_theme_cs.clone();
346 }
347 if merged.resolved_font_family_ascii.is_none() {
348 merged.resolved_font_family_ascii = style.resolved_font_family_ascii.clone();
349 }
350 if merged.resolved_font_family_h_ansi.is_none() {
351 merged.resolved_font_family_h_ansi = style.resolved_font_family_h_ansi.clone();
352 }
353 if merged.resolved_font_family_east_asia.is_none() {
354 merged.resolved_font_family_east_asia = style.resolved_font_family_east_asia.clone();
355 }
356 if merged.resolved_font_family_cs.is_none() {
357 merged.resolved_font_family_cs = style.resolved_font_family_cs.clone();
358 }
359 if merged.font_hint.is_none() {
360 merged.font_hint = style.font_hint.clone();
361 }
362 if merged.language.is_none() {
363 merged.language = style.language.clone();
364 }
365 if merged.language_east_asia.is_none() {
366 merged.language_east_asia = style.language_east_asia.clone();
367 }
368 if merged.language_bidi.is_none() {
369 merged.language_bidi = style.language_bidi.clone();
370 }
371 if merged.right_to_left.is_none() {
372 merged.right_to_left = style.right_to_left;
373 }
374 if merged.complex_script.is_none() {
375 merged.complex_script = style.complex_script;
376 }
377 if merged.character_scale_percent.is_none() && merged.source_character_scale.is_none() {
378 merged.character_scale_percent = style.character_scale_percent;
379 merged.source_character_scale = style.source_character_scale.clone();
380 }
381 if merged.character_spacing_twips.is_none() {
382 merged.character_spacing_twips = style.character_spacing_twips;
383 }
384 if merged.kerning_min_font_size_pt.is_none() {
385 merged.kerning_min_font_size_pt = style.kerning_min_font_size_pt;
386 }
387 if merged.ligatures.is_none() {
388 merged.ligatures = style.ligatures;
389 }
390 if merged.contextual_alternates.is_none() {
391 merged.contextual_alternates = style.contextual_alternates;
392 }
393 if merged.vertical_align.is_none() {
394 merged.vertical_align = style.vertical_align;
395 }
396 if merged.run_border.is_none() {
397 merged.run_border = style.run_border.clone();
398 }
399 if merged.text_shadow.is_none() {
400 merged.text_shadow = style.text_shadow.clone();
401 }
402 if merged.text_outline.is_none() {
403 merged.text_outline = style.text_outline.clone();
404 }
405 }
406
407 if has_any {
408 Some(merged)
409 } else {
410 None
411 }
412}
413
414pub fn merge_paragraph_spacing(
415 inherited: Option<&ParagraphSpacing>,
416 direct: Option<&ParagraphSpacing>,
417) -> Option<ParagraphSpacing> {
418 if inherited.is_none() && direct.is_none() {
419 return None;
420 }
421 let merged = ParagraphSpacing {
422 before_twips: direct
423 .and_then(|d| d.before_twips)
424 .or(inherited.and_then(|i| i.before_twips)),
425 after_twips: direct
426 .and_then(|d| d.after_twips)
427 .or(inherited.and_then(|i| i.after_twips)),
428 before_auto_spacing: direct
429 .and_then(|d| d.before_auto_spacing)
430 .or(inherited.and_then(|i| i.before_auto_spacing)),
431 after_auto_spacing: direct
432 .and_then(|d| d.after_auto_spacing)
433 .or(inherited.and_then(|i| i.after_auto_spacing)),
434 line_twips: direct
435 .and_then(|d| d.line_twips)
436 .or(inherited.and_then(|i| i.line_twips)),
437 line_rule: direct
438 .and_then(|d| d.line_rule)
439 .or(inherited.and_then(|i| i.line_rule)),
440 };
441 if merged.before_twips.is_none()
442 && merged.after_twips.is_none()
443 && merged.before_auto_spacing.is_none()
444 && merged.after_auto_spacing.is_none()
445 && merged.line_twips.is_none()
446 && merged.line_rule.is_none()
447 {
448 return None;
449 }
450 Some(merged)
451}
452
453pub fn merge_paragraph_indent(
454 inherited: Option<&ParagraphIndent>,
455 direct: Option<&ParagraphIndent>,
456) -> Option<ParagraphIndent> {
457 if inherited.is_none() && direct.is_none() {
458 return None;
459 }
460 let merged = ParagraphIndent {
461 left_twips: direct
462 .and_then(|d| d.left_twips)
463 .or(inherited.and_then(|i| i.left_twips)),
464 right_twips: direct
465 .and_then(|d| d.right_twips)
466 .or(inherited.and_then(|i| i.right_twips)),
467 first_line_twips: direct
468 .and_then(|d| d.first_line_twips)
469 .or(inherited.and_then(|i| i.first_line_twips)),
470 hanging_twips: direct
471 .and_then(|d| d.hanging_twips)
472 .or(inherited.and_then(|i| i.hanging_twips)),
473 };
474 if merged.left_twips.is_none()
475 && merged.right_twips.is_none()
476 && merged.first_line_twips.is_none()
477 && merged.hanging_twips.is_none()
478 {
479 return None;
480 }
481 Some(merged)
482}
483
484pub fn merge_paragraph_boolean(inherited: Option<bool>, direct: Option<bool>) -> Option<bool> {
485 direct.or(inherited)
486}
487
488pub fn merge_paragraph_border_style(
489 inherited: Option<&ParagraphBorderStyle>,
490 direct: Option<&ParagraphBorderStyle>,
491) -> Option<ParagraphBorderStyle> {
492 if inherited.is_none() && direct.is_none() {
493 return None;
494 }
495 let border_type = direct
496 .map(|d| d.border_type.clone())
497 .or_else(|| inherited.map(|i| i.border_type.clone()))?;
498 Some(ParagraphBorderStyle {
499 border_type,
500 color: direct
501 .and_then(|d| d.color.clone())
502 .or_else(|| inherited.and_then(|i| i.color.clone())),
503 size_eighth_pt: direct
504 .and_then(|d| d.size_eighth_pt)
505 .or(inherited.and_then(|i| i.size_eighth_pt)),
506 space_pt: direct
507 .and_then(|d| d.space_pt)
508 .or(inherited.and_then(|i| i.space_pt)),
509 frame: direct
510 .and_then(|d| d.frame)
511 .or(inherited.and_then(|i| i.frame)),
512 shadow: direct
513 .and_then(|d| d.shadow)
514 .or(inherited.and_then(|i| i.shadow)),
515 })
516}
517
518pub fn merge_paragraph_border_sets(
519 inherited: Option<&ParagraphBorderSet>,
520 direct: Option<&ParagraphBorderSet>,
521) -> Option<ParagraphBorderSet> {
522 if inherited.is_none() && direct.is_none() {
523 return None;
524 }
525 let merged = ParagraphBorderSet {
526 top: merge_paragraph_border_style(
527 inherited.and_then(|i| i.top.as_ref()),
528 direct.and_then(|d| d.top.as_ref()),
529 ),
530 right: merge_paragraph_border_style(
531 inherited.and_then(|i| i.right.as_ref()),
532 direct.and_then(|d| d.right.as_ref()),
533 ),
534 bottom: merge_paragraph_border_style(
535 inherited.and_then(|i| i.bottom.as_ref()),
536 direct.and_then(|d| d.bottom.as_ref()),
537 ),
538 left: merge_paragraph_border_style(
539 inherited.and_then(|i| i.left.as_ref()),
540 direct.and_then(|d| d.left.as_ref()),
541 ),
542 between: merge_paragraph_border_style(
543 inherited.and_then(|i| i.between.as_ref()),
544 direct.and_then(|d| d.between.as_ref()),
545 ),
546 bar: merge_paragraph_border_style(
547 inherited.and_then(|i| i.bar.as_ref()),
548 direct.and_then(|d| d.bar.as_ref()),
549 ),
550 };
551 if merged.top.is_none()
552 && merged.right.is_none()
553 && merged.bottom.is_none()
554 && merged.left.is_none()
555 && merged.between.is_none()
556 && merged.bar.is_none()
557 {
558 return None;
559 }
560 Some(merged)
561}
562
563pub fn merge_paragraph_tab_stops(
564 inherited: Option<&[ParagraphTabStop]>,
565 direct: Option<&[ParagraphTabStop]>,
566) -> Option<Vec<ParagraphTabStop>> {
567 if inherited.is_none() && direct.is_none() {
568 return None;
569 }
570 let mut by_position: HashMap<i64, ParagraphTabStop> = HashMap::new();
571 for stop in inherited.unwrap_or(&[]) {
572 if let Some(pos) = stop.position_twips {
573 by_position.insert(pos, stop.clone());
574 }
575 }
576 for stop in direct.unwrap_or(&[]) {
577 if let Some(pos) = stop.position_twips {
578 by_position.insert(pos, stop.clone());
579 }
580 }
581 if by_position.is_empty() {
582 return None;
583 }
584 let mut combined: Vec<_> = by_position.into_values().collect();
585 combined.sort_by_key(|s| s.position_twips.unwrap_or(0));
586 Some(combined)
587}
588
589pub fn merge_table_border_sets(
590 inherited: Option<&TableBorderSet>,
591 direct: Option<&TableBorderSet>,
592) -> Option<TableBorderSet> {
593 if inherited.is_none() && direct.is_none() {
594 return None;
595 }
596 let merged = TableBorderSet {
597 top: direct
598 .and_then(|d| d.top.clone())
599 .or_else(|| inherited.and_then(|i| i.top.clone())),
600 right: direct
601 .and_then(|d| d.right.clone())
602 .or_else(|| inherited.and_then(|i| i.right.clone())),
603 bottom: direct
604 .and_then(|d| d.bottom.clone())
605 .or_else(|| inherited.and_then(|i| i.bottom.clone())),
606 left: direct
607 .and_then(|d| d.left.clone())
608 .or_else(|| inherited.and_then(|i| i.left.clone())),
609 inside_h: direct
610 .and_then(|d| d.inside_h.clone())
611 .or_else(|| inherited.and_then(|i| i.inside_h.clone())),
612 inside_v: direct
613 .and_then(|d| d.inside_v.clone())
614 .or_else(|| inherited.and_then(|i| i.inside_v.clone())),
615 tl2br: direct
616 .and_then(|d| d.tl2br.clone())
617 .or_else(|| inherited.and_then(|i| i.tl2br.clone())),
618 tr2bl: direct
619 .and_then(|d| d.tr2bl.clone())
620 .or_else(|| inherited.and_then(|i| i.tr2bl.clone())),
621 };
622 if merged.top.is_none()
623 && merged.right.is_none()
624 && merged.bottom.is_none()
625 && merged.left.is_none()
626 && merged.inside_h.is_none()
627 && merged.inside_v.is_none()
628 && merged.tl2br.is_none()
629 && merged.tr2bl.is_none()
630 {
631 return None;
632 }
633 Some(merged)
634}
635
636pub fn on_off_value_to_boolean(value: Option<&str>) -> Option<bool> {
637 let value = value?.trim();
638 if value.is_empty() {
639 return None;
640 }
641 match value.to_ascii_lowercase().as_str() {
642 "1" | "true" | "on" => Some(true),
643 "0" | "false" | "off" => Some(false),
644 _ => None,
645 }
646}
647
648pub fn decode_xml_attribute(value: Option<&str>) -> Option<String> {
649 value.map(decode_xml_entities)
650}
651
652pub fn decode_hex_code_point(value: Option<&str>) -> Option<String> {
653 let value = value?
654 .trim()
655 .trim_start_matches("0x")
656 .trim_start_matches("0X");
657 if value.is_empty() || !value.chars().all(|c| c.is_ascii_hexdigit()) {
658 return None;
659 }
660 let code_point = i32::from_str_radix(value, 16).ok()?;
661 if code_point <= 0 {
662 return None;
663 }
664 char::from_u32(code_point as u32).map(|c| c.to_string())
665}
666
667pub fn parse_on_off_tag_value(tag_xml: Option<&str>, attributes: &[&str]) -> Option<bool> {
668 let tag_xml = tag_xml?;
669 for attribute in attributes {
670 if let Some(raw) = get_attribute(tag_xml, attribute) {
671 return on_off_value_to_boolean(Some(&raw));
672 }
673 }
674 Some(true)
675}
676
677pub fn clamp(value: f64, minimum: f64, maximum: f64) -> f64 {
678 value.max(minimum).min(maximum)
679}
680
681pub fn clamp_i64(value: i64, minimum: i64, maximum: i64) -> i64 {
682 value.max(minimum).min(maximum)
683}
684
685pub fn escape_xml_text(value: &str) -> String {
686 let bytes = value.as_bytes();
687 if memchr::memchr3(b'&', b'<', b'>', bytes).is_none()
688 && memchr::memchr2(b'"', b'\'', bytes).is_none()
689 {
690 return value.to_owned();
691 }
692 value
693 .replace('&', "&")
694 .replace('<', "<")
695 .replace('>', ">")
696 .replace('"', """)
697 .replace('\'', "'")
698}
699
700pub fn svg_data_uri(svg: &str) -> String {
701 const PREFIX: &[u8] = b"data:image/svg+xml;charset=utf-8,";
702 const HEX: &[u8; 16] = b"0123456789ABCDEF";
703 const UNRESERVED: [bool; 256] = {
704 let mut values = [false; 256];
705 let mut byte = 0;
706 while byte < values.len() {
707 values[byte] = matches!(byte as u8,
708 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~');
709 byte += 1;
710 }
711 values
712 };
713 let capacity = svg.len().checked_add(svg.len() / 8)
714 .and_then(|length| length.checked_add(PREFIX.len()))
715 .expect("encoded data exceeds addressable size");
716 let mut output = Vec::with_capacity(capacity);
717 output.extend_from_slice(PREFIX);
718 let bytes = svg.as_bytes();
719 let mut start = 0;
720 for (at, &byte) in bytes.iter().enumerate() {
721 if UNRESERVED[byte as usize] {
722 continue;
723 }
724 output.extend_from_slice(&bytes[start..at]);
725 output.extend_from_slice(&[b'%', HEX[(byte >> 4) as usize], HEX[(byte & 15) as usize]]);
726 start = at + 1;
727 }
728 output.extend_from_slice(&bytes[start..]);
729 String::from_utf8(output).expect("percent-encoded output is ASCII")
730}
731
732pub fn bytes_to_base64(bytes: &[u8]) -> String {
733 #[cfg(not(target_arch = "wasm32"))]
734 { base64_simd::STANDARD.encode_to_string(bytes) }
735 #[cfg(target_arch = "wasm32")]
736 { STANDARD.encode(bytes) }
737}
738
739pub(crate) fn append_base64(bytes: &[u8], output: &mut String) {
740 #[cfg(not(target_arch = "wasm32"))]
741 base64_simd::STANDARD.encode_append(bytes, output);
742 #[cfg(target_arch = "wasm32")]
743 STANDARD.encode_string(bytes, output);
744}
745
746pub(crate) fn base64_data_uri(content_type: &str, bytes: &[u8]) -> String {
747 let encoded = base64::encoded_len(bytes.len(), true).expect("encoded data exceeds addressable size");
748 let mut output = String::with_capacity(13 + content_type.len() + encoded);
749 output.push_str("data:");
750 output.push_str(content_type);
751 output.push_str(";base64,");
752 append_base64(bytes, &mut output);
753 output
754}
755
756pub fn extension_from_part_name(part_name: &str) -> Option<String> {
757 let last_dot = part_name.rfind('.')?;
758 if last_dot == part_name.len() - 1 {
759 return None;
760 }
761 Some(part_name[last_dot + 1..].to_ascii_lowercase())
762}
763
764pub fn content_type_for_part(part_name: &str, content_types: &ContentTypeLookup) -> Option<String> {
765 if let Some(override_type) = content_types
766 .override_by_part_name
767 .get(part_name)
768 .or_else(|| {
769 content_types
770 .override_by_part_name
771 .get(&format!("/{part_name}"))
772 })
773 {
774 return Some(override_type.clone());
775 }
776 let extension = extension_from_part_name(part_name)?;
777 let mime_by_extension: HashMap<&str, &str> = [
778 ("png", "image/png"),
779 ("jpg", "image/jpeg"),
780 ("jpeg", "image/jpeg"),
781 ("gif", "image/gif"),
782 ("bmp", "image/bmp"),
783 ("wmf", "image/wmf"),
784 ("emf", "image/emf"),
785 ("webp", "image/webp"),
786 ("svg", "image/svg+xml"),
787 ]
788 .into_iter()
789 .collect();
790 content_types
791 .default_by_extension
792 .get(&extension)
793 .cloned()
794 .or_else(|| {
795 mime_by_extension
796 .get(extension.as_str())
797 .map(|s| (*s).to_string())
798 })
799}
800
801pub fn resolve_part_path(base_part_name: &str, target: &str) -> String {
802 if target.is_empty() {
803 return String::new();
804 }
805 if re::get_unchecked(r"(?i)^[a-z][a-z0-9+.-]*:").is_match(target) || target.starts_with('#') {
806 return target.to_string();
807 }
808 if target.starts_with('/') {
809 return target[1..].to_string();
810 }
811 let mut output: Vec<&str> = base_part_name.split('/').collect();
812 output.pop();
813 for segment in target.split('/') {
814 if segment.is_empty() || segment == "." {
815 continue;
816 }
817 if segment == ".." {
818 output.pop();
819 continue;
820 }
821 output.push(segment);
822 }
823 output.join("/")
824}
825
826pub fn relationship_part_name_for_part(part_name: &str) -> String {
827 let mut segments: Vec<&str> = part_name.split('/').collect();
828 let file_name = segments.pop().unwrap_or("");
829 let folder = segments.join("/");
830 if folder.is_empty() {
831 format!("_rels/{file_name}.rels")
832 } else {
833 format!("{folder}/_rels/{file_name}.rels")
834 }
835}
836
837pub fn parse_relationships_from_parts(
838 parts: &HashMap<String, OoxmlPart>,
839 part_name: &str,
840) -> HashMap<String, String> {
841 let mut map = HashMap::new();
842 let relationships_part_name = relationship_part_name_for_part(part_name);
843 let Some(relationships_part) = parts.get(&relationships_part_name) else {
844 return map;
845 };
846 let re = re::get_unchecked(r"(?i)<Relationship\b[^>]*>");
847 for mat in re.find_iter(&relationships_part.content) {
848 let tag = mat.as_str();
849 let Some(id) = get_attribute(tag, "Id") else {
850 continue;
851 };
852 let Some(target) = get_attribute(tag, "Target") else {
853 continue;
854 };
855 map.insert(id, resolve_part_path(part_name, &target));
856 }
857 map
858}
859
860pub fn is_windows_metafile_content_type(
861 content_type: Option<&str>,
862 part_name: Option<&str>,
863) -> bool {
864 if let Some(normalized) = content_type.map(|value| value.trim().to_ascii_lowercase()) {
865 if matches!(
866 normalized.as_str(),
867 "image/wmf"
868 | "image/x-wmf"
869 | "application/x-wmf"
870 | "image/emf"
871 | "image/x-emf"
872 | "application/x-emf"
873 ) {
874 return true;
875 }
876 }
877 matches!(
878 part_name.and_then(extension_from_part_name).as_deref(),
879 Some("wmf" | "emf")
880 )
881}
882
883pub fn rasterize_windows_metafile_to_png_data_uri(
884 _bytes: &[u8],
885 _part_name: Option<&str>,
886) -> Option<String> {
887 None
888}
889
890pub fn windows_metafile_to_svg_data_uri(bytes: &[u8]) -> Option<String> {
893 crate::emf::emf_to_svg(bytes)
894 .or_else(|| crate::wmf::wmf_bitmap_preview_to_svg(bytes))
895 .map(|svg| svg_data_uri(&svg))
896}
897
898pub fn resolve_theme_font(theme_token: Option<&str>, theme_fonts: &ThemeFontMap) -> Option<String> {
899 let theme_token = theme_token?;
900 let normalized = theme_token.to_ascii_lowercase();
901 if normalized.starts_with("major") {
902 if normalized.contains("eastasia") {
903 return theme_fonts
904 .major_east_asia
905 .clone()
906 .or_else(|| theme_fonts.major_latin.clone());
907 }
908 if normalized.contains("bidi") || normalized.contains("cs") {
909 return theme_fonts
910 .major_complex_script
911 .clone()
912 .or_else(|| theme_fonts.major_latin.clone());
913 }
914 return theme_fonts.major_latin.clone();
915 }
916 if normalized.starts_with("minor") {
917 if normalized.contains("eastasia") {
918 return theme_fonts
919 .minor_east_asia
920 .clone()
921 .or_else(|| theme_fonts.minor_latin.clone());
922 }
923 if normalized.contains("bidi") || normalized.contains("cs") {
924 return theme_fonts
925 .minor_complex_script
926 .clone()
927 .or_else(|| theme_fonts.minor_latin.clone());
928 }
929 return theme_fonts.minor_latin.clone();
930 }
931 None
932}
933
934pub fn resolve_drawing_color_from_xml(
935 color_xml: Option<&str>,
936 theme_colors: &ThemeColorMap,
937) -> Option<(String, Option<f64>)> {
938 super::colors::resolve_drawing_color_from_xml(color_xml, theme_colors)
939 .map(|resolved| (resolved.color, resolved.opacity))
940}
941
942pub fn parse_paragraph_spacing_from_xml(xml: &str) -> Option<ParagraphSpacing> {
943 if xml.is_empty() {
944 return None;
945 }
946 let spacing_tag = super::scan::find_tag_token_ref(xml, "w:spacing")?;
947 let line_rule_raw = get_attribute(&spacing_tag, "w:lineRule").map(|v| v.to_ascii_lowercase());
948 let line_rule = match line_rule_raw.as_deref() {
949 Some("auto") => Some(ParagraphLineRule::Auto),
950 Some("exact") => Some(ParagraphLineRule::Exact),
951 Some("atleast") => Some(ParagraphLineRule::AtLeast),
952 _ => None,
953 };
954 let spacing = ParagraphSpacing {
955 before_twips: parse_integer_attribute(&spacing_tag, "w:before"),
956 after_twips: parse_integer_attribute(&spacing_tag, "w:after"),
957 before_auto_spacing: super::styles::parse_on_off_value(
958 get_attribute(&spacing_tag, "w:beforeAutospacing").as_deref(),
959 ),
960 after_auto_spacing: super::styles::parse_on_off_value(
961 get_attribute(&spacing_tag, "w:afterAutospacing").as_deref(),
962 ),
963 line_twips: parse_integer_attribute(&spacing_tag, "w:line"),
964 line_rule,
965 };
966 if spacing.before_twips.is_none()
967 && spacing.after_twips.is_none()
968 && spacing.before_auto_spacing.is_none()
969 && spacing.after_auto_spacing.is_none()
970 && spacing.line_twips.is_none()
971 && spacing.line_rule.is_none()
972 {
973 return None;
974 }
975 Some(spacing)
976}
977
978pub fn parse_paragraph_indent_from_xml(xml: &str) -> Option<ParagraphIndent> {
979 if xml.is_empty() {
980 return None;
981 }
982 let indent_tag = super::scan::find_tag_token_ref(xml, "w:ind")?;
983 let indent = ParagraphIndent {
984 left_twips: parse_integer_attribute(&indent_tag, "w:left"),
985 right_twips: parse_integer_attribute(&indent_tag, "w:right"),
986 first_line_twips: parse_integer_attribute(&indent_tag, "w:firstLine"),
987 hanging_twips: parse_integer_attribute(&indent_tag, "w:hanging"),
988 };
989 if indent.left_twips.is_none()
990 && indent.right_twips.is_none()
991 && indent.first_line_twips.is_none()
992 && indent.hanging_twips.is_none()
993 {
994 return None;
995 }
996 Some(indent)
997}
998
999pub fn parse_paragraph_shading_from_xml(xml: &str) -> Option<String> {
1000 if xml.is_empty() {
1001 return None;
1002 }
1003 let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd")?;
1004 normalize_hex_color(get_attribute(&shading_tag, "w:fill").as_deref())
1005}
1006
1007pub fn parse_text_run_border_style(tag_xml: Option<&str>) -> Option<TextRunBorderStyle> {
1008 let tag_xml = tag_xml?;
1009 let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
1010 if border_type.is_empty() {
1011 return None;
1012 }
1013 let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
1014 let space_pt = parse_integer_attribute(tag_xml, "w:space").map(|v| v as f64);
1015 let raw_color = get_attribute(tag_xml, "w:color");
1016 let color = if raw_color
1017 .as_deref()
1018 .map(|c| c.eq_ignore_ascii_case("auto"))
1019 .unwrap_or(false)
1020 {
1021 None
1022 } else {
1023 normalize_hex_color(raw_color.as_deref())
1024 };
1025 let frame = get_attribute(tag_xml, "w:frame")
1026 .as_deref()
1027 .and_then(|value| on_off_value_to_boolean(Some(value)));
1028 let shadow = get_attribute(tag_xml, "w:shadow")
1029 .as_deref()
1030 .and_then(|value| on_off_value_to_boolean(Some(value)));
1031 Some(TextRunBorderStyle {
1032 border_type,
1033 color,
1034 size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
1035 space_pt: space_pt.filter(|v| *v >= 0.0),
1036 frame,
1037 shadow,
1038 })
1039}
1040
1041pub fn parse_paragraph_border_style(tag_xml: Option<&str>) -> Option<ParagraphBorderStyle> {
1042 let tag_xml = tag_xml?;
1043 let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
1044 if border_type.is_empty() {
1045 return None;
1046 }
1047 let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
1048 let space_pt = parse_integer_attribute(tag_xml, "w:space").map(|v| v as f64);
1049 let raw_color = get_attribute(tag_xml, "w:color");
1050 let color = if raw_color
1051 .as_deref()
1052 .map(|c| c.eq_ignore_ascii_case("auto"))
1053 .unwrap_or(false)
1054 {
1055 Some("#000000".to_string())
1056 } else {
1057 normalize_hex_color(raw_color.as_deref())
1058 };
1059 let frame = get_attribute(tag_xml, "w:frame")
1060 .as_deref()
1061 .and_then(|value| on_off_value_to_boolean(Some(value)));
1062 let shadow = get_attribute(tag_xml, "w:shadow")
1063 .as_deref()
1064 .and_then(|value| on_off_value_to_boolean(Some(value)));
1065 Some(ParagraphBorderStyle {
1066 border_type,
1067 color,
1068 size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
1069 space_pt: space_pt.filter(|v| *v >= 0.0),
1070 frame,
1071 shadow,
1072 })
1073}
1074
1075pub fn parse_paragraph_border_set_from_xml(xml: &str) -> Option<ParagraphBorderSet> {
1076 if xml.is_empty() {
1077 return None;
1078 }
1079 let paragraph_border_xml = extract_balanced_tag_blocks(xml, "w:pBdr")
1080 .into_iter()
1081 .next()
1082 .or_else(|| super::scan::find_tag_token_ref(xml, "w:pBdr").map(str::to_owned))?;
1083 if paragraph_border_xml.is_empty() {
1084 return None;
1085 }
1086 let top = parse_paragraph_border_style(
1087 super::scan::find_tag_token_ref(¶graph_border_xml, "w:top").as_deref(),
1088 );
1089 let right = parse_paragraph_border_style(
1090 super::scan::find_tag_token_ref(¶graph_border_xml, "w:right").as_deref(),
1091 );
1092 let bottom = parse_paragraph_border_style(
1093 super::scan::find_tag_token_ref(¶graph_border_xml, "w:bottom").as_deref(),
1094 );
1095 let left = parse_paragraph_border_style(
1096 super::scan::find_tag_token_ref(¶graph_border_xml, "w:left").as_deref(),
1097 );
1098 let between = parse_paragraph_border_style(
1099 super::scan::find_tag_token_ref(¶graph_border_xml, "w:between").as_deref(),
1100 );
1101 let bar = parse_paragraph_border_style(
1102 super::scan::find_tag_token_ref(¶graph_border_xml, "w:bar").as_deref(),
1103 );
1104 if top.is_none()
1105 && right.is_none()
1106 && bottom.is_none()
1107 && left.is_none()
1108 && between.is_none()
1109 && bar.is_none()
1110 {
1111 return None;
1112 }
1113 Some(ParagraphBorderSet {
1114 top,
1115 right,
1116 bottom,
1117 left,
1118 between,
1119 bar,
1120 })
1121}
1122
1123pub fn parse_table_border_style(tag_xml: Option<&str>) -> Option<TableBorderStyle> {
1124 let tag_xml = tag_xml?;
1125 let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
1126 if border_type.is_empty() {
1127 return None;
1128 }
1129 let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
1130 let raw_color = get_attribute(tag_xml, "w:color");
1131 let color = if raw_color
1132 .as_deref()
1133 .map(|c| c.eq_ignore_ascii_case("auto"))
1134 .unwrap_or(false)
1135 {
1136 Some("#000000".to_string())
1137 } else {
1138 normalize_hex_color(raw_color.as_deref())
1139 };
1140 Some(TableBorderStyle {
1141 border_type,
1142 color,
1143 size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
1144 })
1145}
1146
1147pub fn parse_table_border_set(xml: &str) -> Option<TableBorderSet> {
1148 let top = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:top").as_deref());
1149 let right =
1150 parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:right").as_deref());
1151 let bottom =
1152 parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:bottom").as_deref());
1153 let left = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:left").as_deref());
1154 let inside_h =
1155 parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideH").as_deref());
1156 let inside_v =
1157 parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideV").as_deref());
1158 let tl2br =
1159 parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tl2br").as_deref());
1160 let tr2bl =
1161 parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tr2bl").as_deref());
1162 if top.is_none()
1163 && right.is_none()
1164 && bottom.is_none()
1165 && left.is_none()
1166 && inside_h.is_none()
1167 && inside_v.is_none()
1168 && tl2br.is_none()
1169 && tr2bl.is_none()
1170 {
1171 return None;
1172 }
1173 Some(TableBorderSet {
1174 top,
1175 right,
1176 bottom,
1177 left,
1178 inside_h,
1179 inside_v,
1180 tl2br,
1181 tr2bl,
1182 })
1183}
1184
1185pub fn normalize_tab_stop_alignment(value: Option<&str>) -> Option<ParagraphTabStopAlignment> {
1186 match value?.to_ascii_lowercase().as_str() {
1187 "clear" => Some(ParagraphTabStopAlignment::Clear),
1188 "left" => Some(ParagraphTabStopAlignment::Left),
1189 "center" => Some(ParagraphTabStopAlignment::Center),
1190 "right" => Some(ParagraphTabStopAlignment::Right),
1191 "decimal" => Some(ParagraphTabStopAlignment::Decimal),
1192 "bar" => Some(ParagraphTabStopAlignment::Bar),
1193 _ => None,
1194 }
1195}
1196
1197pub fn normalize_tab_stop_leader(value: Option<&str>) -> Option<ParagraphTabStopLeader> {
1198 match value? {
1199 "none" => Some(ParagraphTabStopLeader::LeaderNone),
1200 "dot" => Some(ParagraphTabStopLeader::Dot),
1201 "hyphen" => Some(ParagraphTabStopLeader::Hyphen),
1202 "underscore" => Some(ParagraphTabStopLeader::Underscore),
1203 "middleDot" => Some(ParagraphTabStopLeader::MiddleDot),
1204 _ => None,
1205 }
1206}
1207
1208pub fn parse_paragraph_tab_stops_from_xml(xml: &str) -> Vec<ParagraphTabStop> {
1209 if xml.is_empty() {
1210 return Vec::new();
1211 }
1212 let tabs_tag = regex_capture_tag(
1213 xml,
1214 r"(?is)<w:tabs\b[^>]*>[\s\S]*?</w:tabs>|<w:tabs\b[^>]*/>",
1215 )
1216 .unwrap_or_default();
1217 if tabs_tag.is_empty() {
1218 return Vec::new();
1219 }
1220 let re = re::get_unchecked(r"(?i)<w:tab\b[^>]*/>");
1221 let mut tab_stops = Vec::new();
1222 for mat in re.find_iter(&tabs_tag) {
1223 let tab_tag = mat.as_str();
1224 let Some(position_twips) = parse_integer_attribute(tab_tag, "w:pos") else {
1225 continue;
1226 };
1227 tab_stops.push(ParagraphTabStop {
1228 alignment: normalize_tab_stop_alignment(get_attribute(tab_tag, "w:val").as_deref())
1229 .or(Some(ParagraphTabStopAlignment::Left)),
1230 leader: normalize_tab_stop_leader(get_attribute(tab_tag, "w:leader").as_deref())
1231 .or(Some(ParagraphTabStopLeader::LeaderNone)),
1232 position_twips: Some(position_twips),
1233 });
1234 }
1235 tab_stops.sort_by_key(|s| s.position_twips.unwrap_or(0));
1236 tab_stops
1237}
1238
1239pub fn parse_paragraph_numbering_parts_from_xml(xml: &str) -> Option<(Option<i64>, Option<i64>)> {
1244 if xml.is_empty() {
1245 return None;
1246 }
1247 let numbering_xml = extract_balanced_tag_blocks(xml, "w:numPr")
1248 .into_iter()
1249 .next()
1250 .or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr").map(str::to_owned))?;
1251 if numbering_xml.is_empty() {
1252 return None;
1253 }
1254 let num_id = regex_capture(&numbering_xml, r#"(?i)<w:numId\b[^>]*w:val="(-?\d+)""#)
1255 .and_then(|raw| raw.parse::<i64>().ok());
1256 let ilvl = regex_capture(&numbering_xml, r#"(?i)<w:ilvl\b[^>]*w:val="(-?\d+)""#)
1257 .and_then(|raw| raw.parse::<i64>().ok());
1258 Some((num_id, ilvl))
1259}
1260
1261pub fn parse_paragraph_numbering_from_xml(xml: &str) -> Option<crate::model::ParagraphNumbering> {
1262 if xml.is_empty() {
1263 return None;
1264 }
1265 let numbering_xml = extract_balanced_tag_blocks(xml, "w:numPr")
1266 .into_iter()
1267 .next()
1268 .or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr").map(str::to_owned))?;
1269 if numbering_xml.is_empty() {
1270 return None;
1271 }
1272 let num_id_raw = regex_capture(&numbering_xml, r#"(?i)<w:numId\b[^>]*w:val="(-?\d+)""#)?;
1273 let num_id: i64 = num_id_raw.parse().ok()?;
1274 if num_id <= 0 {
1275 return None;
1276 }
1277 let ilvl_raw = regex_capture(&numbering_xml, r#"(?i)<w:ilvl\b[^>]*w:val="(-?\d+)""#);
1278 let ilvl_value: i64 = ilvl_raw.and_then(|v| v.parse().ok()).unwrap_or(0);
1279 Some(crate::model::ParagraphNumbering {
1280 num_id,
1281 ilvl: ilvl_value.max(0),
1282 })
1283}
1284
1285pub fn to_image_horizontal_align(raw: Option<&str>) -> Option<ImageHorizontalAlign> {
1286 match raw? {
1287 "left" => Some(ImageHorizontalAlign::Left),
1288 "center" => Some(ImageHorizontalAlign::Center),
1289 "right" => Some(ImageHorizontalAlign::Right),
1290 "inside" => Some(ImageHorizontalAlign::Inside),
1291 "outside" => Some(ImageHorizontalAlign::Outside),
1292 _ => None,
1293 }
1294}
1295
1296pub fn to_image_vertical_align(raw: Option<&str>) -> Option<ImageVerticalAlign> {
1297 match raw? {
1298 "top" => Some(ImageVerticalAlign::Top),
1299 "center" => Some(ImageVerticalAlign::Center),
1300 "bottom" => Some(ImageVerticalAlign::Bottom),
1301 "inside" => Some(ImageVerticalAlign::Inside),
1302 "outside" => Some(ImageVerticalAlign::Outside),
1303 _ => None,
1304 }
1305}
1306
1307pub fn strip_text_box_content(xml: &str) -> String {
1308 let re = re::get_unchecked(r"(?is)<w:txbxContent\b[\s\S]*?</w:txbxContent>");
1309 re.replace_all(xml, "").to_string()
1310}
1311
1312pub fn normalize_legacy_form_display_value(value: Option<&str>) -> Option<String> {
1313 let value = value?;
1314 let normalized = value
1315 .replace('\u{2002}', " ")
1316 .replace('\u{2003}', " ")
1317 .replace('\u{00a0}', " ");
1318 if normalized.trim().is_empty() {
1319 None
1320 } else {
1321 Some(normalized)
1322 }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327 use super::on_off_value_to_boolean;
1328
1329 #[test]
1330 fn parses_only_valid_st_on_off_lexical_values() {
1331 for value in ["1", "true", "TRUE", "on", "ON"] {
1332 assert_eq!(on_off_value_to_boolean(Some(value)), Some(true));
1333 }
1334 for value in ["0", "false", "FALSE", "off", "OFF"] {
1335 assert_eq!(on_off_value_to_boolean(Some(value)), Some(false));
1336 }
1337 for value in ["", "yes", "no", "maybe"] {
1338 assert_eq!(on_off_value_to_boolean(Some(value)), None);
1339 }
1340 }
1341}
1342
1343#[cfg(test)]
1344mod base64_output_tests {
1345 use super::*;
1346 use base64::Engine as _;
1347
1348 #[test]
1349 fn encoding_preserves_all_tails_and_prefixed_output() {
1350 let mut state = 0x61c4_28d9_u64;
1351 let mut bytes = Vec::new();
1352 for _ in 0..=1048576 {
1353 state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
1354 bytes.push((state >> 32) as u8);
1355 }
1356 for length in (0..=512).chain([1023, 1024, 4097, 65539, 1048577]) {
1357 let data = &bytes[..length];
1358 let expected = base64::engine::general_purpose::STANDARD.encode(data);
1359 assert_eq!(bytes_to_base64(data), expected);
1360 for prefix in ["", "prefix:", "π:payload,"] {
1361 let mut output = prefix.to_owned();
1362 append_base64(data, &mut output);
1363 assert_eq!(output, format!("{prefix}{expected}"));
1364 }
1365 assert_eq!(base64_data_uri("application/octet-stream", data), format!("data:application/octet-stream;base64,{expected}"));
1366 }
1367 }
1368}
1369
1370#[cfg(test)]
1371mod svg_uri_encoding_tests {
1372 #[test]
1373 fn reserved_characters_and_unicode_use_uppercase_utf8_escapes() {
1374 assert_eq!(super::svg_data_uri("<svg a=\"a b&é😀\"/>"), "data:image/svg+xml;charset=utf-8,%3Csvg%20a%3D%22a%20b%26%C3%A9%F0%9F%98%80%22%2F%3E");
1375 assert_eq!(super::svg_data_uri("AZaz09-_.~"), "data:image/svg+xml;charset=utf-8,AZaz09-_.~");
1376 assert_eq!(super::svg_data_uri(""), "data:image/svg+xml;charset=utf-8,");
1377 assert_eq!(super::svg_data_uri("\0\t\r\n%+/"), "data:image/svg+xml;charset=utf-8,%00%09%0D%0A%25%2B%2F");
1378 }
1379}
1380
1381#[cfg(test)]
1382mod alternate_scanner_tests {
1383 fn reference(xml: &str) -> String {
1384 use super::re;
1385 let re = re::get_unchecked(r"(?is)<mc:AlternateContent\b[\s\S]*?</mc:AlternateContent>");
1386 re.replace_all(xml, |caps: ®ex::Captures| {
1387 let alternate = &caps[0];
1388 let Some(choice) = re::get_unchecked(r"(?is)<mc:Choice\b[\s\S]*?</mc:Choice>").find(alternate) else {
1389 return alternate.to_owned();
1390 };
1391 let content = re::get_unchecked(r"(?i)<mc:Choice\b[^>]*>").replace(choice.as_str(), "");
1392 re::get_unchecked(r"(?i)</mc:Choice>").replace(&content, "").into_owned()
1393 }).into_owned()
1394 }
1395
1396 #[test]
1397 fn scanner_preserves_lazy_matching_and_boundary_behavior() {
1398 let opens = ["<mc:AlternateContent>", "<MC:ALTERNATECONTENT>", "<mc:AlternateContent:extra>", "<mc:AlternateContent_>", "<mc:AlternateContenté>", "<mc:AlternateContent\u{301}>", "<mc:AlternateContent"];
1399 let contents = ["<mc:Choice>one</mc:Choice><mc:Fallback>two</mc:Fallback>", "<MC:CHOICE a=\"x>y\">one</MC:CHOICE>", "<mc:Choice:extra>one</mc:Choice>", "<mc:Choice_>one</mc:Choice>", "<mc:Choiceé>one</mc:Choice>", "<mc:Choice\u{301}>one</mc:Choice>", "<mc:Choice</mc:Choice>", "<mc:Choice>outer<mc:Choice>inner</mc:Choice>tail</mc:Choice>", "<mc:Fallback>retained</mc:Fallback>", "<mc:AlternateContent><mc:Choice>nested</mc:Choice></mc:AlternateContent>"];
1400 for open in opens {
1401 for content in contents {
1402 for close in ["</mc:AlternateContent>", "</MC:ALTERNATECONTENT>", ""] {
1403 let xml = format!("before{open}{content}{close}after<mc:AlternateContent><mc:Choice>last</mc:Choice></mc:AlternateContent>");
1404 if let Some(scanned) = super::scan_active_xml(&xml) {
1405 assert_eq!(scanned, reference(&xml));
1406 }
1407 }
1408 }
1409 }
1410 assert!(super::scan_active_xml("<mc:AlternateContent><mc:Choice>one</mc:Choice></mc:AlternateContent>").is_some());
1411 }
1412}
1413
1414#[cfg(test)]
1415mod sparse_encoding_tests {
1416 #[test]
1417 fn all_scalar_values_survive_encoded_and_unchanged_boundaries() {
1418 use std::fmt::Write;
1419 for scalar in (0..=0x10ffff).filter_map(char::from_u32) {
1420 let text = format!("a{scalar}%{scalar}z");
1421 let mut expected = String::from("data:image/svg+xml;charset=utf-8,");
1422 for byte in text.bytes() {
1423 if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
1424 expected.push(byte as char);
1425 } else {
1426 write!(expected, "%{byte:02X}").unwrap();
1427 }
1428 }
1429 assert_eq!(super::svg_data_uri(&text), expected);
1430 }
1431 }
1432
1433 #[test]
1434 fn long_runs_and_xml_escaping_preserve_exact_bytes() {
1435 let run = "AZaz09-_.~".repeat(10000);
1436 assert_eq!(super::svg_data_uri(&run), format!("data:image/svg+xml;charset=utf-8,{run}"));
1437 let text = format!("{run}é{run}%");
1438 assert_eq!(super::svg_data_uri(&text), format!("data:image/svg+xml;charset=utf-8,{run}%C3%A9{run}%25"));
1439 assert_eq!(super::escape_xml_text(&run), run);
1440 assert_eq!(super::escape_xml_text("a&<>\"'é\r\n"), "a&<>"'é\r\n");
1441 assert_eq!(super::escape_xml_text("é\r\n"), "é\r\n");
1442 }
1443}