Skip to main content

rust_docx/parse/
style.rs

1use crate::model::{
2    ParagraphDropCap, ParagraphDropCapType, ParagraphSpacing, ParagraphStyle,
3    ParagraphTextAlignment, SharedParagraphStyle, SharedTextStyle, TextLigatures, TextStyle, VerticalAlign,
4};
5use crate::parse::context::{ParseContext, ThemeFontMap};
6use crate::parse::re;
7use crate::parse::util::{
8    active_xml as prefer_alternate_content_choice, merge_paragraph_boolean,
9    merge_paragraph_border_sets, merge_paragraph_indent, merge_paragraph_spacing,
10    merge_paragraph_tab_stops, normalize_heading_level, normalize_hex_color,
11    parse_paragraph_border_set_from_xml, parse_paragraph_indent_from_xml,
12    parse_paragraph_numbering_from_xml, parse_paragraph_numbering_parts_from_xml,
13    parse_paragraph_shading_from_xml, parse_paragraph_spacing_from_xml,
14    parse_paragraph_tab_stops_from_xml, parse_text_run_border_style, resolve_theme_font,
15    to_model_alignment,
16};
17use crate::xml::{
18    extract_balanced_tag_blocks, get_attribute, parse_integer_attribute, parse_on_off_attribute,
19    parse_underline_attribute,
20};
21
22pub fn parse_paragraph_drop_cap_from_xml(
23    paragraph_properties_xml: &str,
24) -> Option<ParagraphDropCap> {
25    let frame_pr_tag =
26        super::scan::find_tag_token_ref(paragraph_properties_xml, "w:framePr").unwrap_or_default();
27    if frame_pr_tag.is_empty() {
28        return None;
29    }
30    let drop_cap_raw =
31        get_attribute(&frame_pr_tag, "w:dropCap").map(|v| v.trim().to_ascii_lowercase())?;
32    let drop_cap_type = match drop_cap_raw.as_str() {
33        "drop" => ParagraphDropCapType::Drop,
34        "margin" => ParagraphDropCapType::Margin,
35        _ => return None,
36    };
37    let lines = parse_integer_attribute(&frame_pr_tag, "w:lines");
38    let wrap = get_attribute(&frame_pr_tag, "w:wrap").map(|v| v.trim().to_string());
39    let horizontal_anchor = get_attribute(&frame_pr_tag, "w:hAnchor").map(|v| v.trim().to_string());
40    let vertical_anchor = get_attribute(&frame_pr_tag, "w:vAnchor").map(|v| v.trim().to_string());
41    let x_twips = parse_integer_attribute(&frame_pr_tag, "w:x");
42    let y_twips = parse_integer_attribute(&frame_pr_tag, "w:y");
43    let horizontal_space_twips = parse_integer_attribute(&frame_pr_tag, "w:hSpace");
44    let vertical_space_twips = parse_integer_attribute(&frame_pr_tag, "w:vSpace");
45    Some(ParagraphDropCap {
46        drop_cap_type,
47        lines: lines.filter(|l| *l > 0),
48        wrap,
49        horizontal_anchor,
50        vertical_anchor,
51        x_twips,
52        y_twips,
53        horizontal_space_twips,
54        vertical_space_twips,
55    })
56}
57
58pub(crate) fn parse_paragraph_text_alignment_from_xml(xml: &str) -> Option<ParagraphTextAlignment> {
59    let tag = super::styles::direct_table_property(xml, "w:textAlignment")?;
60    match get_attribute(tag, "w:val")?.to_ascii_lowercase().as_str() {
61        "auto" => Some(ParagraphTextAlignment::Auto),
62        "top" => Some(ParagraphTextAlignment::Top),
63        "center" => Some(ParagraphTextAlignment::Center),
64        "baseline" => Some(ParagraphTextAlignment::Baseline),
65        "bottom" => Some(ParagraphTextAlignment::Bottom),
66        _ => None,
67    }
68}
69
70pub fn parse_paragraph_align_from_xml(xml: &str) -> Option<crate::model::ParagraphAlignment> {
71    let alignment = super::scan::find_attribute_value_in_tag(xml, "w:jc", "w:val")
72        .filter(|value| !value.is_empty());
73    to_model_alignment(alignment.as_deref())
74}
75
76pub(crate) fn parse_text_ligatures_from_xml(xml: &str) -> Option<TextLigatures> {
77    super::scan::find_attribute_value_in_tag(xml, "w14:ligatures", "w14:val")
78        .as_deref()
79        .and_then(TextLigatures::from_xml_value)
80}
81
82pub(crate) fn parse_contextual_alternates_from_xml(xml: &str) -> Option<bool> {
83    let tag = super::scan::find_tag_token(xml, "w14:cntxtAlts")?;
84    match get_attribute(&tag, "w14:val").as_deref() {
85        None | Some("true" | "1") => Some(true),
86        Some("false" | "0") => Some(false),
87        _ => None,
88    }
89}
90
91pub(crate) fn parse_character_scale_percent_value(raw: &str) -> Option<u16> {
92    let value = raw.trim();
93    let (digits, negative) = if let Some(digits) = value.strip_suffix('%') {
94        (digits, false)
95    } else if let Some(digits) = value.strip_prefix('-') {
96        (digits, true)
97    } else {
98        (value.strip_prefix('+').unwrap_or(value), false)
99    };
100    if digits.is_empty() || !digits.bytes().all(|digit| digit.is_ascii_digit()) {
101        return None;
102    }
103    let significant_digits = digits.trim_start_matches('0');
104    let percent = if significant_digits.is_empty() {
105        0
106    } else {
107        significant_digits.parse::<u16>().ok()?
108    };
109    (percent <= 600 && (!negative || percent == 0)).then_some(percent)
110}
111
112pub(crate) fn parse_character_scale_percent_from_xml(xml: &str) -> Option<u16> {
113    let tag = super::scan::find_tag_token(xml, "w:w")?;
114    get_attribute(&tag, "w:val")
115        .as_deref()
116        .map(parse_character_scale_percent_value)
117        .unwrap_or(Some(100))
118}
119
120fn text_script_flags(xml: &str) -> u8 {
121    if xml.is_ascii() && !xml.contains("&#") {
122        return 0;
123    }
124    let mut flags = 0;
125    if let Some(pattern) = re::get(r"(?is)<(?:w|a):t\b[^>]*>([\s\S]*?)</(?:w|a):t>") {
126        for caps in pattern.captures_iter(xml) {
127            if let Some(text) = caps.get(1) {
128                for ch in crate::xml::decode_xml_entities(text.as_str()).chars() {
129                    let code = ch as u32;
130                    if matches!(code, 0x2e80..=0x9fff | 0xac00..=0xd7af) {
131                        flags |= 1;
132                    }
133                    if matches!(code, 0x0590..=0x08ff | 0xfb1d..=0xfefc) {
134                        flags |= 2;
135                    }
136                }
137            }
138        }
139    }
140    flags
141}
142
143pub fn parse_text_style_from_xml(xml: &str, theme_fonts: &ThemeFontMap) -> Option<TextStyle> {
144    cached_text_style(xml, theme_fonts, text_script_flags(xml))
145}
146
147fn has_drawing_text_style_tags(xml: &str) -> bool {
148    xml.split('<').skip(1).any(|tag| {
149        if !super::scan::starts_with_ignore_ascii_case(tag, "a:") {
150            return false;
151        }
152        let tag = &tag[2..];
153        match tag.as_bytes().first().map(u8::to_ascii_lowercase) {
154            Some(b'r') => super::scan::starts_with_ignore_ascii_case(tag, "rPr"),
155            Some(b'd') => super::scan::starts_with_ignore_ascii_case(tag, "defRPr"),
156            Some(b'l') => super::scan::starts_with_ignore_ascii_case(tag, "latin"),
157            _ => false,
158        }
159    })
160}
161
162fn parse_text_style_with_scripts(
163    xml: &str,
164    theme_fonts: &ThemeFontMap,
165    scripts: u8,
166) -> Option<TextStyle> {
167    if xml.is_empty() {
168        return None;
169    }
170    let bold = parse_on_off_attribute(xml, "b");
171    let italic = parse_on_off_attribute(xml, "i");
172    let underline = parse_underline_attribute(xml);
173    let strike = parse_on_off_attribute(xml, "strike");
174    let all_caps = parse_on_off_attribute(xml, "caps");
175    let small_caps = parse_on_off_attribute(xml, "smallCaps");
176
177    let color_match = super::scan::find_attribute_value_in_tag(xml, "w:color", "w:val")
178        .filter(|value| !value.is_empty());
179    let highlight_match = super::scan::find_attribute_value_in_tag(xml, "w:highlight", "w:val")
180        .filter(|value| !value.is_empty());
181    let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd");
182    let character_spacing_match =
183        super::scan::find_attribute_value_in_tag(xml, "w:spacing", "w:val");
184    let size_match = super::scan::find_attribute_value_in_tag(xml, "w:sz", "w:val")
185        .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()));
186    let complex_size_match = super::scan::find_attribute_value_in_tag(xml, "w:szCs", "w:val")
187        .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()));
188    let run_fonts_tag = super::scan::find_tag_token_ref(xml, "w:rFonts").unwrap_or_default();
189    let ascii_font = get_attribute(&run_fonts_tag, "w:ascii");
190    let h_ansi_font = get_attribute(&run_fonts_tag, "w:hAnsi");
191    let east_asia_font = get_attribute(&run_fonts_tag, "w:eastAsia");
192    let complex_script_font = get_attribute(&run_fonts_tag, "w:cs");
193    let ascii_theme_font = get_attribute(&run_fonts_tag, "w:asciiTheme");
194    let h_ansi_theme_font = get_attribute(&run_fonts_tag, "w:hAnsiTheme");
195    let east_asia_theme_font = get_attribute(&run_fonts_tag, "w:eastAsiaTheme");
196    let complex_script_theme_font = get_attribute(&run_fonts_tag, "w:csTheme");
197    let font_hint = get_attribute(&run_fonts_tag, "w:hint");
198    let language_tag = super::scan::find_tag_token_ref(xml, "w:lang").unwrap_or_default();
199    let language = get_attribute(&language_tag, "w:val");
200    let language_east_asia = get_attribute(&language_tag, "w:eastAsia");
201    let language_bidi = get_attribute(&language_tag, "w:bidi");
202    let right_to_left = parse_on_off_attribute(xml, "rtl");
203    let complex_script = parse_on_off_attribute(xml, "cs");
204    let vertical_align_match =
205        super::scan::find_attribute_value_in_tag(xml, "w:vertAlign", "w:val")
206            .filter(|value| !value.is_empty());
207    let drawing_xml = if has_drawing_text_style_tags(xml) {
208        xml
209    } else {
210        ""
211    };
212    let drawing_bold_match = regex_capture(drawing_xml, r#"(?i)<a:rPr\b[^>]*\bb="([^"]+)""#);
213    let drawing_italic_match = regex_capture(drawing_xml, r#"(?i)<a:rPr\b[^>]*\bi="([^"]+)""#);
214    let drawing_underline_match = regex_capture(drawing_xml, r#"(?i)<a:rPr\b[^>]*\bu="([^"]+)""#);
215    let drawing_strike_match =
216        regex_capture(drawing_xml, r#"(?i)<a:rPr\b[^>]*\b(?:strike|s)="([^"]+)""#);
217    let drawing_color_match = regex_capture(
218        drawing_xml,
219        r#"(?is)<a:rPr\b[\s\S]*?<a:(?:solidFill|srgbClr)\b[\s\S]*?<a:srgbClr\b[^>]*val="([^"]+)""#,
220    );
221    let drawing_size_match = regex_capture(drawing_xml, r#"(?i)<a:rPr\b[^>]*\bsz="(\d+)""#);
222    let drawing_font_match = regex_capture(
223        drawing_xml,
224        r#"(?is)<a:rPr\b[\s\S]*?<a:latin\b[^>]*typeface="([^"]+)""#,
225    );
226    let drawing_default_font_match = regex_capture(
227        drawing_xml,
228        r#"(?is)<a:defRPr\b[\s\S]*?<a:latin\b[^>]*typeface="([^"]+)""#,
229    );
230    let drawing_any_latin_match =
231        regex_capture(drawing_xml, r#"(?i)<a:latin\b[^>]*typeface="([^"]+)""#);
232    let run_border_tag = super::scan::find_tag_token_ref(xml, "w:bdr");
233
234    let contains_east_asia_glyphs = scripts & 1 != 0;
235    let contains_complex_script_glyphs = scripts & 2 != 0;
236
237    let mut style = 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    if let Some(v) = bold {
283        style.bold = Some(v);
284        has_any = true;
285    }
286    if let Some(v) = italic {
287        style.italic = Some(v);
288        has_any = true;
289    }
290    if let Some(v) = underline {
291        style.underline = Some(v);
292        has_any = true;
293    }
294    if let Some(v) = strike {
295        style.strike = Some(v);
296        has_any = true;
297    }
298    if let Some(v) = all_caps {
299        style.all_caps = Some(v);
300        has_any = true;
301    }
302    if let Some(v) = small_caps {
303        style.small_caps = Some(v);
304        has_any = true;
305    }
306
307    if style.bold.is_none() {
308        if let Some(value) = drawing_bold_match {
309            let value = value.to_ascii_lowercase();
310            style.bold = Some(value != "0" && value != "false");
311            has_any = true;
312        }
313    }
314    if style.italic.is_none() {
315        if let Some(value) = drawing_italic_match {
316            let value = value.to_ascii_lowercase();
317            style.italic = Some(value != "0" && value != "false");
318            has_any = true;
319        }
320    }
321    if style.underline.is_none() {
322        if let Some(value) = drawing_underline_match {
323            let value = value.to_ascii_lowercase();
324            style.underline = Some(value != "none" && value != "false" && value != "0");
325            has_any = true;
326        }
327    }
328    if style.strike.is_none() {
329        if let Some(value) = drawing_strike_match {
330            let value = value.to_ascii_lowercase();
331            style.strike =
332                Some(value != "nostrike" && value != "none" && value != "false" && value != "0");
333            has_any = true;
334        }
335    }
336
337    if let Some(color) = normalize_hex_color(color_match.as_deref()) {
338        style.color = Some(color);
339        has_any = true;
340    } else if let Some(color) = normalize_hex_color(drawing_color_match.as_deref()) {
341        style.color = Some(color);
342        has_any = true;
343    }
344
345    if let Some(highlight) = highlight_match {
346        style.highlight = Some(highlight);
347        has_any = true;
348    }
349
350    if let Some(tag) = shading_tag.as_deref() {
351        if let Some(fill) = normalize_hex_color(get_attribute(tag, "w:fill").as_deref()) {
352            style.background_color = Some(fill);
353            has_any = true;
354        }
355    }
356
357    style.character_scale_percent = parse_character_scale_percent_from_xml(xml);
358    style.source_character_scale = super::scan::find_attribute_value_in_tag(xml, "w:w", "w:val");
359    has_any |= style.character_scale_percent.is_some() || style.source_character_scale.is_some();
360
361    if let Some(spacing) = character_spacing_match.and_then(|v| v.parse::<i64>().ok()) {
362        style.character_spacing_twips = Some(spacing);
363        has_any = true;
364    }
365
366    if let Some(ligatures) = parse_text_ligatures_from_xml(xml) {
367        style.ligatures = Some(ligatures);
368        has_any = true;
369    }
370    if let Some(contextual_alternates) = parse_contextual_alternates_from_xml(xml) {
371        style.contextual_alternates = Some(contextual_alternates);
372        has_any = true;
373    }
374
375    if let Some(kerning) = super::scan::find_attribute_value_in_tag(xml, "w:kern", "w:val")
376        .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
377        .and_then(|value| value.parse::<f64>().ok())
378    {
379        if kerning.is_finite() && kerning >= 0.0 {
380            style.kerning_min_font_size_pt = Some(kerning / 2.0);
381            has_any = true;
382        }
383    }
384
385    if let Some(size) = size_match.and_then(|v| v.parse::<f64>().ok()) {
386        style.font_size_pt = Some(size / 2.0);
387        has_any = true;
388    } else if let Some(size) = drawing_size_match.and_then(|v| v.parse::<f64>().ok()) {
389        style.font_size_pt = Some(size / 100.0);
390        has_any = true;
391    }
392
393    if let Some(size) = complex_size_match.and_then(|v| v.parse::<f64>().ok()) {
394        style.font_size_cs_pt = Some(size / 2.0);
395        has_any = true;
396    }
397
398    style.font_family_ascii = ascii_font.clone();
399    style.font_family_h_ansi = h_ansi_font.clone();
400    style.font_family_east_asia = east_asia_font.clone();
401    style.font_family_cs = complex_script_font.clone();
402    style.font_theme_ascii = ascii_theme_font.clone();
403    style.font_theme_h_ansi = h_ansi_theme_font.clone();
404    style.font_theme_east_asia = east_asia_theme_font.clone();
405    style.font_theme_cs = complex_script_theme_font.clone();
406    style.resolved_font_family_ascii = ascii_theme_font
407        .as_deref()
408        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
409        .or_else(|| ascii_font.clone());
410    style.resolved_font_family_h_ansi = h_ansi_theme_font
411        .as_deref()
412        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
413        .or_else(|| h_ansi_font.clone());
414    style.resolved_font_family_east_asia = east_asia_theme_font
415        .as_deref()
416        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
417        .or_else(|| east_asia_font.clone());
418    style.resolved_font_family_cs = complex_script_theme_font
419        .as_deref()
420        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
421        .or_else(|| complex_script_font.clone());
422    style.font_hint = font_hint;
423    style.language = language;
424    style.language_east_asia = language_east_asia;
425    style.language_bidi = language_bidi;
426    style.right_to_left = right_to_left;
427    style.complex_script = complex_script;
428    has_any |= style.font_family_ascii.is_some()
429        || style.font_family_h_ansi.is_some()
430        || style.font_family_east_asia.is_some()
431        || style.font_family_cs.is_some()
432        || style.font_theme_ascii.is_some()
433        || style.font_theme_h_ansi.is_some()
434        || style.font_theme_east_asia.is_some()
435        || style.font_theme_cs.is_some()
436        || style.resolved_font_family_ascii.is_some()
437        || style.resolved_font_family_h_ansi.is_some()
438        || style.resolved_font_family_east_asia.is_some()
439        || style.resolved_font_family_cs.is_some()
440        || style.font_hint.is_some()
441        || style.language.is_some()
442        || style.language_east_asia.is_some()
443        || style.language_bidi.is_some()
444        || style.right_to_left.is_some()
445        || style.complex_script.is_some();
446
447    let run_font_family = ascii_font.clone().or(h_ansi_font.clone());
448    let run_theme_font_token = ascii_theme_font.clone().or(h_ansi_theme_font.clone());
449    let east_asia_fallback_font = if contains_east_asia_glyphs {
450        east_asia_font.clone()
451    } else {
452        None
453    };
454    let east_asia_fallback_theme_token = if contains_east_asia_glyphs {
455        east_asia_theme_font.clone()
456    } else {
457        None
458    };
459    let complex_script_fallback_font = if contains_complex_script_glyphs {
460        complex_script_font.clone()
461    } else {
462        None
463    };
464    let complex_script_fallback_theme_token = if contains_complex_script_glyphs {
465        complex_script_theme_font.clone()
466    } else {
467        None
468    };
469    let symbol_fallback_font = {
470        let font = east_asia_font.clone().or(complex_script_font.clone());
471        font.as_deref()
472            .filter(|f| {
473                re::get_unchecked(r"(?i)(symbol|emoji|dingbats?|wingdings|webdings)").is_match(f)
474            })
475            .map(|_| east_asia_font.clone().or(complex_script_font.clone()))
476            .flatten()
477    };
478
479    if let Some(family) = run_font_family {
480        style.font_family = Some(family);
481        has_any = true;
482    } else if let Some(token) = run_theme_font_token.as_deref() {
483        if let Some(family) = resolve_theme_font(Some(token), theme_fonts) {
484            style.font_family = Some(family);
485            has_any = true;
486        }
487    } else if let Some(family) = east_asia_fallback_font {
488        style.font_family = Some(family);
489        has_any = true;
490    } else if let Some(token) = east_asia_fallback_theme_token.as_deref() {
491        if let Some(family) = resolve_theme_font(Some(token), theme_fonts) {
492            style.font_family = Some(family);
493            has_any = true;
494        }
495    } else if let Some(family) = complex_script_fallback_font {
496        style.font_family = Some(family);
497        has_any = true;
498    } else if let Some(token) = complex_script_fallback_theme_token.as_deref() {
499        if let Some(family) = resolve_theme_font(Some(token), theme_fonts) {
500            style.font_family = Some(family);
501            has_any = true;
502        }
503    } else if let Some(family) = symbol_fallback_font {
504        style.font_family = Some(family);
505        has_any = true;
506    } else if let Some(family) = drawing_font_match {
507        style.font_family = Some(family);
508        has_any = true;
509    } else if let Some(family) = drawing_default_font_match {
510        style.font_family = Some(family);
511        has_any = true;
512    } else if let Some(family) = drawing_any_latin_match {
513        style.font_family = Some(family);
514        has_any = true;
515    }
516    style.source_font_family = style.font_family.clone();
517
518    if let Some(value) = vertical_align_match.map(|v| v.to_ascii_lowercase()) {
519        style.vertical_align = match value.as_str() {
520            "superscript" => Some(VerticalAlign::Superscript),
521            "subscript" => Some(VerticalAlign::Subscript),
522            _ => None,
523        };
524        if style.vertical_align.is_some() {
525            has_any = true;
526        }
527    }
528
529    if let Some(border) = parse_text_run_border_style(run_border_tag.as_deref()) {
530        style.run_border = Some(border);
531        has_any = true;
532    }
533
534    has_any |= super::text_effects::parse_into(&mut style, xml);
535
536    if has_any {
537        Some(style)
538    } else {
539        None
540    }
541}
542
543/// ECMA-376 §17.7.3 toggle-property resolution: a toggle set true by both the
544/// paragraph style and the character style cancels out (XOR across style
545/// application levels). Direct formatting and explicit `val="0"` stay
546/// absolute, and basedOn chains resolve by normal inheritance beforehand.
547fn resolve_toggle_property(
548    default_value: Option<bool>,
549    paragraph_value: Option<bool>,
550    character_value: Option<bool>,
551    direct_value: Option<bool>,
552) -> Option<bool> {
553    if direct_value.is_some() {
554        return direct_value;
555    }
556    if paragraph_value == Some(true) && character_value == Some(true) {
557        return Some(false);
558    }
559    character_value.or(paragraph_value).or(default_value)
560}
561
562type RawStyleEntries =
563    std::collections::HashMap<String, Vec<(u8, ThemeFontMap, Option<SharedTextStyle>)>>;
564
565#[derive(Default)]
566struct RunStyleCache {
567    entries: RawStyleEntries,
568    resolved: std::collections::HashMap<String, Vec<ResolvedRunStyle>>,
569    paragraphs:
570        std::collections::HashMap<String, Vec<(Option<ParagraphSpacing>, Option<SharedParagraphStyle>)>>,
571    mark_default: Option<CachedMarkDefaultStyle>,
572    #[cfg(not(target_arch = "wasm32"))]
573    sheet: Option<std::sync::Arc<super::context::ParsedStyleSheet>>,
574}
575
576struct CachedMarkDefaultStyle {
577    document: Option<TextStyle>,
578    bound_document: bool,
579    table: Option<TextStyle>,
580    style: Option<SharedTextStyle>,
581}
582
583struct ResolvedRunStyle {
584    scripts: u8,
585    paragraph: Option<String>,
586    table: Option<Box<TextStyle>>,
587    style: Option<SharedTextStyle>,
588}
589
590thread_local! {
591    static RUN_STYLE_CACHE: std::cell::RefCell<Option<RunStyleCache>> = const { std::cell::RefCell::new(None) };
592}
593
594pub(crate) struct RunStyleCacheScope(Option<RunStyleCache>);
595
596#[cfg(not(target_arch = "wasm32"))]
597thread_local! {
598    static RETAINED_RAW_STYLES: std::cell::RefCell<Option<RawStyleEntries>> = const { std::cell::RefCell::new(None) };
599    static RETAINED_RESOLVED_STYLES: std::cell::RefCell<Option<RunStyleCache>> = const { std::cell::RefCell::new(None) };
600}
601
602pub(crate) fn bind_style_cache(sheet: &super::context::ParsedStyleSheet) {
603    #[cfg(not(target_arch = "wasm32"))]
604    if let Some(identity) = super::styles::cached_document_style_identity(sheet) {
605        bind_shared_style_cache(identity);
606    }
607    #[cfg(target_arch = "wasm32")]
608    let _ = sheet;
609}
610
611#[cfg(not(target_arch = "wasm32"))]
612fn bind_shared_style_cache(sheet: std::sync::Arc<super::context::ParsedStyleSheet>) {
613    RUN_STYLE_CACHE.with(|current| {
614        let mut current = current.borrow_mut();
615        let Some(current) = current.as_mut() else { return; };
616        if current.sheet.as_ref().is_some_and(|previous| std::sync::Arc::ptr_eq(previous, &sheet)) {
617            return;
618        }
619        let previous = RETAINED_RESOLVED_STYLES.with(|retained| retained.take())
620            .filter(|previous| previous.sheet.as_ref().is_some_and(|previous| std::sync::Arc::ptr_eq(previous, &sheet)));
621        current.resolved.clear();
622        current.paragraphs.clear();
623        current.mark_default = None;
624        current.sheet = Some(sheet);
625        if let Some(previous) = previous {
626            current.resolved = previous.resolved;
627            current.paragraphs = previous.paragraphs;
628            current.mark_default = previous.mark_default;
629        }
630    });
631}
632
633pub(crate) fn run_style_cache_scope() -> RunStyleCacheScope {
634    #[allow(unused_mut)]
635    let mut current = RunStyleCache::default();
636    #[cfg(not(target_arch = "wasm32"))]
637    if RUN_STYLE_CACHE.with(|cache| cache.borrow().is_none()) {
638        current.entries = RETAINED_RAW_STYLES
639            .with(|cache| cache.take())
640            .unwrap_or_default();
641    }
642    RunStyleCacheScope(RUN_STYLE_CACHE.with(|cache| cache.replace(Some(current))))
643}
644
645impl Drop for RunStyleCacheScope {
646    fn drop(&mut self) {
647        #[cfg(not(target_arch = "wasm32"))]
648        let outer = self.0.is_none();
649        let _current = RUN_STYLE_CACHE.with(|cache| cache.replace(self.0.take()));
650        #[cfg(not(target_arch = "wasm32"))]
651        if outer {
652            if let Some(mut current) = _current {
653                if current.entries.len() <= 512
654                    && current.entries.keys().map(String::len).sum::<usize>() <= 512 * 1024
655                    && current.entries.values().map(Vec::len).sum::<usize>() <= 1024
656                {
657                    RETAINED_RAW_STYLES
658                        .with(|cache| cache.replace(Some(std::mem::take(&mut current.entries))));
659                }
660                current.entries = RawStyleEntries::default();
661                if current.sheet.is_some()
662                    && current.resolved.len() + current.paragraphs.len() <= 512
663                    && current.resolved.keys().chain(current.paragraphs.keys()).map(String::len).sum::<usize>() <= 512 * 1024
664                    && current.resolved.values().map(Vec::len).sum::<usize>()
665                        + current.paragraphs.values().map(Vec::len).sum::<usize>() <= 1024
666                {
667                    RETAINED_RESOLVED_STYLES.with(|cache| cache.replace(Some(current)));
668                }
669            }
670        }
671    }
672}
673
674fn same_mark_default_style(left: Option<&TextStyle>, right: Option<&TextStyle>) -> bool {
675    if left != right {
676        return false;
677    }
678    let (Some(left), Some(right)) = (left, right) else {
679        return true;
680    };
681    left.font_size_pt.map(f64::to_bits) == right.font_size_pt.map(f64::to_bits)
682        && left.font_size_cs_pt.map(f64::to_bits) == right.font_size_cs_pt.map(f64::to_bits)
683        && left.kerning_min_font_size_pt.map(f64::to_bits)
684            == right.kerning_min_font_size_pt.map(f64::to_bits)
685        && left
686            .run_border
687            .as_ref()
688            .and_then(|border| border.size_eighth_pt)
689            .map(f64::to_bits)
690            == right
691                .run_border
692                .as_ref()
693                .and_then(|border| border.size_eighth_pt)
694                .map(f64::to_bits)
695        && left
696            .run_border
697            .as_ref()
698            .and_then(|border| border.space_pt)
699            .map(f64::to_bits)
700            == right
701                .run_border
702                .as_ref()
703                .and_then(|border| border.space_pt)
704                .map(f64::to_bits)
705}
706
707fn mark_document_is_bound(cache: &RunStyleCache, document: Option<&TextStyle>) -> bool {
708    #[cfg(not(target_arch = "wasm32"))]
709    {
710        cache.sheet.as_ref().is_some_and(|sheet| {
711            match (sheet.default_run_style.as_ref(), document) {
712                (Some(left), Some(right)) => std::ptr::eq(left, right),
713                (None, None) => true,
714                _ => false,
715            }
716        })
717    }
718    #[cfg(target_arch = "wasm32")]
719    {
720        let _ = (cache, document);
721        false
722    }
723}
724
725pub(crate) fn cached_mark_default_style(
726    document: Option<&TextStyle>,
727    table: Option<&TextStyle>,
728) -> Option<SharedTextStyle> {
729    let cached = RUN_STYLE_CACHE.with(|cache| {
730        let cache = cache.borrow();
731        let cache = cache.as_ref()?;
732        let entry = cache.mark_default.as_ref()?;
733        ((entry.bound_document && mark_document_is_bound(cache, document)
734            || same_mark_default_style(entry.document.as_ref(), document))
735            && same_mark_default_style(entry.table.as_ref(), table))
736        .then(|| entry.style.clone())
737    });
738    if let Some(style) = cached {
739        return style;
740    }
741    let style: Option<SharedTextStyle> =
742        super::util::merge_text_style_refs(&[document, table]).map(Into::into);
743    RUN_STYLE_CACHE.with(|cache| {
744        if let Some(cache) = cache.borrow_mut().as_mut() {
745            cache.mark_default = Some(CachedMarkDefaultStyle {
746                document: document.cloned(),
747                bound_document: mark_document_is_bound(cache, document),
748                table: table.cloned(),
749                style: style.clone(),
750            });
751        }
752    });
753    style
754}
755
756pub(crate) fn cached_definition_style(xml: &str, fonts: &ThemeFontMap) -> Option<TextStyle> {
757    cached_text_style(xml, fonts, 128)
758}
759
760fn cached_text_style(xml: &str, fonts: &ThemeFontMap, scripts: u8) -> Option<TextStyle> {
761    if xml.is_empty() {
762        return None;
763    }
764    if RUN_STYLE_CACHE.with(|cache| cache.borrow().is_none()) {
765        return if scripts == 128 {
766            super::styles::parse_definition_style_uncached(xml, fonts)
767        } else {
768            parse_text_style_with_scripts(xml, fonts, scripts)
769        };
770    }
771    cached_shared_text_style(xml, fonts, scripts).map(SharedTextStyle::into_owned)
772}
773
774pub(crate) fn parse_shared_text_style_from_xml(
775    xml: &str,
776    fonts: &ThemeFontMap,
777) -> Option<SharedTextStyle> {
778    cached_shared_text_style(xml, fonts, text_script_flags(xml))
779}
780
781fn cached_shared_text_style(
782    xml: &str,
783    fonts: &ThemeFontMap,
784    scripts: u8,
785) -> Option<SharedTextStyle> {
786    if xml.is_empty() {
787        return None;
788    }
789    let cached = RUN_STYLE_CACHE.with(|cache| {
790        let cache = cache.borrow();
791        cache
792            .as_ref()?
793            .entries
794            .get(xml)?
795            .iter()
796            .find(|(flags, theme, _)| *flags == scripts && theme == fonts)
797            .map(|(_, _, style)| style.clone())
798    });
799    if let Some(style) = cached {
800        return style;
801    }
802    let style = if scripts == 128 {
803        super::styles::parse_definition_style_uncached(xml, fonts)
804    } else {
805        parse_text_style_with_scripts(xml, fonts, scripts)
806    }
807    .map(SharedTextStyle::from);
808    if xml.len() <= 8192 {
809        RUN_STYLE_CACHE.with(|cache| {
810            if let Some(cache) = cache
811                .borrow_mut()
812                .as_mut()
813                .filter(|cache| cache.entries.len() < 4096)
814            {
815                let variants = cache
816                    .entries
817                    .entry(xml.to_owned())
818                    .or_insert_with(|| Vec::with_capacity(1));
819                if variants.len() < 16 {
820                    variants.push((scripts, fonts.clone(), style.clone()));
821                }
822            }
823        });
824    }
825    return style;
826}
827
828fn ordinary_run_properties(run_xml: &str) -> Option<&str> {
829    let mut properties = None;
830    let mut seen = false;
831    if let Some(ordinary) = crate::xml_index::for_each_child(run_xml, |name, xml| {
832        seen = true;
833        if name.eq_ignore_ascii_case("w:rPr") {
834            return properties.replace(xml).is_none();
835        }
836        if name.eq_ignore_ascii_case("w:fldChar") && !xml.ends_with("/>") {
837            return false;
838        }
839        [
840            "w:t",
841            "w:tab",
842            "w:br",
843            "w:cr",
844            "w:sym",
845            "w:noBreakHyphen",
846            "w:softHyphen",
847            "w:footnoteReference",
848            "w:endnoteReference",
849            "w:instrText",
850            "w:fldChar",
851            "w:lastRenderedPageBreak",
852        ]
853        .iter()
854        .any(|tag| name.eq_ignore_ascii_case(tag))
855    }) {
856        return (ordinary && seen).then(|| properties.unwrap_or(""));
857    }
858    let ranges = crate::xml::extract_direct_child_tag_ranges(run_xml);
859    let ordinary = !ranges.is_empty()
860        && ranges
861            .iter()
862            .filter(|range| range.tag_name.eq_ignore_ascii_case("w:rPr"))
863            .count()
864            <= 1
865        && ranges.iter().all(|range| {
866            if range.tag_name.eq_ignore_ascii_case("w:fldChar")
867                && !run_xml[range.start..range.end].ends_with("/>")
868            {
869                return false;
870            }
871            [
872                "w:rPr",
873                "w:t",
874                "w:tab",
875                "w:br",
876                "w:cr",
877                "w:sym",
878                "w:noBreakHyphen",
879                "w:softHyphen",
880                "w:footnoteReference",
881                "w:endnoteReference",
882                "w:instrText",
883                "w:fldChar",
884                "w:lastRenderedPageBreak",
885            ]
886            .iter()
887            .any(|tag| range.tag_name.eq_ignore_ascii_case(tag))
888        });
889    if ordinary {
890        let properties = ranges
891            .iter()
892            .find(|range| range.tag_name.eq_ignore_ascii_case("w:rPr"))
893            .map(|range| &run_xml[range.start..range.end])
894            .unwrap_or("");
895        return Some(properties);
896    }
897    None
898}
899
900fn parse_direct_run_style(
901    run_xml: &str,
902    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
903) -> Option<TextStyle> {
904    let text_box_content = prefer_alternate_content_choice(run_xml);
905    let text_box_run_xml = extract_balanced_tag_blocks(&text_box_content, "w:txbxContent")
906        .into_iter()
907        .next()
908        .and_then(|txbx| extract_balanced_tag_blocks(&txbx, "w:r").into_iter().next());
909    parse_text_style_from_xml(
910        text_box_run_xml.as_deref().unwrap_or(run_xml),
911        &context.styles().theme_fonts,
912    )
913}
914
915pub fn parse_run_style(
916    run_xml: &str,
917    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
918    paragraph_style_id: Option<&str>,
919) -> Option<TextStyle> {
920    parse_run_style_in_table(run_xml, context, paragraph_style_id, None)
921        .map(SharedTextStyle::into_owned)
922}
923
924pub(crate) fn parse_run_style_in_table(
925    run_xml: &str,
926    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
927    paragraph_style_id: Option<&str>,
928    table_run_style: Option<&TextStyle>,
929) -> Option<SharedTextStyle> {
930    let properties = ordinary_run_properties(run_xml);
931    resolve_run_style(
932        run_xml,
933        properties,
934        context,
935        paragraph_style_id,
936        table_run_style,
937    )
938}
939
940pub(crate) fn parse_mark_run_style(
941    properties: &str,
942    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
943    paragraph_style_id: Option<&str>,
944    table_run_style: Option<&TextStyle>,
945) -> Option<SharedTextStyle> {
946    resolve_run_style(
947        properties,
948        Some(properties),
949        context,
950        paragraph_style_id,
951        table_run_style,
952    )
953}
954
955fn resolve_run_style(
956    run_xml: &str,
957    properties: Option<&str>,
958    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
959    paragraph_style_id: Option<&str>,
960    table_run_style: Option<&TextStyle>,
961) -> Option<SharedTextStyle> {
962    let scripts = if properties.is_some_and(|xml| !xml.is_empty()) {
963        text_script_flags(run_xml)
964    } else {
965        0
966    };
967    resolve_run_style_with_scripts(
968        run_xml,
969        properties,
970        scripts,
971        context,
972        paragraph_style_id,
973        table_run_style,
974    )
975}
976
977pub(crate) fn parse_run_style_from_properties(
978    run_xml: &str,
979    properties: &str,
980    scripts: u8,
981    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
982    paragraph_style_id: Option<&str>,
983    table_run_style: Option<&TextStyle>,
984) -> Option<SharedTextStyle> {
985    resolve_run_style_with_scripts(
986        run_xml,
987        Some(properties),
988        if properties.is_empty() { 0 } else { scripts },
989        context,
990        paragraph_style_id,
991        table_run_style,
992    )
993}
994
995fn resolve_run_style_with_scripts(
996    run_xml: &str,
997    properties: Option<&str>,
998    scripts: u8,
999    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1000    paragraph_style_id: Option<&str>,
1001    table_run_style: Option<&TextStyle>,
1002) -> Option<SharedTextStyle> {
1003    if let Some(xml) = properties {
1004        let cached = RUN_STYLE_CACHE.with(|cache| {
1005            let cache = cache.borrow();
1006            cache
1007                .as_ref()?
1008                .resolved
1009                .get(xml)?
1010                .iter()
1011                .find(|entry| {
1012                    entry.scripts == scripts
1013                        && entry.paragraph.as_deref() == paragraph_style_id
1014                        && same_mark_default_style(entry.table.as_deref(), table_run_style)
1015                })
1016                .map(|entry| entry.style.clone())
1017        });
1018        if let Some(style) = cached {
1019            return style;
1020        }
1021    }
1022    let run_style_id = super::scan::find_attribute_value_in_tag(run_xml, "w:rStyle", "w:val")
1023        .filter(|value| !value.is_empty());
1024    let direct = match properties {
1025        Some(xml) => cached_shared_text_style(xml, &context.styles().theme_fonts, scripts),
1026        None => parse_direct_run_style(run_xml, context).map(SharedTextStyle::from),
1027    };
1028    let inherited_paragraph_run_style = paragraph_style_id
1029        .and_then(|id| context.styles().paragraph_style_by_id.get(id))
1030        .and_then(|style| style.run_style.as_ref());
1031    let inherited_run_style = run_style_id
1032        .as_deref()
1033        .and_then(|id| context.styles().run_style_by_id.get(id));
1034    let default_run_style = super::util::merge_text_style_refs(&[
1035        context.styles().default_run_style.as_ref(),
1036        table_run_style,
1037    ]);
1038
1039    let toggle_of = |get: fn(&TextStyle) -> Option<bool>| {
1040        resolve_toggle_property(
1041            default_run_style.as_ref().and_then(get),
1042            inherited_paragraph_run_style.and_then(get),
1043            inherited_run_style.and_then(get),
1044            direct.as_deref().and_then(get),
1045        )
1046    };
1047    let bold = toggle_of(|s| s.bold);
1048    let italic = toggle_of(|s| s.italic);
1049    let strike = toggle_of(|s| s.strike);
1050    let all_caps = toggle_of(|s| s.all_caps);
1051    let small_caps = toggle_of(|s| s.small_caps);
1052
1053    let merged = super::util::merge_text_style_refs(&[
1054        default_run_style.as_ref(),
1055        inherited_paragraph_run_style,
1056        inherited_run_style,
1057        direct.as_deref(),
1058    ]);
1059    let resolved = merged.map(|mut style| {
1060        style.bold = bold;
1061        style.italic = italic;
1062        style.strike = strike;
1063        style.all_caps = all_caps;
1064        style.small_caps = small_caps;
1065        super::text_effects::resolve(&mut style, &context.styles().theme_colors);
1066        SharedTextStyle::from(style)
1067    });
1068    if let Some(xml) = properties.filter(|xml| xml.len() <= 8192) {
1069        RUN_STYLE_CACHE.with(|cache| {
1070            if let Some(cache) = cache
1071                .borrow_mut()
1072                .as_mut()
1073                .filter(|cache| cache.resolved.len() < 4096)
1074            {
1075                let variants = cache
1076                    .resolved
1077                    .entry(xml.to_owned())
1078                    .or_insert_with(|| Vec::with_capacity(1));
1079                if variants.len() < 32 {
1080                    variants.push(ResolvedRunStyle {
1081                        scripts,
1082                        paragraph: paragraph_style_id.map(str::to_owned),
1083                        table: table_run_style.cloned().map(Box::new),
1084                        style: resolved.clone(),
1085                    });
1086                }
1087            }
1088        });
1089    }
1090    resolved
1091}
1092
1093pub fn parse_paragraph_style(
1094    paragraph_xml: &str,
1095    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1096) -> Option<ParagraphStyle> {
1097    parse_paragraph_style_in_table(paragraph_xml, context, None)
1098}
1099
1100pub fn parse_paragraph_style_in_table(
1101    paragraph_xml: &str,
1102    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1103    table_paragraph_spacing: Option<&ParagraphSpacing>,
1104) -> Option<ParagraphStyle> {
1105    let xml = super::styles::direct_table_property(paragraph_xml, "w:pPr").unwrap_or("");
1106    parse_paragraph_style_from_properties(xml, context, table_paragraph_spacing)
1107        .map(SharedParagraphStyle::into_owned)
1108}
1109
1110pub(crate) fn parse_paragraph_style_from_properties(
1111    xml: &str,
1112    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1113    table_paragraph_spacing: Option<&ParagraphSpacing>,
1114) -> Option<SharedParagraphStyle> {
1115    let cached = RUN_STYLE_CACHE.with(|cache| {
1116        let cache = cache.borrow();
1117        cache
1118            .as_ref()?
1119            .paragraphs
1120            .get(xml)?
1121            .iter()
1122            .find(|(spacing, _)| spacing.as_ref() == table_paragraph_spacing)
1123            .map(|(_, style)| style.clone())
1124    });
1125    if let Some(style) = cached {
1126        return style;
1127    }
1128    let style = parse_paragraph_style_uncached(xml, context, table_paragraph_spacing)
1129        .map(SharedParagraphStyle::from);
1130    if xml.len() <= 8192 {
1131        RUN_STYLE_CACHE.with(|cache| {
1132            if let Some(cache) = cache
1133                .borrow_mut()
1134                .as_mut()
1135                .filter(|cache| cache.paragraphs.len() < 4096)
1136            {
1137                let variants = cache
1138                    .paragraphs
1139                    .entry(xml.to_owned())
1140                    .or_insert_with(|| Vec::with_capacity(1));
1141                if variants.len() < 16 {
1142                    variants.push((table_paragraph_spacing.cloned(), style.clone()));
1143                }
1144            }
1145        });
1146    }
1147    style
1148}
1149
1150fn parse_paragraph_style_uncached(
1151    original_paragraph_properties_xml: &str,
1152    context: &ParseContext<'_, impl std::borrow::Borrow<super::context::ParsedStyleSheet>>,
1153    table_paragraph_spacing: Option<&ParagraphSpacing>,
1154) -> Option<ParagraphStyle> {
1155    let paragraph_properties_xml =
1156        crate::xml::without_direct_child_tags(original_paragraph_properties_xml, "w:pPrChange");
1157    let alignment_match =
1158        super::scan::find_attribute_value_in_tag(&paragraph_properties_xml, "w:jc", "w:val")
1159            .filter(|value| !value.is_empty());
1160    let p_style_match =
1161        super::scan::find_attribute_value_in_tag(&paragraph_properties_xml, "w:pStyle", "w:val")
1162            .filter(|value| !value.is_empty());
1163    let direct_snap_to_grid = parse_on_off_attribute(&paragraph_properties_xml, "snapToGrid");
1164    let direct_text_alignment = parse_paragraph_text_alignment_from_xml(&paragraph_properties_xml);
1165    let direct_spacing = parse_paragraph_spacing_from_xml(&paragraph_properties_xml);
1166    let direct_indent = parse_paragraph_indent_from_xml(&paragraph_properties_xml);
1167    let direct_background_color = parse_paragraph_shading_from_xml(&paragraph_properties_xml);
1168    let direct_borders = parse_paragraph_border_set_from_xml(&paragraph_properties_xml);
1169    let direct_numbering = parse_paragraph_numbering_from_xml(&paragraph_properties_xml);
1170    let direct_tab_stops = parse_paragraph_tab_stops_from_xml(&paragraph_properties_xml);
1171    let direct_drop_cap = parse_paragraph_drop_cap_from_xml(&paragraph_properties_xml);
1172    let direct_contextual_spacing =
1173        parse_on_off_attribute(&paragraph_properties_xml, "contextualSpacing");
1174    let direct_keep_next = parse_on_off_attribute(&paragraph_properties_xml, "keepNext");
1175    let direct_keep_lines = parse_on_off_attribute(&paragraph_properties_xml, "keepLines");
1176    let direct_widow_control = parse_on_off_attribute(&paragraph_properties_xml, "widowControl");
1177    let direct_page_break_before =
1178        parse_on_off_attribute(&paragraph_properties_xml, "pageBreakBefore");
1179    let has_direct_num_pr =
1180        re::get_unchecked(r"(?i)<w:numPr\b").is_match(&paragraph_properties_xml);
1181
1182    let explicit_style_id = p_style_match;
1183    let style_id = explicit_style_id
1184        .clone()
1185        .or_else(|| context.styles().default_paragraph_style_id.clone());
1186    let inherited = style_id
1187        .as_deref()
1188        .and_then(|id| context.styles().paragraph_style_by_id.get(id));
1189    let default_paragraph_style = context.styles().default_paragraph_style.as_ref();
1190
1191    let align = to_model_alignment(alignment_match.as_deref())
1192        .or_else(|| inherited.and_then(|s| s.align))
1193        .or_else(|| default_paragraph_style.and_then(|s| s.align));
1194    let inherited_text_alignment = inherited
1195        .and_then(|style| style.text_alignment)
1196        .or_else(|| default_paragraph_style.and_then(|style| style.text_alignment))
1197        .or(context.styles().default_paragraph_text_alignment);
1198    let text_alignment = direct_text_alignment.or(inherited_text_alignment);
1199    let inherited_snap_to_grid = inherited
1200        .and_then(|style| style.snap_to_grid)
1201        .or_else(|| default_paragraph_style.and_then(|style| style.snap_to_grid));
1202    let snap_to_grid = direct_snap_to_grid.or(inherited_snap_to_grid);
1203    let heading_level = super::styles::parse_heading_level_from_outline(&paragraph_properties_xml)
1204        .unwrap_or_else(|| match inherited {
1205            Some(style) => style.heading_level,
1206            None => normalize_heading_level(explicit_style_id.as_deref())
1207                .or_else(|| default_paragraph_style.and_then(|s| s.heading_level)),
1208        });
1209    let numbering = if has_direct_num_pr {
1210        direct_numbering.or_else(|| {
1211            // A direct numPr without a usable numId either removes numbering
1212            // (numId present but <= 0) or, when it carries only an ilvl,
1213            // changes the level of the style-provided list (ECMA-376: numId
1214            // and ilvl inherit independently through the hierarchy).
1215            match parse_paragraph_numbering_parts_from_xml(&paragraph_properties_xml) {
1216                Some((None, Some(direct_ilvl))) => inherited
1217                    .and_then(|s| s.numbering.clone())
1218                    .or_else(|| default_paragraph_style.and_then(|s| s.numbering.clone()))
1219                    .map(|style_numbering| crate::model::ParagraphNumbering {
1220                        num_id: style_numbering.num_id,
1221                        ilvl: direct_ilvl.max(0),
1222                    }),
1223                _ => None,
1224            }
1225        })
1226    } else {
1227        direct_numbering
1228            .or_else(|| inherited.and_then(|s| s.numbering.clone()))
1229            .or_else(|| default_paragraph_style.and_then(|s| s.numbering.clone()))
1230    };
1231    // ECMA-376 style cascade: docDefaults < table style pPr < paragraph style
1232    // < direct pPr. The implicit default paragraph style (no explicit pStyle)
1233    // sits below the table-style layer; an explicit pStyle overrides it.
1234    let style_layer_spacing = if explicit_style_id.is_some() {
1235        merge_paragraph_spacing(
1236            table_paragraph_spacing,
1237            inherited.and_then(|s| s.spacing.as_ref()),
1238        )
1239    } else {
1240        merge_paragraph_spacing(
1241            inherited.and_then(|s| s.spacing.as_ref()),
1242            table_paragraph_spacing,
1243        )
1244    };
1245    let spacing = merge_paragraph_spacing(
1246        merge_paragraph_spacing(
1247            context.styles().doc_default_paragraph_spacing.as_ref(),
1248            style_layer_spacing.as_ref(),
1249        )
1250        .as_ref(),
1251        direct_spacing.as_ref(),
1252    );
1253    let indent = merge_paragraph_indent(
1254        merge_paragraph_indent(
1255            default_paragraph_style.and_then(|s| s.indent.as_ref()),
1256            inherited.and_then(|s| s.indent.as_ref()),
1257        )
1258        .as_ref(),
1259        direct_indent.as_ref(),
1260    );
1261    let background_color = direct_background_color.or_else(|| {
1262        inherited
1263            .and_then(|s| s.background_color.clone())
1264            .or_else(|| default_paragraph_style.and_then(|s| s.background_color.clone()))
1265    });
1266    let borders = merge_paragraph_border_sets(
1267        merge_paragraph_border_sets(
1268            default_paragraph_style.and_then(|s| s.borders.as_ref()),
1269            inherited.and_then(|s| s.borders.as_ref()),
1270        )
1271        .as_ref(),
1272        direct_borders.as_ref(),
1273    );
1274    let tab_stops = merge_paragraph_tab_stops(
1275        merge_paragraph_tab_stops(
1276            default_paragraph_style.and_then(|s| s.tab_stops.as_deref()),
1277            inherited.and_then(|s| s.tab_stops.as_deref()),
1278        )
1279        .as_deref(),
1280        Some(direct_tab_stops.as_slice()),
1281    );
1282    let contextual_spacing = merge_paragraph_boolean(
1283        merge_paragraph_boolean(
1284            default_paragraph_style.and_then(|s| s.contextual_spacing),
1285            inherited.and_then(|s| s.contextual_spacing),
1286        ),
1287        direct_contextual_spacing,
1288    );
1289    let keep_next = merge_paragraph_boolean(
1290        merge_paragraph_boolean(
1291            default_paragraph_style.and_then(|s| s.keep_next),
1292            inherited.and_then(|s| s.keep_next),
1293        ),
1294        direct_keep_next,
1295    );
1296    let keep_lines = merge_paragraph_boolean(
1297        merge_paragraph_boolean(
1298            default_paragraph_style.and_then(|s| s.keep_lines),
1299            inherited.and_then(|s| s.keep_lines),
1300        ),
1301        direct_keep_lines,
1302    );
1303    let widow_control = merge_paragraph_boolean(
1304        merge_paragraph_boolean(
1305            default_paragraph_style.and_then(|s| s.widow_control),
1306            inherited.and_then(|s| s.widow_control),
1307        ),
1308        direct_widow_control,
1309    );
1310    let page_break_before = merge_paragraph_boolean(
1311        merge_paragraph_boolean(
1312            default_paragraph_style.and_then(|s| s.page_break_before),
1313            inherited.and_then(|s| s.page_break_before),
1314        ),
1315        direct_page_break_before,
1316    );
1317    let style_name = inherited.map(|s| s.name.clone());
1318
1319    if align.is_none()
1320        && snap_to_grid.is_none()
1321        && text_alignment.is_none()
1322        && heading_level.is_none()
1323        && style_id.is_none()
1324        && style_name.is_none()
1325        && numbering.is_none()
1326        && spacing.is_none()
1327        && indent.is_none()
1328        && background_color.is_none()
1329        && borders.is_none()
1330        && tab_stops.as_ref().is_none_or(|t| t.is_empty())
1331        && direct_drop_cap.is_none()
1332        && contextual_spacing.is_none()
1333        && keep_next.is_none()
1334        && keep_lines.is_none()
1335        && widow_control.is_none()
1336        && page_break_before.is_none()
1337    {
1338        return None;
1339    }
1340
1341    Some(ParagraphStyle {
1342        align,
1343        snap_to_grid,
1344        source_snap_to_grid: snap_to_grid,
1345        source_has_snap_to_grid: Some(
1346            super::styles::direct_table_property(&paragraph_properties_xml, "w:snapToGrid")
1347                .is_some(),
1348        ),
1349        source_inherited_snap_to_grid: inherited_snap_to_grid,
1350        source_table_snap_to_grid: None,
1351        text_alignment,
1352        source_text_alignment: text_alignment,
1353        source_has_text_alignment: Some(
1354            super::styles::direct_table_property(&paragraph_properties_xml, "w:textAlignment")
1355                .is_some(),
1356        ),
1357        source_inherited_text_alignment: inherited_text_alignment,
1358        source_table_text_alignment: None,
1359        heading_level,
1360        style_id,
1361        style_name,
1362        numbering,
1363        source_before_auto_spacing: spacing
1364            .as_ref()
1365            .and_then(|spacing| spacing.before_auto_spacing),
1366        source_after_auto_spacing: spacing
1367            .as_ref()
1368            .and_then(|spacing| spacing.after_auto_spacing),
1369        spacing,
1370        indent,
1371        background_color,
1372        borders,
1373        tab_stops,
1374        drop_cap: direct_drop_cap,
1375        contextual_spacing,
1376        keep_next,
1377        keep_lines,
1378        widow_control,
1379        page_break_before,
1380    })
1381}
1382
1383fn regex_capture(xml: &str, pattern: &str) -> Option<String> {
1384    if xml.is_empty() {
1385        return None;
1386    }
1387    re::get(pattern)?
1388        .captures(xml)
1389        .and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
1390}
1391
1392#[cfg(test)]
1393mod shared_cache_tests {
1394    #[test]
1395    fn editing_one_cached_style_does_not_change_other_nodes() {
1396        let scope = super::run_style_cache_scope();
1397        let xml = "<w:rPr><w:b/></w:rPr>";
1398        let mut first = super::parse_shared_text_style_from_xml(xml, &Default::default()).unwrap();
1399        let second = super::parse_shared_text_style_from_xml(xml, &Default::default()).unwrap();
1400        drop(scope);
1401        first.bold = Some(false);
1402        assert_eq!(first.bold, Some(false));
1403        assert_eq!(second.bold, Some(true));
1404    }
1405}
1406
1407#[cfg(all(test, not(target_arch = "wasm32")))]
1408mod retained_style_tests {
1409    #[test]
1410    fn raw_style_reuse_preserves_theme_and_script_dependencies() {
1411        let xml = "<w:rPr><w:rFonts w:asciiTheme='minorHAnsi' w:eastAsia='East'/><w:b/><w:sz w:val='24'/></w:rPr>";
1412        for font in ["Serif", "Sans", "Serif"] {
1413            let theme = super::ThemeFontMap {
1414                minor_latin: Some(font.into()),
1415                ..Default::default()
1416            };
1417            for scripts in [0, 1, 2, 128] {
1418                let expected = if scripts == 128 {
1419                    crate::parse::styles::parse_definition_style_uncached(xml, &theme)
1420                } else {
1421                    super::parse_text_style_with_scripts(xml, &theme, scripts)
1422                };
1423                let _scope = super::run_style_cache_scope();
1424                assert_eq!(super::cached_text_style(xml, &theme, scripts), expected);
1425            }
1426        }
1427    }
1428
1429    #[test]
1430    fn oversized_raw_style_sets_are_not_retained() {
1431        {
1432            let _scope = super::run_style_cache_scope();
1433            let theme = super::ThemeFontMap::default();
1434            for index in 1..=520 {
1435                let xml = format!("<w:rPr><w:sz w:val='{index}'/></w:rPr>");
1436                let actual = super::cached_text_style(&xml, &theme, 0).unwrap();
1437                assert_eq!(actual.font_size_pt, Some(index as f64 / 2.0));
1438            }
1439        }
1440        super::RETAINED_RAW_STYLES.with(|cache| assert!(cache.borrow().is_none()));
1441    }
1442}
1443
1444#[cfg(all(test, not(target_arch = "wasm32")))]
1445mod retained_resolution_tests {
1446    use super::*;
1447    use crate::package::{OoxmlPackage, OoxmlPart};
1448    use std::{cell::RefCell, collections::HashMap, sync::Arc};
1449
1450    #[test]
1451    fn retained_resolution_is_bound_to_an_immutable_sheet() {
1452        let sheets: Vec<_> = ["Alpha", "Beta"].into_iter().enumerate().map(|(index, font)| {
1453            let name = "word/styles.xml".to_owned();
1454            let content = format!(r#"<w:styles><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="{font}"/><w:b w:val="{index}"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Main"><w:name w:val="{font}"/><w:pPr><w:spacing w:before="{}"/></w:pPr></w:style></w:styles>"#, 120 * (index + 1));
1455            let package = OoxmlPackage { parts: HashMap::from([(name.clone(), OoxmlPart { name, content })]), binary_assets: HashMap::new(), warnings: Vec::new() };
1456            Arc::new(super::super::styles::parse_document_style_sheet(&package))
1457        }).collect();
1458        let parts = HashMap::new();
1459        let assets = HashMap::new();
1460        let run = r#"<w:r><w:rPr><w:i/></w:rPr><w:t>text</w:t></w:r>"#;
1461        let paragraph = r#"<w:p><w:pPr><w:pStyle w:val="Main"/></w:pPr></w:p>"#;
1462        let mut expected_styles = Vec::new();
1463        for index in [0, 0, 1, 1, 0, 1, 0] {
1464            let sheet = &sheets[index];
1465            let context = ParseContext { relationships: HashMap::new(), content_types: Default::default(), parts: &parts, binary_assets: &assets, style_sheet: sheet.as_ref(), warnings: RefCell::new(Vec::new()) };
1466            let expected_run = parse_run_style(run, &context, Some("Main"));
1467            let expected_paragraph = parse_paragraph_style(paragraph, &context);
1468            expected_styles.push((expected_run.clone(), expected_paragraph.clone()));
1469            {
1470                let _scope = run_style_cache_scope();
1471                bind_shared_style_cache(Arc::clone(sheet));
1472                let mut actual_run = parse_run_style(run, &context, Some("Main"));
1473                let mut actual_paragraph = parse_paragraph_style(paragraph, &context);
1474                assert_eq!(actual_run, expected_run);
1475                assert_eq!(actual_paragraph, expected_paragraph);
1476                actual_run.as_mut().unwrap().font_family = Some("changed".to_owned());
1477                actual_paragraph.as_mut().unwrap().style_name = Some("changed".to_owned());
1478            }
1479            {
1480                let _scope = run_style_cache_scope();
1481                bind_shared_style_cache(Arc::clone(sheet));
1482                assert_eq!(parse_run_style(run, &context, Some("Main")), expected_run);
1483                assert_eq!(parse_paragraph_style(paragraph, &context), expected_paragraph);
1484                {
1485                    let _nested = run_style_cache_scope();
1486                    bind_shared_style_cache(Arc::clone(&sheets[1 - index]));
1487                }
1488                assert_eq!(parse_run_style(run, &context, Some("Main")), expected_run);
1489            }
1490        }
1491        assert_ne!(expected_styles[0], expected_styles[2]);
1492    }
1493}
1494
1495#[cfg(all(test, not(target_arch = "wasm32")))]
1496mod retained_numeric_style_tests {
1497    use super::*;
1498    use std::{cell::RefCell, collections::HashMap, sync::Arc};
1499
1500    #[test]
1501    fn retained_table_formats_preserve_float_bits() {
1502        let sheet = Arc::new(super::super::context::ParsedStyleSheet::empty());
1503        let parts = HashMap::new();
1504        let assets = HashMap::new();
1505        let context = ParseContext { relationships: HashMap::new(), content_types: Default::default(), parts: &parts, binary_assets: &assets, style_sheet: sheet.as_ref(), warnings: RefCell::new(Vec::new()) };
1506        for value in [0.0_f64, -0.0_f64, 0.0_f64, -0.0_f64] {
1507            let mut table = parse_text_style_from_xml("<w:b/>", &Default::default()).unwrap();
1508            table.font_size_pt = Some(value);
1509            let _scope = run_style_cache_scope();
1510            bind_shared_style_cache(Arc::clone(&sheet));
1511            let actual = parse_run_style_in_table("<w:r><w:t>text</w:t></w:r>", &context, None, Some(&table)).unwrap();
1512            assert_eq!(actual.font_size_pt.unwrap().to_bits(), value.to_bits());
1513        }
1514    }
1515}
1516
1517#[cfg(test)]
1518mod drawing_style_preflight_tests {
1519    use super::*;
1520
1521    #[test]
1522    fn preflight_keeps_text_styles_and_ignores_geometry_only_content() {
1523        for xml in [
1524            r#"<a:rPr b="1"/>"#,
1525            r#"<A:RPR sz="1200"/>"#,
1526            r#"<a:defRPr><a:latin typeface="Primary"/></a:defRPr>"#,
1527            r#"<a:latin typeface="Primary"/>"#,
1528            r#"<![CDATA[<a:rPr b="1"/>]]>"#,
1529            r#"<a:rPr-extra b="1"/>"#,
1530        ] {
1531            assert!(has_drawing_text_style_tags(xml));
1532            assert!(parse_text_style_from_xml(xml, &Default::default()).is_some());
1533        }
1534        for xml in [
1535            "", "<a:", "<a:pt x=\"1\" y=\"2\"/>",
1536            "<a:rPrExtra/>", "<a:ln><a:noFill/></a:ln>",
1537            "<a:solidFill><a:srgbClr val=\"ffffff\"/></a:solidFill>",
1538        ] {
1539            assert!(parse_text_style_from_xml(xml, &Default::default()).is_none());
1540        }
1541        let xml = r#"<w:r><w:rPr><w:b/></w:rPr><a:pt x="1" y="2"/></w:r>"#;
1542        assert!(!has_drawing_text_style_tags(xml));
1543        assert_eq!(parse_text_style_from_xml(xml, &Default::default()).unwrap().bold, Some(true));
1544    }
1545}
1546
1547#[cfg(all(test, not(target_arch = "wasm32")))]
1548mod bound_mark_default_tests {
1549    use super::*;
1550    use std::sync::Arc;
1551
1552    #[test]
1553    fn bound_defaults_preserve_values_and_validate_unbound_inputs() {
1554        let mut defaults = parse_text_style_from_xml("<w:b/>", &Default::default()).unwrap();
1555        defaults.font_size_pt = Some(-0.0);
1556        let mut sheet = super::super::context::ParsedStyleSheet::empty();
1557        sheet.default_run_style = Some(defaults.clone());
1558        let sheet = Arc::new(sheet);
1559        let _scope = run_style_cache_scope();
1560        bind_shared_style_cache(Arc::clone(&sheet));
1561        for _ in 0..2 {
1562            let actual = cached_mark_default_style(sheet.default_run_style.as_ref(), None).unwrap();
1563            assert_eq!(*actual, defaults);
1564            assert_eq!(actual.font_size_pt.unwrap().to_bits(), (-0.0_f64).to_bits());
1565        }
1566        for value in [0.0_f64, -0.0_f64] {
1567            defaults.font_size_pt = Some(value);
1568            let actual = cached_mark_default_style(Some(&defaults), None).unwrap();
1569            assert_eq!(actual.font_size_pt.unwrap().to_bits(), value.to_bits());
1570        }
1571        defaults.bold = Some(false);
1572        assert_eq!(cached_mark_default_style(Some(&defaults), None).unwrap().bold, Some(false));
1573        assert_eq!(cached_mark_default_style(sheet.default_run_style.as_ref(), None).unwrap().bold, Some(true));
1574        let table = parse_text_style_from_xml("<w:i/>", &Default::default()).unwrap();
1575        assert_eq!(cached_mark_default_style(sheet.default_run_style.as_ref(), Some(&table)).unwrap().italic, Some(true));
1576        assert_eq!(cached_mark_default_style(sheet.default_run_style.as_ref(), None).unwrap().italic, None);
1577        bind_shared_style_cache(Arc::new(super::super::context::ParsedStyleSheet::empty()));
1578        assert!(cached_mark_default_style(None, None).is_none());
1579    }
1580}
1581
1582#[cfg(test)]
1583mod shared_paragraph_cache_tests {
1584    use super::*;
1585    use std::{cell::RefCell, collections::HashMap};
1586
1587    #[test]
1588    fn shared_paragraph_styles_preserve_cached_values_and_serialization() {
1589        let parts = HashMap::new();
1590        let assets = HashMap::new();
1591        let sheet = super::super::context::ParsedStyleSheet::empty();
1592        let context = ParseContext { relationships: HashMap::new(), content_types: Default::default(), parts: &parts, binary_assets: &assets, style_sheet: &sheet, warnings: RefCell::new(Vec::new()) };
1593        let _scope = run_style_cache_scope();
1594        let properties = "<w:pPr><w:jc w:val='center'/></w:pPr>";
1595        let mut first = parse_paragraph_style_from_properties(properties, &context, None).unwrap();
1596        let second = parse_paragraph_style_from_properties(properties, &context, None).unwrap();
1597        assert_eq!(serde_json::to_value(&second).unwrap(), serde_json::to_value(&*second).unwrap());
1598        first.style_name = Some("changed".into());
1599        assert!(second.style_name.is_none());
1600        assert_eq!(parse_paragraph_style_from_properties(properties, &context, None).unwrap(), second);
1601        let mut node: crate::model::ParagraphNode = serde_json::from_str(r#"{"type":"paragraph","children":[],"style":{"styleName":"base"}}"#).unwrap();
1602        let unchanged = node.clone();
1603        node.style.as_mut().unwrap().style_name = Some("changed".into());
1604        assert_eq!(unchanged.style.as_ref().unwrap().style_name.as_deref(), Some("base"));
1605    }
1606}