Skip to main content

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