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