Skip to main content

rust_docx/parse/
util.rs

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: &regex::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('&', "&amp;")
694        .replace('<', "&lt;")
695        .replace('>', "&gt;")
696        .replace('"', "&quot;")
697        .replace('\'', "&apos;")
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
890/// Converts supported metafile drawing records while retaining opaque payloads
891/// when playback is unavailable.
892pub 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(&paragraph_border_xml, "w:top").as_deref(),
1088    );
1089    let right = parse_paragraph_border_style(
1090        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:right").as_deref(),
1091    );
1092    let bottom = parse_paragraph_border_style(
1093        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:bottom").as_deref(),
1094    );
1095    let left = parse_paragraph_border_style(
1096        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:left").as_deref(),
1097    );
1098    let between = parse_paragraph_border_style(
1099        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:between").as_deref(),
1100    );
1101    let bar = parse_paragraph_border_style(
1102        super::scan::find_tag_token_ref(&paragraph_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
1239/// Raw `w:numPr` sub-values before inheritance: `(numId, ilvl)`, each `None`
1240/// when its element is absent. Returns `None` when the properties block has no
1241/// `w:numPr` at all. Callers use this to distinguish an ilvl-only override
1242/// (level change on a style-provided list) from numbering removal (numId=0).
1243pub 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: &regex::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&amp;&lt;&gt;&quot;&apos;é\r\n");
1441        assert_eq!(super::escape_xml_text("é\r\n"), "é\r\n");
1442    }
1443}