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    };
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('&', "&amp;")
686        .replace('<', "&lt;")
687        .replace('>', "&gt;")
688        .replace('"', "&quot;")
689        .replace('\'', "&apos;")
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
882/// Converts vector EMF assets to an SVG data URI. WMF and EMFs containing
883/// unsupported drawing records return `None` so the caller
884/// keeps the unsupported-image placeholder.
885pub fn windows_metafile_to_svg_data_uri(bytes: &[u8]) -> Option<String> {
886    crate::emf::emf_to_svg(bytes).map(|svg| svg_data_uri(&svg))
887}
888
889pub fn resolve_theme_font(theme_token: Option<&str>, theme_fonts: &ThemeFontMap) -> Option<String> {
890    let theme_token = theme_token?;
891    let normalized = theme_token.to_ascii_lowercase();
892    if normalized.starts_with("major") {
893        if normalized.contains("eastasia") {
894            return theme_fonts
895                .major_east_asia
896                .clone()
897                .or_else(|| theme_fonts.major_latin.clone());
898        }
899        if normalized.contains("bidi") || normalized.contains("cs") {
900            return theme_fonts
901                .major_complex_script
902                .clone()
903                .or_else(|| theme_fonts.major_latin.clone());
904        }
905        return theme_fonts.major_latin.clone();
906    }
907    if normalized.starts_with("minor") {
908        if normalized.contains("eastasia") {
909            return theme_fonts
910                .minor_east_asia
911                .clone()
912                .or_else(|| theme_fonts.minor_latin.clone());
913        }
914        if normalized.contains("bidi") || normalized.contains("cs") {
915            return theme_fonts
916                .minor_complex_script
917                .clone()
918                .or_else(|| theme_fonts.minor_latin.clone());
919        }
920        return theme_fonts.minor_latin.clone();
921    }
922    None
923}
924
925pub fn resolve_drawing_color_from_xml(
926    color_xml: Option<&str>,
927    theme_colors: &ThemeColorMap,
928) -> Option<(String, Option<f64>)> {
929    super::colors::resolve_drawing_color_from_xml(color_xml, theme_colors)
930        .map(|resolved| (resolved.color, resolved.opacity))
931}
932
933pub fn parse_paragraph_spacing_from_xml(xml: &str) -> Option<ParagraphSpacing> {
934    if xml.is_empty() {
935        return None;
936    }
937    let spacing_tag = super::scan::find_tag_token_ref(xml, "w:spacing")?;
938    let line_rule_raw = get_attribute(&spacing_tag, "w:lineRule").map(|v| v.to_ascii_lowercase());
939    let line_rule = match line_rule_raw.as_deref() {
940        Some("auto") => Some(ParagraphLineRule::Auto),
941        Some("exact") => Some(ParagraphLineRule::Exact),
942        Some("atleast") => Some(ParagraphLineRule::AtLeast),
943        _ => None,
944    };
945    let spacing = ParagraphSpacing {
946        before_twips: parse_integer_attribute(&spacing_tag, "w:before"),
947        after_twips: parse_integer_attribute(&spacing_tag, "w:after"),
948        before_auto_spacing: super::styles::parse_on_off_value(
949            get_attribute(&spacing_tag, "w:beforeAutospacing").as_deref(),
950        ),
951        after_auto_spacing: super::styles::parse_on_off_value(
952            get_attribute(&spacing_tag, "w:afterAutospacing").as_deref(),
953        ),
954        line_twips: parse_integer_attribute(&spacing_tag, "w:line"),
955        line_rule,
956    };
957    if spacing.before_twips.is_none()
958        && spacing.after_twips.is_none()
959        && spacing.before_auto_spacing.is_none()
960        && spacing.after_auto_spacing.is_none()
961        && spacing.line_twips.is_none()
962        && spacing.line_rule.is_none()
963    {
964        return None;
965    }
966    Some(spacing)
967}
968
969pub fn parse_paragraph_indent_from_xml(xml: &str) -> Option<ParagraphIndent> {
970    if xml.is_empty() {
971        return None;
972    }
973    let indent_tag = super::scan::find_tag_token_ref(xml, "w:ind")?;
974    let indent = ParagraphIndent {
975        left_twips: parse_integer_attribute(&indent_tag, "w:left"),
976        right_twips: parse_integer_attribute(&indent_tag, "w:right"),
977        first_line_twips: parse_integer_attribute(&indent_tag, "w:firstLine"),
978        hanging_twips: parse_integer_attribute(&indent_tag, "w:hanging"),
979    };
980    if indent.left_twips.is_none()
981        && indent.right_twips.is_none()
982        && indent.first_line_twips.is_none()
983        && indent.hanging_twips.is_none()
984    {
985        return None;
986    }
987    Some(indent)
988}
989
990pub fn parse_paragraph_shading_from_xml(xml: &str) -> Option<String> {
991    if xml.is_empty() {
992        return None;
993    }
994    let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd")?;
995    normalize_hex_color(get_attribute(&shading_tag, "w:fill").as_deref())
996}
997
998pub fn parse_text_run_border_style(tag_xml: Option<&str>) -> Option<TextRunBorderStyle> {
999    let tag_xml = tag_xml?;
1000    let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
1001    if border_type.is_empty() {
1002        return None;
1003    }
1004    let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
1005    let space_pt = parse_integer_attribute(tag_xml, "w:space").map(|v| v as f64);
1006    let raw_color = get_attribute(tag_xml, "w:color");
1007    let color = if raw_color
1008        .as_deref()
1009        .map(|c| c.eq_ignore_ascii_case("auto"))
1010        .unwrap_or(false)
1011    {
1012        None
1013    } else {
1014        normalize_hex_color(raw_color.as_deref())
1015    };
1016    let frame = get_attribute(tag_xml, "w:frame")
1017        .as_deref()
1018        .and_then(|value| on_off_value_to_boolean(Some(value)));
1019    let shadow = get_attribute(tag_xml, "w:shadow")
1020        .as_deref()
1021        .and_then(|value| on_off_value_to_boolean(Some(value)));
1022    Some(TextRunBorderStyle {
1023        border_type,
1024        color,
1025        size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
1026        space_pt: space_pt.filter(|v| *v >= 0.0),
1027        frame,
1028        shadow,
1029    })
1030}
1031
1032pub fn parse_paragraph_border_style(tag_xml: Option<&str>) -> Option<ParagraphBorderStyle> {
1033    let tag_xml = tag_xml?;
1034    let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
1035    if border_type.is_empty() {
1036        return None;
1037    }
1038    let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
1039    let space_pt = parse_integer_attribute(tag_xml, "w:space").map(|v| v as f64);
1040    let raw_color = get_attribute(tag_xml, "w:color");
1041    let color = if raw_color
1042        .as_deref()
1043        .map(|c| c.eq_ignore_ascii_case("auto"))
1044        .unwrap_or(false)
1045    {
1046        Some("#000000".to_string())
1047    } else {
1048        normalize_hex_color(raw_color.as_deref())
1049    };
1050    let frame = get_attribute(tag_xml, "w:frame")
1051        .as_deref()
1052        .and_then(|value| on_off_value_to_boolean(Some(value)));
1053    let shadow = get_attribute(tag_xml, "w:shadow")
1054        .as_deref()
1055        .and_then(|value| on_off_value_to_boolean(Some(value)));
1056    Some(ParagraphBorderStyle {
1057        border_type,
1058        color,
1059        size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
1060        space_pt: space_pt.filter(|v| *v >= 0.0),
1061        frame,
1062        shadow,
1063    })
1064}
1065
1066pub fn parse_paragraph_border_set_from_xml(xml: &str) -> Option<ParagraphBorderSet> {
1067    if xml.is_empty() {
1068        return None;
1069    }
1070    let paragraph_border_xml = extract_balanced_tag_blocks(xml, "w:pBdr")
1071        .into_iter()
1072        .next()
1073        .or_else(|| super::scan::find_tag_token_ref(xml, "w:pBdr").map(str::to_owned))?;
1074    if paragraph_border_xml.is_empty() {
1075        return None;
1076    }
1077    let top = parse_paragraph_border_style(
1078        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:top").as_deref(),
1079    );
1080    let right = parse_paragraph_border_style(
1081        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:right").as_deref(),
1082    );
1083    let bottom = parse_paragraph_border_style(
1084        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:bottom").as_deref(),
1085    );
1086    let left = parse_paragraph_border_style(
1087        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:left").as_deref(),
1088    );
1089    let between = parse_paragraph_border_style(
1090        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:between").as_deref(),
1091    );
1092    let bar = parse_paragraph_border_style(
1093        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:bar").as_deref(),
1094    );
1095    if top.is_none()
1096        && right.is_none()
1097        && bottom.is_none()
1098        && left.is_none()
1099        && between.is_none()
1100        && bar.is_none()
1101    {
1102        return None;
1103    }
1104    Some(ParagraphBorderSet {
1105        top,
1106        right,
1107        bottom,
1108        left,
1109        between,
1110        bar,
1111    })
1112}
1113
1114pub fn parse_table_border_style(tag_xml: Option<&str>) -> Option<TableBorderStyle> {
1115    let tag_xml = tag_xml?;
1116    let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
1117    if border_type.is_empty() {
1118        return None;
1119    }
1120    let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz").map(|v| v as f64);
1121    let raw_color = get_attribute(tag_xml, "w:color");
1122    let color = if raw_color
1123        .as_deref()
1124        .map(|c| c.eq_ignore_ascii_case("auto"))
1125        .unwrap_or(false)
1126    {
1127        Some("#000000".to_string())
1128    } else {
1129        normalize_hex_color(raw_color.as_deref())
1130    };
1131    Some(TableBorderStyle {
1132        border_type,
1133        color,
1134        size_eighth_pt: size_eighth_pt.filter(|v| *v >= 0.0),
1135    })
1136}
1137
1138pub fn parse_table_border_set(xml: &str) -> Option<TableBorderSet> {
1139    let top = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:top").as_deref());
1140    let right =
1141        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:right").as_deref());
1142    let bottom =
1143        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:bottom").as_deref());
1144    let left = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:left").as_deref());
1145    let inside_h =
1146        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideH").as_deref());
1147    let inside_v =
1148        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideV").as_deref());
1149    let tl2br =
1150        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tl2br").as_deref());
1151    let tr2bl =
1152        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tr2bl").as_deref());
1153    if top.is_none()
1154        && right.is_none()
1155        && bottom.is_none()
1156        && left.is_none()
1157        && inside_h.is_none()
1158        && inside_v.is_none()
1159        && tl2br.is_none()
1160        && tr2bl.is_none()
1161    {
1162        return None;
1163    }
1164    Some(TableBorderSet {
1165        top,
1166        right,
1167        bottom,
1168        left,
1169        inside_h,
1170        inside_v,
1171        tl2br,
1172        tr2bl,
1173    })
1174}
1175
1176pub fn normalize_tab_stop_alignment(value: Option<&str>) -> Option<ParagraphTabStopAlignment> {
1177    match value?.to_ascii_lowercase().as_str() {
1178        "clear" => Some(ParagraphTabStopAlignment::Clear),
1179        "left" => Some(ParagraphTabStopAlignment::Left),
1180        "center" => Some(ParagraphTabStopAlignment::Center),
1181        "right" => Some(ParagraphTabStopAlignment::Right),
1182        "decimal" => Some(ParagraphTabStopAlignment::Decimal),
1183        "bar" => Some(ParagraphTabStopAlignment::Bar),
1184        _ => None,
1185    }
1186}
1187
1188pub fn normalize_tab_stop_leader(value: Option<&str>) -> Option<ParagraphTabStopLeader> {
1189    match value? {
1190        "none" => Some(ParagraphTabStopLeader::LeaderNone),
1191        "dot" => Some(ParagraphTabStopLeader::Dot),
1192        "hyphen" => Some(ParagraphTabStopLeader::Hyphen),
1193        "underscore" => Some(ParagraphTabStopLeader::Underscore),
1194        "middleDot" => Some(ParagraphTabStopLeader::MiddleDot),
1195        _ => None,
1196    }
1197}
1198
1199pub fn parse_paragraph_tab_stops_from_xml(xml: &str) -> Vec<ParagraphTabStop> {
1200    if xml.is_empty() {
1201        return Vec::new();
1202    }
1203    let tabs_tag = regex_capture_tag(
1204        xml,
1205        r"(?is)<w:tabs\b[^>]*>[\s\S]*?</w:tabs>|<w:tabs\b[^>]*/>",
1206    )
1207    .unwrap_or_default();
1208    if tabs_tag.is_empty() {
1209        return Vec::new();
1210    }
1211    let re = re::get_unchecked(r"(?i)<w:tab\b[^>]*/>");
1212    let mut tab_stops = Vec::new();
1213    for mat in re.find_iter(&tabs_tag) {
1214        let tab_tag = mat.as_str();
1215        let Some(position_twips) = parse_integer_attribute(tab_tag, "w:pos") else {
1216            continue;
1217        };
1218        tab_stops.push(ParagraphTabStop {
1219            alignment: normalize_tab_stop_alignment(get_attribute(tab_tag, "w:val").as_deref())
1220                .or(Some(ParagraphTabStopAlignment::Left)),
1221            leader: normalize_tab_stop_leader(get_attribute(tab_tag, "w:leader").as_deref())
1222                .or(Some(ParagraphTabStopLeader::LeaderNone)),
1223            position_twips: Some(position_twips),
1224        });
1225    }
1226    tab_stops.sort_by_key(|s| s.position_twips.unwrap_or(0));
1227    tab_stops
1228}
1229
1230/// Raw `w:numPr` sub-values before inheritance: `(numId, ilvl)`, each `None`
1231/// when its element is absent. Returns `None` when the properties block has no
1232/// `w:numPr` at all. Callers use this to distinguish an ilvl-only override
1233/// (level change on a style-provided list) from numbering removal (numId=0).
1234pub fn parse_paragraph_numbering_parts_from_xml(xml: &str) -> Option<(Option<i64>, Option<i64>)> {
1235    if xml.is_empty() {
1236        return None;
1237    }
1238    let numbering_xml = extract_balanced_tag_blocks(xml, "w:numPr")
1239        .into_iter()
1240        .next()
1241        .or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr").map(str::to_owned))?;
1242    if numbering_xml.is_empty() {
1243        return None;
1244    }
1245    let num_id = regex_capture(&numbering_xml, r#"(?i)<w:numId\b[^>]*w:val="(-?\d+)""#)
1246        .and_then(|raw| raw.parse::<i64>().ok());
1247    let ilvl = regex_capture(&numbering_xml, r#"(?i)<w:ilvl\b[^>]*w:val="(-?\d+)""#)
1248        .and_then(|raw| raw.parse::<i64>().ok());
1249    Some((num_id, ilvl))
1250}
1251
1252pub fn parse_paragraph_numbering_from_xml(xml: &str) -> Option<crate::model::ParagraphNumbering> {
1253    if xml.is_empty() {
1254        return None;
1255    }
1256    let numbering_xml = extract_balanced_tag_blocks(xml, "w:numPr")
1257        .into_iter()
1258        .next()
1259        .or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr").map(str::to_owned))?;
1260    if numbering_xml.is_empty() {
1261        return None;
1262    }
1263    let num_id_raw = regex_capture(&numbering_xml, r#"(?i)<w:numId\b[^>]*w:val="(-?\d+)""#)?;
1264    let num_id: i64 = num_id_raw.parse().ok()?;
1265    if num_id <= 0 {
1266        return None;
1267    }
1268    let ilvl_raw = regex_capture(&numbering_xml, r#"(?i)<w:ilvl\b[^>]*w:val="(-?\d+)""#);
1269    let ilvl_value: i64 = ilvl_raw.and_then(|v| v.parse().ok()).unwrap_or(0);
1270    Some(crate::model::ParagraphNumbering {
1271        num_id,
1272        ilvl: ilvl_value.max(0),
1273    })
1274}
1275
1276pub fn to_image_horizontal_align(raw: Option<&str>) -> Option<ImageHorizontalAlign> {
1277    match raw? {
1278        "left" => Some(ImageHorizontalAlign::Left),
1279        "center" => Some(ImageHorizontalAlign::Center),
1280        "right" => Some(ImageHorizontalAlign::Right),
1281        "inside" => Some(ImageHorizontalAlign::Inside),
1282        "outside" => Some(ImageHorizontalAlign::Outside),
1283        _ => None,
1284    }
1285}
1286
1287pub fn to_image_vertical_align(raw: Option<&str>) -> Option<ImageVerticalAlign> {
1288    match raw? {
1289        "top" => Some(ImageVerticalAlign::Top),
1290        "center" => Some(ImageVerticalAlign::Center),
1291        "bottom" => Some(ImageVerticalAlign::Bottom),
1292        "inside" => Some(ImageVerticalAlign::Inside),
1293        "outside" => Some(ImageVerticalAlign::Outside),
1294        _ => None,
1295    }
1296}
1297
1298pub fn strip_text_box_content(xml: &str) -> String {
1299    let re = re::get_unchecked(r"(?is)<w:txbxContent\b[\s\S]*?</w:txbxContent>");
1300    re.replace_all(xml, "").to_string()
1301}
1302
1303pub fn normalize_legacy_form_display_value(value: Option<&str>) -> Option<String> {
1304    let value = value?;
1305    let normalized = value
1306        .replace('\u{2002}', " ")
1307        .replace('\u{2003}', " ")
1308        .replace('\u{00a0}', " ");
1309    if normalized.trim().is_empty() {
1310        None
1311    } else {
1312        Some(normalized)
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::on_off_value_to_boolean;
1319
1320    #[test]
1321    fn parses_only_valid_st_on_off_lexical_values() {
1322        for value in ["1", "true", "TRUE", "on", "ON"] {
1323            assert_eq!(on_off_value_to_boolean(Some(value)), Some(true));
1324        }
1325        for value in ["0", "false", "FALSE", "off", "OFF"] {
1326            assert_eq!(on_off_value_to_boolean(Some(value)), Some(false));
1327        }
1328        for value in ["", "yes", "no", "maybe"] {
1329            assert_eq!(on_off_value_to_boolean(Some(value)), None);
1330        }
1331    }
1332}
1333
1334#[cfg(test)]
1335mod base64_output_tests {
1336    use super::*;
1337    use base64::Engine as _;
1338
1339    #[test]
1340    fn encoding_preserves_all_tails_and_prefixed_output() {
1341        let mut state = 0x61c4_28d9_u64;
1342        let mut bytes = Vec::new();
1343        for _ in 0..=1048576 {
1344            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
1345            bytes.push((state >> 32) as u8);
1346        }
1347        for length in (0..=512).chain([1023, 1024, 4097, 65539, 1048577]) {
1348            let data = &bytes[..length];
1349            let expected = base64::engine::general_purpose::STANDARD.encode(data);
1350            assert_eq!(bytes_to_base64(data), expected);
1351            for prefix in ["", "prefix:", "π:payload,"] {
1352                let mut output = prefix.to_owned();
1353                append_base64(data, &mut output);
1354                assert_eq!(output, format!("{prefix}{expected}"));
1355            }
1356            assert_eq!(base64_data_uri("application/octet-stream", data), format!("data:application/octet-stream;base64,{expected}"));
1357        }
1358    }
1359}
1360
1361#[cfg(test)]
1362mod svg_uri_encoding_tests {
1363    #[test]
1364    fn reserved_characters_and_unicode_use_uppercase_utf8_escapes() {
1365        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");
1366        assert_eq!(super::svg_data_uri("AZaz09-_.~"), "data:image/svg+xml;charset=utf-8,AZaz09-_.~");
1367        assert_eq!(super::svg_data_uri(""), "data:image/svg+xml;charset=utf-8,");
1368        assert_eq!(super::svg_data_uri("\0\t\r\n%+/"), "data:image/svg+xml;charset=utf-8,%00%09%0D%0A%25%2B%2F");
1369    }
1370}
1371
1372#[cfg(test)]
1373mod alternate_scanner_tests {
1374    fn reference(xml: &str) -> String {
1375        use super::re;
1376        let re = re::get_unchecked(r"(?is)<mc:AlternateContent\b[\s\S]*?</mc:AlternateContent>");
1377        re.replace_all(xml, |caps: &regex::Captures| {
1378            let alternate = &caps[0];
1379            let Some(choice) = re::get_unchecked(r"(?is)<mc:Choice\b[\s\S]*?</mc:Choice>").find(alternate) else {
1380                return alternate.to_owned();
1381            };
1382            let content = re::get_unchecked(r"(?i)<mc:Choice\b[^>]*>").replace(choice.as_str(), "");
1383            re::get_unchecked(r"(?i)</mc:Choice>").replace(&content, "").into_owned()
1384        }).into_owned()
1385    }
1386
1387    #[test]
1388    fn scanner_preserves_lazy_matching_and_boundary_behavior() {
1389        let opens = ["<mc:AlternateContent>", "<MC:ALTERNATECONTENT>", "<mc:AlternateContent:extra>", "<mc:AlternateContent_>", "<mc:AlternateContenté>", "<mc:AlternateContent\u{301}>", "<mc:AlternateContent"];
1390        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>"];
1391        for open in opens {
1392            for content in contents {
1393                for close in ["</mc:AlternateContent>", "</MC:ALTERNATECONTENT>", ""] {
1394                    let xml = format!("before{open}{content}{close}after<mc:AlternateContent><mc:Choice>last</mc:Choice></mc:AlternateContent>");
1395                    if let Some(scanned) = super::scan_active_xml(&xml) {
1396                        assert_eq!(scanned, reference(&xml));
1397                    }
1398                }
1399            }
1400        }
1401        assert!(super::scan_active_xml("<mc:AlternateContent><mc:Choice>one</mc:Choice></mc:AlternateContent>").is_some());
1402    }
1403}
1404
1405#[cfg(test)]
1406mod sparse_encoding_tests {
1407    #[test]
1408    fn all_scalar_values_survive_encoded_and_unchanged_boundaries() {
1409        use std::fmt::Write;
1410        for scalar in (0..=0x10ffff).filter_map(char::from_u32) {
1411            let text = format!("a{scalar}%{scalar}z");
1412            let mut expected = String::from("data:image/svg+xml;charset=utf-8,");
1413            for byte in text.bytes() {
1414                if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
1415                    expected.push(byte as char);
1416                } else {
1417                    write!(expected, "%{byte:02X}").unwrap();
1418                }
1419            }
1420            assert_eq!(super::svg_data_uri(&text), expected);
1421        }
1422    }
1423
1424    #[test]
1425    fn long_runs_and_xml_escaping_preserve_exact_bytes() {
1426        let run = "AZaz09-_.~".repeat(10000);
1427        assert_eq!(super::svg_data_uri(&run), format!("data:image/svg+xml;charset=utf-8,{run}"));
1428        let text = format!("{run}é{run}%");
1429        assert_eq!(super::svg_data_uri(&text), format!("data:image/svg+xml;charset=utf-8,{run}%C3%A9{run}%25"));
1430        assert_eq!(super::escape_xml_text(&run), run);
1431        assert_eq!(super::escape_xml_text("a&<>\"'é\r\n"), "a&amp;&lt;&gt;&quot;&apos;é\r\n");
1432        assert_eq!(super::escape_xml_text("é\r\n"), "é\r\n");
1433    }
1434}