Skip to main content

extend_docx/parse/
styles.rs

1use std::collections::{HashMap, HashSet};
2
3use super::colors::normalize_hex_color;
4use super::context::{
5    empty_style_sheet, ParsedStyleSheet, ParsedTableLook, ParsedTableProperties,
6    ParsedTableStyleCondition, ParsedTableStyleDefinition, TableConditionalStyleType,
7    ThemeColorMap, ThemeFontMap,
8};
9use crate::model::NumberingDefinitionSet;
10use crate::model::{
11    HeadingLevel, ImageHorizontalAlign, ImageVerticalAlign, ParagraphAlignment, ParagraphBorderSet,
12    ParagraphBorderStyle, ParagraphIndent, ParagraphLineRule, ParagraphNumbering, ParagraphSpacing,
13    ParagraphStyle, ParagraphStyleDefinition, ParagraphTabStop, ParagraphTabStopAlignment,
14    ParagraphTabStopLeader, ParagraphTextAlignment, TableAlignment, TableBorderSet,
15    TableBorderStyle, TableBoxSpacing, TableFloating, TableLayout, TablePreferredWidth,
16    TextRunBorderStyle, TextStyle, VerticalAlign,
17};
18use crate::package::OoxmlPackage;
19use crate::xml::{
20    decode_xml_entities, get_attribute, normalize_alignment, parse_integer_attribute,
21    parse_on_off_attribute, parse_underline_attribute,
22};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25enum RawStyleType {
26    Paragraph,
27    Character,
28    Numbering,
29    Table,
30}
31
32struct RawStyleDefinition {
33    id: String,
34    style_type: RawStyleType,
35    name: String,
36    based_on_id: Option<String>,
37    next_style_id: Option<String>,
38    align: Option<ParagraphAlignment>,
39    snap_to_grid: Option<bool>,
40    text_alignment: Option<ParagraphTextAlignment>,
41    heading_level: Option<HeadingLevel>,
42    outline_heading: Option<Option<HeadingLevel>>,
43    numbering: Option<ParagraphNumbering>,
44    spacing: Option<ParagraphSpacing>,
45    indent: Option<ParagraphIndent>,
46    background_color: Option<String>,
47    borders: Option<ParagraphBorderSet>,
48    tab_stops: Option<Vec<ParagraphTabStop>>,
49    contextual_spacing: Option<bool>,
50    keep_next: Option<bool>,
51    keep_lines: Option<bool>,
52    widow_control: Option<bool>,
53    page_break_before: Option<bool>,
54    run_style: Option<TextStyle>,
55    ui_priority: Option<i64>,
56    is_default: Option<bool>,
57    is_primary: Option<bool>,
58}
59
60const DEFAULT_TABLE_LOOK: ParsedTableLook = ParsedTableLook {
61    first_row: false,
62    last_row: false,
63    first_col: false,
64    last_col: false,
65    no_h_band: true,
66    no_v_band: true,
67    row_band_size: 1,
68    col_band_size: 1,
69};
70
71fn map_alignment(value: Option<&str>) -> Option<ParagraphAlignment> {
72    match normalize_alignment(value)? {
73        "left" => Some(ParagraphAlignment::Left),
74        "center" => Some(ParagraphAlignment::Center),
75        "right" => Some(ParagraphAlignment::Right),
76        "justify" => Some(ParagraphAlignment::Justify),
77        _ => None,
78    }
79}
80
81/// Mirrors TypeScript `normalizeHeadingLevel`.
82pub fn normalize_heading_level(value: Option<&str>) -> Option<HeadingLevel> {
83    let value = value?;
84    let lower = value.to_ascii_lowercase();
85    let bytes = lower.as_bytes();
86    let needle = b"heading";
87    let mut index = 0;
88
89    while index + needle.len() <= bytes.len() {
90        if bytes[index..index + needle.len()]
91            .iter()
92            .zip(needle.iter())
93            .all(|(left, right)| left.eq_ignore_ascii_case(right))
94        {
95            let mut cursor = index + needle.len();
96            while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
97                cursor += 1;
98            }
99            if let Some(digit) = bytes
100                .get(cursor)
101                .and_then(|byte| (*byte as char).to_digit(10))
102            {
103                if (1..=9).contains(&digit) {
104                    return match digit {
105                        1 => Some(HeadingLevel::One),
106                        2 => Some(HeadingLevel::Two),
107                        3 => Some(HeadingLevel::Three),
108                        4 => Some(HeadingLevel::Four),
109                        5 => Some(HeadingLevel::Five),
110                        6 => Some(HeadingLevel::Six),
111                        7 => Some(HeadingLevel::Seven),
112                        8 => Some(HeadingLevel::Eight),
113                        9 => Some(HeadingLevel::Nine),
114                        _ => None,
115                    };
116                }
117            }
118        }
119        index += 1;
120    }
121
122    None
123}
124
125/// Mirrors TypeScript `parseParagraphNumberingFromXml`.
126pub fn parse_paragraph_numbering_from_xml(xml: &str) -> Option<ParagraphNumbering> {
127    if xml.is_empty() {
128        return None;
129    }
130
131    let numbering_xml = crate::xml::extract_balanced_tag_slices(xml, "w:numPr")
132        .into_iter()
133        .next()
134        .or_else(|| super::scan::find_tag_token_ref(xml, "w:numPr"))
135        .unwrap_or_default();
136    if numbering_xml.is_empty() {
137        return None;
138    }
139
140    let num_id_raw = find_namespaced_integer_attribute(&numbering_xml, "w:numId", "w:val");
141    let num_id = num_id_raw?;
142    if num_id <= 0 {
143        return None;
144    }
145
146    let ilvl_raw = find_namespaced_integer_attribute(&numbering_xml, "w:ilvl", "w:val");
147    let ilvl_value = ilvl_raw.unwrap_or(0);
148
149    Some(ParagraphNumbering {
150        num_id,
151        ilvl: if ilvl_value >= 0 { ilvl_value } else { 0 },
152    })
153}
154
155fn find_namespaced_integer_attribute(xml: &str, tag_name: &str, attribute: &str) -> Option<i64> {
156    let tag = super::scan::find_tag_token_ref(xml, tag_name)?;
157    parse_integer_attribute(&tag, attribute)
158}
159
160/// Mirrors TypeScript `parseParagraphSpacingFromXml`.
161pub fn parse_paragraph_spacing_from_xml(xml: &str) -> Option<ParagraphSpacing> {
162    if xml.is_empty() {
163        return None;
164    }
165
166    let spacing_tag = super::scan::find_tag_token_ref(xml, "w:spacing")?;
167    let line_rule_raw =
168        get_attribute(&spacing_tag, "w:lineRule").map(|value| value.to_ascii_lowercase());
169    let line_rule = match line_rule_raw.as_deref() {
170        Some("auto") => Some(ParagraphLineRule::Auto),
171        Some("exact") => Some(ParagraphLineRule::Exact),
172        Some("atleast") => Some(ParagraphLineRule::AtLeast),
173        _ => None,
174    };
175
176    let spacing = ParagraphSpacing {
177        before_twips: parse_integer_attribute(&spacing_tag, "w:before"),
178        after_twips: parse_integer_attribute(&spacing_tag, "w:after"),
179        before_auto_spacing: parse_on_off_value(
180            get_attribute(&spacing_tag, "w:beforeAutospacing").as_deref(),
181        ),
182        after_auto_spacing: parse_on_off_value(
183            get_attribute(&spacing_tag, "w:afterAutospacing").as_deref(),
184        ),
185        line_twips: parse_integer_attribute(&spacing_tag, "w:line"),
186        line_rule,
187    };
188
189    if spacing.before_twips.is_none()
190        && spacing.after_twips.is_none()
191        && spacing.before_auto_spacing.is_none()
192        && spacing.after_auto_spacing.is_none()
193        && spacing.line_twips.is_none()
194        && spacing.line_rule.is_none()
195    {
196        return None;
197    }
198
199    Some(spacing)
200}
201
202/// Mirrors TypeScript `parseParagraphIndentFromXml`.
203pub fn parse_paragraph_indent_from_xml(xml: &str) -> Option<ParagraphIndent> {
204    if xml.is_empty() {
205        return None;
206    }
207
208    let indent_tag = super::scan::find_tag_token_ref(xml, "w:ind")?;
209    let indent = ParagraphIndent {
210        left_twips: parse_integer_attribute(&indent_tag, "w:left"),
211        right_twips: parse_integer_attribute(&indent_tag, "w:right"),
212        first_line_twips: parse_integer_attribute(&indent_tag, "w:firstLine"),
213        hanging_twips: parse_integer_attribute(&indent_tag, "w:hanging"),
214    };
215
216    if indent.left_twips.is_none()
217        && indent.right_twips.is_none()
218        && indent.first_line_twips.is_none()
219        && indent.hanging_twips.is_none()
220    {
221        return None;
222    }
223
224    Some(indent)
225}
226
227fn normalize_tab_stop_alignment(value: Option<&str>) -> Option<ParagraphTabStopAlignment> {
228    match value?.to_ascii_lowercase().as_str() {
229        "clear" => Some(ParagraphTabStopAlignment::Clear),
230        "left" => Some(ParagraphTabStopAlignment::Left),
231        "center" => Some(ParagraphTabStopAlignment::Center),
232        "right" => Some(ParagraphTabStopAlignment::Right),
233        "decimal" => Some(ParagraphTabStopAlignment::Decimal),
234        "bar" => Some(ParagraphTabStopAlignment::Bar),
235        _ => None,
236    }
237}
238
239fn normalize_tab_stop_leader(value: Option<&str>) -> Option<ParagraphTabStopLeader> {
240    match value?.to_ascii_lowercase().as_str() {
241        "none" => Some(ParagraphTabStopLeader::LeaderNone),
242        "dot" => Some(ParagraphTabStopLeader::Dot),
243        "hyphen" => Some(ParagraphTabStopLeader::Hyphen),
244        "underscore" => Some(ParagraphTabStopLeader::Underscore),
245        "middledot" => Some(ParagraphTabStopLeader::MiddleDot),
246        _ => None,
247    }
248}
249
250/// Mirrors TypeScript `parseParagraphTabStopsFromXml`.
251pub fn parse_paragraph_tab_stops_from_xml(xml: &str) -> Vec<ParagraphTabStop> {
252    if xml.is_empty() {
253        return Vec::new();
254    }
255
256    let tabs_xml = crate::xml::extract_balanced_tag_slices(xml, "w:tabs")
257        .into_iter()
258        .next()
259        .or_else(|| super::scan::find_tag_token_ref(xml, "w:tabs"))
260        .unwrap_or_default();
261    if tabs_xml.is_empty() {
262        return Vec::new();
263    }
264
265    let mut tab_stops = Vec::new();
266    for tab_tag in super::scan::find_all_tag_tokens(&tabs_xml, "w:tab") {
267        let alignment = normalize_tab_stop_alignment(get_attribute(&tab_tag, "w:val").as_deref());
268        let leader = normalize_tab_stop_leader(get_attribute(&tab_tag, "w:leader").as_deref());
269        let Some(position_twips) = parse_integer_attribute(&tab_tag, "w:pos") else {
270            continue;
271        };
272        tab_stops.push(ParagraphTabStop {
273            alignment: alignment.or(Some(ParagraphTabStopAlignment::Left)),
274            leader: leader.or(Some(ParagraphTabStopLeader::LeaderNone)),
275            position_twips: Some(position_twips),
276        });
277    }
278
279    tab_stops.sort_by_key(|stop| stop.position_twips.unwrap_or(0));
280    tab_stops
281}
282
283/// Mirrors TypeScript `parseParagraphShadingFromXml`.
284pub fn parse_paragraph_shading_from_xml(xml: &str) -> Option<String> {
285    if xml.is_empty() {
286        return None;
287    }
288    let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd")?;
289    normalize_hex_color(get_attribute(&shading_tag, "w:fill").as_deref())
290}
291
292pub(super) fn parse_on_off_value(value: Option<&str>) -> Option<bool> {
293    let value = value?;
294    let normalized = value.trim().to_ascii_lowercase();
295    Some(normalized != "0" && normalized != "false" && normalized != "none" && normalized != "off")
296}
297
298/// Mirrors TypeScript `parseParagraphBorderStyle`.
299pub fn parse_paragraph_border_style(tag_xml: Option<&str>) -> Option<ParagraphBorderStyle> {
300    let tag_xml = tag_xml?;
301    let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
302    if border_type.is_empty() {
303        return None;
304    }
305
306    let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz");
307    let space_pt = parse_integer_attribute(tag_xml, "w:space");
308    let raw_color = get_attribute(tag_xml, "w:color");
309    let color = if raw_color
310        .as_deref()
311        .is_some_and(|value| value.trim().eq_ignore_ascii_case("auto"))
312    {
313        Some("#000000".to_string())
314    } else {
315        normalize_hex_color(raw_color.as_deref())
316    };
317
318    Some(ParagraphBorderStyle {
319        border_type,
320        size_eighth_pt: size_eighth_pt
321            .filter(|value| *value >= 0)
322            .map(|value| value as f64),
323        space_pt: space_pt
324            .filter(|value| *value >= 0)
325            .map(|value| value as f64),
326        color,
327        frame: parse_on_off_value(get_attribute(tag_xml, "w:frame").as_deref()),
328        shadow: parse_on_off_value(get_attribute(tag_xml, "w:shadow").as_deref()),
329    })
330}
331
332/// Mirrors TypeScript `parseTextRunBorderStyle`.
333pub fn parse_text_run_border_style(tag_xml: Option<&str>) -> Option<TextRunBorderStyle> {
334    let tag_xml = tag_xml?;
335    let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
336    if border_type.is_empty() {
337        return None;
338    }
339
340    let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz");
341    let space_pt = parse_integer_attribute(tag_xml, "w:space");
342    let raw_color = get_attribute(tag_xml, "w:color");
343    let color = if raw_color
344        .as_deref()
345        .is_some_and(|value| value.trim().eq_ignore_ascii_case("auto"))
346    {
347        None
348    } else {
349        normalize_hex_color(raw_color.as_deref())
350    };
351
352    Some(TextRunBorderStyle {
353        border_type,
354        size_eighth_pt: size_eighth_pt
355            .filter(|value| *value >= 0)
356            .map(|value| value as f64),
357        space_pt: space_pt
358            .filter(|value| *value >= 0)
359            .map(|value| value as f64),
360        color,
361        frame: parse_on_off_value(get_attribute(tag_xml, "w:frame").as_deref()),
362        shadow: parse_on_off_value(get_attribute(tag_xml, "w:shadow").as_deref()),
363    })
364}
365
366/// Mirrors TypeScript `parseParagraphBorderSetFromXml`.
367pub fn parse_paragraph_border_set_from_xml(xml: &str) -> Option<ParagraphBorderSet> {
368    if xml.is_empty() {
369        return None;
370    }
371
372    let paragraph_border_xml = crate::xml::extract_balanced_tag_slices(xml, "w:pBdr")
373        .into_iter()
374        .next()
375        .or_else(|| super::scan::find_tag_token_ref(xml, "w:pBdr"))
376        .unwrap_or_default();
377    if paragraph_border_xml.is_empty() {
378        return None;
379    }
380
381    let top = parse_paragraph_border_style(
382        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:top").as_deref(),
383    );
384    let right = parse_paragraph_border_style(
385        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:right").as_deref(),
386    );
387    let bottom = parse_paragraph_border_style(
388        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:bottom").as_deref(),
389    );
390    let left = parse_paragraph_border_style(
391        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:left").as_deref(),
392    );
393    let between = parse_paragraph_border_style(
394        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:between").as_deref(),
395    );
396    let bar = parse_paragraph_border_style(
397        super::scan::find_tag_token_ref(&paragraph_border_xml, "w:bar").as_deref(),
398    );
399
400    if top.is_none()
401        && right.is_none()
402        && bottom.is_none()
403        && left.is_none()
404        && between.is_none()
405        && bar.is_none()
406    {
407        return None;
408    }
409
410    Some(ParagraphBorderSet {
411        top,
412        right,
413        bottom,
414        left,
415        between,
416        bar,
417    })
418}
419
420/// Mirrors TypeScript `parseTableBoxSpacing`.
421pub fn parse_table_box_spacing(xml: &str) -> Option<TableBoxSpacing> {
422    let top_match = super::scan::find_tag_token_ref(xml, "w:top");
423    // Word writes the bidi-aware w:start/w:end instead of w:left/w:right in
424    // newer documents; treat them as left/right (LTR rendering).
425    let right_match = super::scan::find_tag_token_ref(xml, "w:right")
426        .or_else(|| super::scan::find_tag_token_ref(xml, "w:end"));
427    let bottom_match = super::scan::find_tag_token_ref(xml, "w:bottom");
428    let left_match = super::scan::find_tag_token_ref(xml, "w:left")
429        .or_else(|| super::scan::find_tag_token_ref(xml, "w:start"));
430
431    let spacing = TableBoxSpacing {
432        top_twips: top_match
433            .as_deref()
434            .and_then(|tag| parse_integer_attribute(tag, "w:w")),
435        right_twips: right_match
436            .as_deref()
437            .and_then(|tag| parse_integer_attribute(tag, "w:w")),
438        bottom_twips: bottom_match
439            .as_deref()
440            .and_then(|tag| parse_integer_attribute(tag, "w:w")),
441        left_twips: left_match
442            .as_deref()
443            .and_then(|tag| parse_integer_attribute(tag, "w:w")),
444    };
445
446    if spacing.top_twips.is_none()
447        && spacing.right_twips.is_none()
448        && spacing.bottom_twips.is_none()
449        && spacing.left_twips.is_none()
450    {
451        return None;
452    }
453
454    Some(spacing)
455}
456
457/// Mirrors TypeScript `parseTableBorderStyle`.
458pub fn parse_table_border_style(tag_xml: Option<&str>) -> Option<TableBorderStyle> {
459    let tag_xml = tag_xml?;
460    let border_type = get_attribute(tag_xml, "w:val")?.trim().to_ascii_lowercase();
461    if border_type.is_empty() {
462        return None;
463    }
464
465    let size_eighth_pt = parse_integer_attribute(tag_xml, "w:sz");
466    let raw_color = get_attribute(tag_xml, "w:color");
467    let color = if raw_color
468        .as_deref()
469        .is_some_and(|value| value.trim().eq_ignore_ascii_case("auto"))
470    {
471        Some("#000000".to_string())
472    } else {
473        normalize_hex_color(raw_color.as_deref())
474    };
475
476    Some(TableBorderStyle {
477        border_type,
478        size_eighth_pt: size_eighth_pt
479            .filter(|value| *value >= 0)
480            .map(|value| value as f64),
481        color,
482    })
483}
484
485/// Mirrors TypeScript `parseTableBorderSet`.
486pub fn parse_table_border_set(xml: &str) -> Option<TableBorderSet> {
487    let top = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:top").as_deref());
488    let right =
489        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:right").as_deref());
490    let bottom =
491        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:bottom").as_deref());
492    let left = parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:left").as_deref());
493    let inside_h =
494        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideH").as_deref());
495    let inside_v =
496        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:insideV").as_deref());
497    let tl2br =
498        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tl2br").as_deref());
499    let tr2bl =
500        parse_table_border_style(super::scan::find_tag_token_ref(xml, "w:tr2bl").as_deref());
501
502    if top.is_none()
503        && right.is_none()
504        && bottom.is_none()
505        && left.is_none()
506        && inside_h.is_none()
507        && inside_v.is_none()
508        && tl2br.is_none()
509        && tr2bl.is_none()
510    {
511        return None;
512    }
513
514    Some(TableBorderSet {
515        top,
516        right,
517        bottom,
518        left,
519        inside_h,
520        inside_v,
521        tl2br,
522        tr2bl,
523    })
524}
525
526fn has_table_properties(properties: &ParsedTableProperties) -> bool {
527    properties.width_twips.is_some()
528        || properties.preferred_width.is_some()
529        || properties.alignment.is_some()
530        || properties.bidi_visual.is_some()
531        || properties.indent_twips.is_some()
532        || properties.layout.is_some()
533        || properties.cell_spacing_twips.is_some()
534        || properties.floating.is_some()
535        || properties.cell_margin_twips.is_some()
536}
537
538/// Mirrors TypeScript `parseFloatingTableStyle`.
539pub fn parse_floating_table_style(table_properties_xml: Option<&str>) -> Option<TableFloating> {
540    let table_properties_xml = table_properties_xml?;
541    let floating_tag = super::scan::find_tag_token_ref(table_properties_xml, "w:tblpPr")?;
542
543    let x_twips = parse_integer_attribute(&floating_tag, "w:tblpX");
544    let y_twips = parse_integer_attribute(&floating_tag, "w:tblpY");
545    let left_from_text_twips = parse_integer_attribute(&floating_tag, "w:leftFromText");
546    let right_from_text_twips = parse_integer_attribute(&floating_tag, "w:rightFromText");
547    let top_from_text_twips = parse_integer_attribute(&floating_tag, "w:topFromText");
548    let bottom_from_text_twips = parse_integer_attribute(&floating_tag, "w:bottomFromText");
549    let horizontal_anchor = get_attribute(&floating_tag, "w:horzAnchor");
550    let vertical_anchor = get_attribute(&floating_tag, "w:vertAnchor");
551    let horizontal_align_raw =
552        get_attribute(&floating_tag, "w:tblpXSpec").map(|value| value.trim().to_ascii_lowercase());
553    let vertical_align_raw =
554        get_attribute(&floating_tag, "w:tblpYSpec").map(|value| value.trim().to_ascii_lowercase());
555
556    let horizontal_align = match horizontal_align_raw.as_deref() {
557        Some("left") => Some(ImageHorizontalAlign::Left),
558        Some("center") => Some(ImageHorizontalAlign::Center),
559        Some("right") => Some(ImageHorizontalAlign::Right),
560        Some("inside") => Some(ImageHorizontalAlign::Inside),
561        Some("outside") => Some(ImageHorizontalAlign::Outside),
562        _ => None,
563    };
564    let vertical_align = match vertical_align_raw.as_deref() {
565        Some("top") => Some(ImageVerticalAlign::Top),
566        Some("center") => Some(ImageVerticalAlign::Center),
567        Some("bottom") => Some(ImageVerticalAlign::Bottom),
568        Some("inside") => Some(ImageVerticalAlign::Inside),
569        Some("outside") => Some(ImageVerticalAlign::Outside),
570        _ => None,
571    };
572
573    if x_twips.is_none()
574        && y_twips.is_none()
575        && left_from_text_twips.is_none()
576        && right_from_text_twips.is_none()
577        && top_from_text_twips.is_none()
578        && bottom_from_text_twips.is_none()
579        && horizontal_anchor.is_none()
580        && vertical_anchor.is_none()
581        && horizontal_align.is_none()
582        && vertical_align.is_none()
583    {
584        return None;
585    }
586
587    Some(TableFloating {
588        x_twips,
589        y_twips,
590        left_from_text_twips,
591        right_from_text_twips,
592        top_from_text_twips,
593        bottom_from_text_twips,
594        horizontal_anchor,
595        vertical_anchor,
596        horizontal_align,
597        vertical_align,
598    })
599}
600
601pub(crate) fn direct_table_property<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
602    crate::xml::direct_child_tag(xml, name)
603}
604
605pub(crate) fn parse_table_preferred_width(tag: &str) -> Option<TablePreferredWidth> {
606    let raw = crate::xml::get_attribute_ref(tag, "w:w").unwrap_or("0");
607    let raw = raw.trim();
608    if let Some(percent) = raw.strip_suffix('%') {
609        let value = percent.trim().parse::<f64>().ok()?;
610        return (value.is_finite() && value >= 0.0).then_some(TablePreferredWidth::Pct { value });
611    }
612    match crate::xml::get_attribute_ref(tag, "w:type").unwrap_or("dxa") {
613        "auto" => Some(TablePreferredWidth::Auto),
614        "nil" => Some(TablePreferredWidth::Nil),
615        "pct" => {
616            let value = raw.parse::<i64>().ok()? as f64 / 50.0;
617            (value >= 0.0).then_some(TablePreferredWidth::Pct { value })
618        }
619        "dxa" => {
620            let value = if let Ok(value) = raw.parse::<i64>() {
621                value as f64
622            } else {
623                let units = [
624                    ("mm", 1440.0 / 25.4),
625                    ("cm", 1440.0 / 2.54),
626                    ("in", 1440.0),
627                    ("pt", 20.0),
628                    ("pc", 240.0),
629                    ("pi", 240.0),
630                ];
631                let (number, factor) = units.iter().find_map(|(suffix, factor)| {
632                    raw.strip_suffix(suffix).map(|number| (number, *factor))
633                })?;
634                number.parse::<f64>().ok()? * factor
635            };
636            (value.is_finite() && value >= 0.0).then_some(TablePreferredWidth::Dxa { value })
637        }
638        _ => None,
639    }
640}
641
642pub(crate) fn preferred_width_twips(width: &TablePreferredWidth) -> Option<i64> {
643    match width {
644        TablePreferredWidth::Dxa { value } if value.is_finite() && *value >= 0.0 => {
645            Some(value.round() as i64)
646        }
647        _ => None,
648    }
649}
650
651/// Mirrors TypeScript `parseTableStylePropertiesFromXml`.
652pub fn parse_table_style_properties_from_xml(
653    table_properties_xml: Option<&str>,
654) -> Option<ParsedTableProperties> {
655    let table_properties_xml = table_properties_xml?;
656
657    let preferred_width =
658        direct_table_property(table_properties_xml, "w:tblW").and_then(parse_table_preferred_width);
659    let width_twips = preferred_width.as_ref().and_then(preferred_width_twips);
660    let alignment = direct_table_property(table_properties_xml, "w:jc")
661        .and_then(|tag| get_attribute(tag, "w:val"))
662        .and_then(|value| match value.as_str() {
663            "left" => Some(TableAlignment::Left),
664            "center" => Some(TableAlignment::Center),
665            "right" => Some(TableAlignment::Right),
666            _ => None,
667        });
668    let bidi_visual = direct_table_property(table_properties_xml, "w:bidiVisual")
669        .and_then(|tag| parse_on_off_attribute(tag, "bidiVisual"));
670
671    let table_indent_tag = super::scan::find_tag_token_ref(table_properties_xml, "w:tblInd");
672    let table_indent_type = table_indent_tag
673        .as_deref()
674        .and_then(|tag| get_attribute(tag, "w:type"))
675        .map(|value| value.to_ascii_lowercase());
676    let table_indent_raw = table_indent_tag
677        .as_deref()
678        .and_then(|tag| parse_integer_attribute(tag, "w:w"));
679    let indent_twips = if table_indent_type.as_deref() == Some("dxa")
680        && table_indent_raw.is_some_and(|value| value != 0)
681    {
682        table_indent_raw
683    } else {
684        None
685    };
686
687    let table_layout_tag = super::scan::find_tag_token_ref(table_properties_xml, "w:tblLayout");
688    let table_layout_raw = table_layout_tag
689        .as_deref()
690        .and_then(|tag| get_attribute(tag, "w:type"))
691        .map(|value| value.to_ascii_lowercase());
692    let layout = match table_layout_raw.as_deref() {
693        Some("fixed") => Some(TableLayout::Fixed),
694        Some("autofit") => Some(TableLayout::Autofit),
695        _ => None,
696    };
697
698    let table_cell_spacing_tag =
699        super::scan::find_tag_token_ref(table_properties_xml, "w:tblCellSpacing");
700    let table_cell_spacing_type = table_cell_spacing_tag
701        .as_deref()
702        .and_then(|tag| get_attribute(tag, "w:type"))
703        .map(|value| value.to_ascii_lowercase());
704    let table_cell_spacing_raw = table_cell_spacing_tag
705        .as_deref()
706        .and_then(|tag| parse_integer_attribute(tag, "w:w"));
707    let cell_spacing_twips = if table_cell_spacing_type.as_deref() == Some("dxa")
708        && table_cell_spacing_raw.is_some_and(|value| value >= 0)
709    {
710        table_cell_spacing_raw
711    } else {
712        None
713    };
714
715    let table_cell_margin_xml =
716        crate::xml::extract_balanced_tag_slices(table_properties_xml, "w:tblCellMar")
717            .into_iter()
718            .next()
719            .or_else(|| super::scan::find_tag_token_ref(table_properties_xml, "w:tblCellMar"));
720    let cell_margin_twips = table_cell_margin_xml
721        .as_deref()
722        .and_then(parse_table_box_spacing);
723    let floating = parse_floating_table_style(Some(table_properties_xml));
724
725    let properties = ParsedTableProperties {
726        width_twips,
727        preferred_width,
728        alignment,
729        bidi_visual,
730        indent_twips,
731        layout,
732        cell_spacing_twips,
733        cell_margin_twips,
734        floating,
735    };
736
737    if has_table_properties(&properties) {
738        Some(properties)
739    } else {
740        None
741    }
742}
743
744pub(crate) fn merge_table_style_properties(
745    inherited: Option<&ParsedTableProperties>,
746    direct: Option<&ParsedTableProperties>,
747) -> Option<ParsedTableProperties> {
748    if inherited.is_none() && direct.is_none() {
749        return None;
750    }
751
752    let preferred_width = direct
753        .and_then(|value| value.preferred_width.clone())
754        .or_else(|| inherited.and_then(|value| value.preferred_width.clone()));
755    let merged = ParsedTableProperties {
756        width_twips: preferred_width.as_ref().and_then(preferred_width_twips),
757        preferred_width,
758        alignment: direct
759            .and_then(|value| value.alignment)
760            .or_else(|| inherited.and_then(|value| value.alignment)),
761        bidi_visual: direct
762            .and_then(|value| value.bidi_visual)
763            .or_else(|| inherited.and_then(|value| value.bidi_visual)),
764        indent_twips: direct
765            .and_then(|value| value.indent_twips)
766            .or_else(|| inherited.and_then(|value| value.indent_twips)),
767        layout: direct
768            .and_then(|value| value.layout)
769            .or_else(|| inherited.and_then(|value| value.layout)),
770        cell_spacing_twips: direct
771            .and_then(|value| value.cell_spacing_twips)
772            .or_else(|| inherited.and_then(|value| value.cell_spacing_twips)),
773        floating: direct
774            .and_then(|value| value.floating.clone())
775            .or_else(|| inherited.and_then(|value| value.floating.clone())),
776        cell_margin_twips: direct
777            .and_then(|value| value.cell_margin_twips.clone())
778            .or_else(|| inherited.and_then(|value| value.cell_margin_twips.clone())),
779    };
780
781    if has_table_properties(&merged) {
782        Some(merged)
783    } else {
784        None
785    }
786}
787
788fn merge_table_look(
789    direct: Option<&ParsedTableLook>,
790    inherited: Option<&ParsedTableLook>,
791) -> ParsedTableLook {
792    let mut merged = DEFAULT_TABLE_LOOK;
793    if let Some(inherited) = inherited {
794        merged = inherited.clone();
795    }
796    if let Some(direct) = direct {
797        merged = direct.clone();
798    }
799    merged
800}
801
802fn normalize_table_conditional_style_type(
803    value: Option<&str>,
804) -> Option<TableConditionalStyleType> {
805    match value?.trim().to_ascii_lowercase().as_str() {
806        "wholtable" | "wholetable" => Some(TableConditionalStyleType::WholeTable),
807        "firstrow" => Some(TableConditionalStyleType::FirstRow),
808        "lastrow" => Some(TableConditionalStyleType::LastRow),
809        "firstcol" => Some(TableConditionalStyleType::FirstCol),
810        "lastcol" => Some(TableConditionalStyleType::LastCol),
811        "band1horz" => Some(TableConditionalStyleType::Band1Horz),
812        "band2horz" => Some(TableConditionalStyleType::Band2Horz),
813        "band1vert" => Some(TableConditionalStyleType::Band1Vert),
814        "band2vert" => Some(TableConditionalStyleType::Band2Vert),
815        "nwcell" => Some(TableConditionalStyleType::NwCell),
816        "necell" => Some(TableConditionalStyleType::NeCell),
817        "swcell" => Some(TableConditionalStyleType::SwCell),
818        "secell" => Some(TableConditionalStyleType::SeCell),
819        _ => None,
820    }
821}
822
823fn merge_table_border_sets(
824    inherited: Option<&TableBorderSet>,
825    direct: Option<&TableBorderSet>,
826) -> Option<TableBorderSet> {
827    if inherited.is_none() && direct.is_none() {
828        return None;
829    }
830
831    let merged = TableBorderSet {
832        top: direct
833            .and_then(|value| value.top.clone())
834            .or_else(|| inherited.and_then(|value| value.top.clone())),
835        right: direct
836            .and_then(|value| value.right.clone())
837            .or_else(|| inherited.and_then(|value| value.right.clone())),
838        bottom: direct
839            .and_then(|value| value.bottom.clone())
840            .or_else(|| inherited.and_then(|value| value.bottom.clone())),
841        left: direct
842            .and_then(|value| value.left.clone())
843            .or_else(|| inherited.and_then(|value| value.left.clone())),
844        inside_h: direct
845            .and_then(|value| value.inside_h.clone())
846            .or_else(|| inherited.and_then(|value| value.inside_h.clone())),
847        inside_v: direct
848            .and_then(|value| value.inside_v.clone())
849            .or_else(|| inherited.and_then(|value| value.inside_v.clone())),
850        tl2br: direct
851            .and_then(|value| value.tl2br.clone())
852            .or_else(|| inherited.and_then(|value| value.tl2br.clone())),
853        tr2bl: direct
854            .and_then(|value| value.tr2bl.clone())
855            .or_else(|| inherited.and_then(|value| value.tr2bl.clone())),
856    };
857
858    if merged.top.is_none()
859        && merged.right.is_none()
860        && merged.bottom.is_none()
861        && merged.left.is_none()
862        && merged.inside_h.is_none()
863        && merged.inside_v.is_none()
864        && merged.tl2br.is_none()
865        && merged.tr2bl.is_none()
866    {
867        return None;
868    }
869
870    Some(merged)
871}
872
873/// Mirrors TypeScript `parseTableLook`.
874pub fn parse_table_look(table_properties_xml: Option<&str>) -> Option<ParsedTableLook> {
875    let table_properties_xml = table_properties_xml?;
876    let table_look_tag = super::scan::find_tag_token_ref(table_properties_xml, "w:tblLook")?;
877    let look_mask_raw = get_attribute(&table_look_tag, "w:val");
878    let look_mask = look_mask_raw
879        .as_deref()
880        .and_then(|raw| i64::from_str_radix(raw, 16).ok());
881
882    let row_band_size_tag = super::scan::find_tag_token_ref(
883        table_properties_xml,
884        "w:tblStyleRowBandSize",
885    )
886    .or_else(|| {
887        crate::xml::extract_balanced_tag_slices(table_properties_xml, "w:tblStyleRowBandSize")
888            .into_iter()
889            .next()
890    });
891    let col_band_size_tag = super::scan::find_tag_token_ref(
892        table_properties_xml,
893        "w:tblStyleColBandSize",
894    )
895    .or_else(|| {
896        crate::xml::extract_balanced_tag_slices(table_properties_xml, "w:tblStyleColBandSize")
897            .into_iter()
898            .next()
899    });
900    let row_band_size_raw = row_band_size_tag
901        .as_deref()
902        .and_then(|tag| parse_integer_attribute(tag, "w:val"))
903        .unwrap_or(1);
904    let col_band_size_raw = col_band_size_tag
905        .as_deref()
906        .and_then(|tag| parse_integer_attribute(tag, "w:val"))
907        .unwrap_or(1);
908
909    let resolve_on_off_attribute = |attribute: &str| -> Option<bool> {
910        let value = get_attribute(&table_look_tag, attribute)?.to_ascii_lowercase();
911        match value.as_str() {
912            "1" | "true" | "on" => Some(true),
913            "0" | "false" | "off" => Some(false),
914            _ => None,
915        }
916    };
917
918    let mask_value = look_mask.unwrap_or(0);
919
920    Some(ParsedTableLook {
921        first_row: resolve_on_off_attribute("w:firstRow").unwrap_or((mask_value & 0x0020) != 0),
922        last_row: resolve_on_off_attribute("w:lastRow").unwrap_or((mask_value & 0x0040) != 0),
923        first_col: resolve_on_off_attribute("w:firstColumn").unwrap_or((mask_value & 0x0080) != 0),
924        last_col: resolve_on_off_attribute("w:lastColumn").unwrap_or((mask_value & 0x0100) != 0),
925        no_h_band: resolve_on_off_attribute("w:noHBand").unwrap_or((mask_value & 0x0200) != 0),
926        no_v_band: resolve_on_off_attribute("w:noVBand").unwrap_or((mask_value & 0x0400) != 0),
927        row_band_size: row_band_size_raw.max(1),
928        col_band_size: col_band_size_raw.max(1),
929    })
930}
931
932/// Mirrors TypeScript `parseTableConditionalStyleFromXml`.
933pub fn parse_table_conditional_style_from_xml(
934    xml: &str,
935    theme_fonts: &ThemeFontMap,
936) -> Option<ParsedTableStyleCondition> {
937    if xml.is_empty() {
938        return None;
939    }
940
941    let table_properties_xml = direct_table_property(xml, "w:tblPr").unwrap_or_default();
942    let paragraph_properties_xml = crate::xml::extract_balanced_tag_slices(xml, "w:pPr")
943        .into_iter()
944        .next()
945        .unwrap_or_default();
946    let row_properties_xml = crate::xml::extract_balanced_tag_slices(xml, "w:trPr")
947        .into_iter()
948        .next()
949        .unwrap_or_default();
950    let cell_properties_xml = direct_table_property(xml, "w:tcPr").unwrap_or_default();
951    let cell_preferred_width =
952        direct_table_property(&cell_properties_xml, "w:tcW").and_then(parse_table_preferred_width);
953    let run_properties_xml = resolve_style_properties_block(xml, "w:rPr");
954    let table_look = parse_table_look(Some(&table_properties_xml));
955    let paragraph_text_alignment = super::style::parse_paragraph_text_alignment_from_xml(
956        direct_table_property(xml, "w:pPr").unwrap_or_default(),
957    );
958    let paragraph_snap_to_grid = parse_on_off_attribute(
959        direct_table_property(xml, "w:pPr").unwrap_or_default(),
960        "snapToGrid",
961    );
962    let paragraph_align = map_alignment(
963        super::scan::find_attribute_value_in_tag(&paragraph_properties_xml, "w:jc", "w:val")
964            .as_deref(),
965    );
966
967    let row_shading_tag = super::scan::find_tag_token_ref(&row_properties_xml, "w:shd");
968    let cell_shading_tag = super::scan::find_tag_token_ref(&cell_properties_xml, "w:shd")
969        .or_else(|| super::scan::find_tag_token_ref(&table_properties_xml, "w:shd"));
970    let row_background_color = row_shading_tag
971        .as_deref()
972        .and_then(|tag| get_attribute(tag, "w:fill"))
973        .as_deref()
974        .and_then(|value| normalize_hex_color(Some(value)));
975    let cell_background_color = cell_shading_tag
976        .as_deref()
977        .and_then(|tag| get_attribute(tag, "w:fill"))
978        .as_deref()
979        .and_then(|value| normalize_hex_color(Some(value)));
980    let run_style = parse_text_style_from_xml(&run_properties_xml, theme_fonts);
981
982    let table_borders_xml =
983        crate::xml::extract_balanced_tag_slices(&table_properties_xml, "w:tblBorders")
984            .into_iter()
985            .next()
986            .or_else(|| super::scan::find_tag_token_ref(&table_properties_xml, "w:tblBorders"));
987    let cell_borders_xml =
988        crate::xml::extract_balanced_tag_slices(&cell_properties_xml, "w:tcBorders")
989            .into_iter()
990            .next()
991            .or_else(|| super::scan::find_tag_token_ref(&cell_properties_xml, "w:tcBorders"));
992    let table_borders = table_borders_xml
993        .as_deref()
994        .and_then(parse_table_border_set);
995    let cell_borders = cell_borders_xml.as_deref().and_then(parse_table_border_set);
996    let table_properties = parse_table_style_properties_from_xml(Some(&table_properties_xml));
997
998    if row_background_color.is_none()
999        && cell_background_color.is_none()
1000        && cell_preferred_width.is_none()
1001        && paragraph_align.is_none()
1002        && paragraph_snap_to_grid.is_none()
1003        && paragraph_text_alignment.is_none()
1004        && run_style.is_none()
1005        && table_borders.is_none()
1006        && cell_borders.is_none()
1007        && table_properties.is_none()
1008        && table_look.is_none()
1009    {
1010        return None;
1011    }
1012
1013    Some(ParsedTableStyleCondition {
1014        row_background_color,
1015        cell_background_color,
1016        cell_preferred_width,
1017        paragraph_align,
1018        paragraph_snap_to_grid,
1019        paragraph_text_alignment,
1020        run_style,
1021        table_borders,
1022        cell_borders,
1023        table_properties,
1024        table_look,
1025    })
1026}
1027
1028fn merge_table_conditional_style(
1029    inherited: Option<&ParsedTableStyleCondition>,
1030    direct: Option<&ParsedTableStyleCondition>,
1031) -> Option<ParsedTableStyleCondition> {
1032    if inherited.is_none() && direct.is_none() {
1033        return None;
1034    }
1035
1036    let merged = ParsedTableStyleCondition {
1037        row_background_color: direct
1038            .and_then(|value| value.row_background_color.clone())
1039            .or_else(|| inherited.and_then(|value| value.row_background_color.clone())),
1040        cell_background_color: direct
1041            .and_then(|value| value.cell_background_color.clone())
1042            .or_else(|| inherited.and_then(|value| value.cell_background_color.clone())),
1043        cell_preferred_width: direct
1044            .and_then(|value| value.cell_preferred_width.clone())
1045            .or_else(|| inherited.and_then(|value| value.cell_preferred_width.clone())),
1046        paragraph_align: direct
1047            .and_then(|value| value.paragraph_align)
1048            .or_else(|| inherited.and_then(|value| value.paragraph_align)),
1049        paragraph_text_alignment: direct
1050            .and_then(|value| value.paragraph_text_alignment)
1051            .or_else(|| inherited.and_then(|value| value.paragraph_text_alignment)),
1052        paragraph_snap_to_grid: direct
1053            .and_then(|value| value.paragraph_snap_to_grid)
1054            .or_else(|| inherited.and_then(|value| value.paragraph_snap_to_grid)),
1055        run_style: merge_text_styles(
1056            inherited.and_then(|value| value.run_style.clone()),
1057            direct.and_then(|value| value.run_style.clone()),
1058        ),
1059        table_borders: merge_table_border_sets(
1060            inherited.and_then(|value| value.table_borders.as_ref()),
1061            direct.and_then(|value| value.table_borders.as_ref()),
1062        ),
1063        cell_borders: merge_table_border_sets(
1064            inherited.and_then(|value| value.cell_borders.as_ref()),
1065            direct.and_then(|value| value.cell_borders.as_ref()),
1066        ),
1067        table_properties: merge_table_style_properties(
1068            inherited.and_then(|value| value.table_properties.as_ref()),
1069            direct.and_then(|value| value.table_properties.as_ref()),
1070        ),
1071        table_look: Some(merge_table_look(
1072            direct.and_then(|value| value.table_look.as_ref()),
1073            inherited.and_then(|value| value.table_look.as_ref()),
1074        )),
1075    };
1076
1077    if merged.row_background_color.is_none()
1078        && merged.cell_background_color.is_none()
1079        && merged.cell_preferred_width.is_none()
1080        && merged.paragraph_align.is_none()
1081        && merged.paragraph_snap_to_grid.is_none()
1082        && merged.paragraph_text_alignment.is_none()
1083        && merged.run_style.is_none()
1084        && merged.table_borders.is_none()
1085        && merged.cell_borders.is_none()
1086        && merged.table_properties.is_none()
1087        && merged.table_look.is_none()
1088    {
1089        return None;
1090    }
1091
1092    Some(merged)
1093}
1094
1095/// Mirrors TypeScript `mergeTextStyles`.
1096pub fn merge_text_styles(
1097    inherited: Option<TextStyle>,
1098    direct: Option<TextStyle>,
1099) -> Option<TextStyle> {
1100    let mut merged = TextStyle {
1101        bold: None,
1102        italic: None,
1103        underline: None,
1104        strike: None,
1105        all_caps: None,
1106        small_caps: None,
1107        color: None,
1108        highlight: None,
1109        background_color: None,
1110        font_size_pt: None,
1111        font_size_cs_pt: None,
1112        font_family: None,
1113        source_font_family: None,
1114        font_family_ascii: None,
1115        font_family_h_ansi: None,
1116        font_family_east_asia: None,
1117        font_family_cs: None,
1118        font_theme_ascii: None,
1119        font_theme_h_ansi: None,
1120        font_theme_east_asia: None,
1121        font_theme_cs: None,
1122        resolved_font_family_ascii: None,
1123        resolved_font_family_h_ansi: None,
1124        resolved_font_family_east_asia: None,
1125        resolved_font_family_cs: None,
1126        font_hint: None,
1127        language: None,
1128        language_east_asia: None,
1129        language_bidi: None,
1130        right_to_left: None,
1131        complex_script: None,
1132        character_spacing_twips: None,
1133        character_scale_percent: None,
1134        source_character_scale: None,
1135        kerning_min_font_size_pt: None,
1136        ligatures: None,
1137        contextual_alternates: None,
1138        vertical_align: None,
1139        run_border: None,
1140    };
1141
1142    for style in [inherited, direct].into_iter().flatten() {
1143        if style.bold.is_some() {
1144            merged.bold = style.bold;
1145        }
1146        if style.italic.is_some() {
1147            merged.italic = style.italic;
1148        }
1149        if style.underline.is_some() {
1150            merged.underline = style.underline;
1151        }
1152        if style.strike.is_some() {
1153            merged.strike = style.strike;
1154        }
1155        if style.all_caps.is_some() {
1156            merged.all_caps = style.all_caps;
1157        }
1158        if style.small_caps.is_some() {
1159            merged.small_caps = style.small_caps;
1160        }
1161        if style.color.is_some() {
1162            merged.color = style.color;
1163        }
1164        if style.highlight.is_some() {
1165            merged.highlight = style.highlight;
1166        }
1167        if style.background_color.is_some() {
1168            merged.background_color = style.background_color;
1169        }
1170        if style.font_size_pt.is_some() {
1171            merged.font_size_pt = style.font_size_pt;
1172        }
1173        if style.font_size_cs_pt.is_some() {
1174            merged.font_size_cs_pt = style.font_size_cs_pt;
1175        }
1176        if style.font_family.is_some() {
1177            merged.font_family = style.font_family;
1178        }
1179        if style.source_font_family.is_some() {
1180            merged.source_font_family = style.source_font_family;
1181        }
1182        if style.font_family_ascii.is_some() {
1183            merged.font_family_ascii = style.font_family_ascii;
1184        }
1185        if style.font_family_h_ansi.is_some() {
1186            merged.font_family_h_ansi = style.font_family_h_ansi;
1187        }
1188        if style.font_family_east_asia.is_some() {
1189            merged.font_family_east_asia = style.font_family_east_asia;
1190        }
1191        if style.font_family_cs.is_some() {
1192            merged.font_family_cs = style.font_family_cs;
1193        }
1194        if style.font_theme_ascii.is_some() {
1195            merged.font_theme_ascii = style.font_theme_ascii;
1196        }
1197        if style.font_theme_h_ansi.is_some() {
1198            merged.font_theme_h_ansi = style.font_theme_h_ansi;
1199        }
1200        if style.font_theme_east_asia.is_some() {
1201            merged.font_theme_east_asia = style.font_theme_east_asia;
1202        }
1203        if style.font_theme_cs.is_some() {
1204            merged.font_theme_cs = style.font_theme_cs;
1205        }
1206        if style.resolved_font_family_ascii.is_some() {
1207            merged.resolved_font_family_ascii = style.resolved_font_family_ascii;
1208        }
1209        if style.resolved_font_family_h_ansi.is_some() {
1210            merged.resolved_font_family_h_ansi = style.resolved_font_family_h_ansi;
1211        }
1212        if style.resolved_font_family_east_asia.is_some() {
1213            merged.resolved_font_family_east_asia = style.resolved_font_family_east_asia;
1214        }
1215        if style.resolved_font_family_cs.is_some() {
1216            merged.resolved_font_family_cs = style.resolved_font_family_cs;
1217        }
1218        if style.font_hint.is_some() {
1219            merged.font_hint = style.font_hint;
1220        }
1221        if style.language.is_some() {
1222            merged.language = style.language;
1223        }
1224        if style.language_east_asia.is_some() {
1225            merged.language_east_asia = style.language_east_asia;
1226        }
1227        if style.language_bidi.is_some() {
1228            merged.language_bidi = style.language_bidi;
1229        }
1230        if style.right_to_left.is_some() {
1231            merged.right_to_left = style.right_to_left;
1232        }
1233        if style.complex_script.is_some() {
1234            merged.complex_script = style.complex_script;
1235        }
1236        if style.character_scale_percent.is_some() || style.source_character_scale.is_some() {
1237            merged.character_scale_percent = style.character_scale_percent;
1238            merged.source_character_scale = style.source_character_scale;
1239        }
1240        if style.character_spacing_twips.is_some() {
1241            merged.character_spacing_twips = style.character_spacing_twips;
1242        }
1243        if style.kerning_min_font_size_pt.is_some() {
1244            merged.kerning_min_font_size_pt = style.kerning_min_font_size_pt;
1245        }
1246        if style.ligatures.is_some() {
1247            merged.ligatures = style.ligatures;
1248        }
1249        if style.contextual_alternates.is_some() {
1250            merged.contextual_alternates = style.contextual_alternates;
1251        }
1252        if style.vertical_align.is_some() {
1253            merged.vertical_align = style.vertical_align;
1254        }
1255        if style.run_border.is_some() {
1256            merged.run_border = style.run_border;
1257        }
1258    }
1259
1260    let has_any = merged.bold.is_some()
1261        || merged.italic.is_some()
1262        || merged.underline.is_some()
1263        || merged.strike.is_some()
1264        || merged.all_caps.is_some()
1265        || merged.small_caps.is_some()
1266        || merged.color.is_some()
1267        || merged.highlight.is_some()
1268        || merged.background_color.is_some()
1269        || merged.font_size_pt.is_some()
1270        || merged.font_size_cs_pt.is_some()
1271        || merged.font_family.is_some()
1272        || merged.source_font_family.is_some()
1273        || merged.font_family_ascii.is_some()
1274        || merged.font_family_h_ansi.is_some()
1275        || merged.font_family_east_asia.is_some()
1276        || merged.font_family_cs.is_some()
1277        || merged.font_theme_ascii.is_some()
1278        || merged.font_theme_h_ansi.is_some()
1279        || merged.font_theme_east_asia.is_some()
1280        || merged.font_theme_cs.is_some()
1281        || merged.resolved_font_family_ascii.is_some()
1282        || merged.resolved_font_family_h_ansi.is_some()
1283        || merged.resolved_font_family_east_asia.is_some()
1284        || merged.resolved_font_family_cs.is_some()
1285        || merged.font_hint.is_some()
1286        || merged.language.is_some()
1287        || merged.language_east_asia.is_some()
1288        || merged.language_bidi.is_some()
1289        || merged.right_to_left.is_some()
1290        || merged.complex_script.is_some()
1291        || merged.character_spacing_twips.is_some()
1292        || merged.character_scale_percent.is_some()
1293        || merged.source_character_scale.is_some()
1294        || merged.kerning_min_font_size_pt.is_some()
1295        || merged.ligatures.is_some()
1296        || merged.contextual_alternates.is_some()
1297        || merged.vertical_align.is_some()
1298        || merged.run_border.is_some();
1299
1300    if has_any {
1301        Some(merged)
1302    } else {
1303        None
1304    }
1305}
1306
1307fn merge_paragraph_spacing(
1308    inherited: Option<&ParagraphSpacing>,
1309    direct: Option<&ParagraphSpacing>,
1310) -> Option<ParagraphSpacing> {
1311    if inherited.is_none() && direct.is_none() {
1312        return None;
1313    }
1314
1315    let merged = ParagraphSpacing {
1316        before_twips: direct
1317            .and_then(|value| value.before_twips)
1318            .or_else(|| inherited.and_then(|value| value.before_twips)),
1319        after_twips: direct
1320            .and_then(|value| value.after_twips)
1321            .or_else(|| inherited.and_then(|value| value.after_twips)),
1322        before_auto_spacing: direct
1323            .and_then(|value| value.before_auto_spacing)
1324            .or_else(|| inherited.and_then(|value| value.before_auto_spacing)),
1325        after_auto_spacing: direct
1326            .and_then(|value| value.after_auto_spacing)
1327            .or_else(|| inherited.and_then(|value| value.after_auto_spacing)),
1328        line_twips: direct
1329            .and_then(|value| value.line_twips)
1330            .or_else(|| inherited.and_then(|value| value.line_twips)),
1331        line_rule: direct
1332            .and_then(|value| value.line_rule)
1333            .or_else(|| inherited.and_then(|value| value.line_rule)),
1334    };
1335
1336    if merged.before_twips.is_none()
1337        && merged.after_twips.is_none()
1338        && merged.before_auto_spacing.is_none()
1339        && merged.after_auto_spacing.is_none()
1340        && merged.line_twips.is_none()
1341        && merged.line_rule.is_none()
1342    {
1343        return None;
1344    }
1345
1346    Some(merged)
1347}
1348
1349fn merge_paragraph_indent(
1350    inherited: Option<&ParagraphIndent>,
1351    direct: Option<&ParagraphIndent>,
1352) -> Option<ParagraphIndent> {
1353    if inherited.is_none() && direct.is_none() {
1354        return None;
1355    }
1356
1357    let merged = ParagraphIndent {
1358        left_twips: direct
1359            .and_then(|value| value.left_twips)
1360            .or_else(|| inherited.and_then(|value| value.left_twips)),
1361        right_twips: direct
1362            .and_then(|value| value.right_twips)
1363            .or_else(|| inherited.and_then(|value| value.right_twips)),
1364        first_line_twips: direct
1365            .and_then(|value| value.first_line_twips)
1366            .or_else(|| inherited.and_then(|value| value.first_line_twips)),
1367        hanging_twips: direct
1368            .and_then(|value| value.hanging_twips)
1369            .or_else(|| inherited.and_then(|value| value.hanging_twips)),
1370    };
1371
1372    if merged.left_twips.is_none()
1373        && merged.right_twips.is_none()
1374        && merged.first_line_twips.is_none()
1375        && merged.hanging_twips.is_none()
1376    {
1377        return None;
1378    }
1379
1380    Some(merged)
1381}
1382
1383fn merge_paragraph_background_color(
1384    inherited: Option<&str>,
1385    direct: Option<&str>,
1386) -> Option<String> {
1387    direct
1388        .map(str::to_string)
1389        .or_else(|| inherited.map(str::to_string))
1390}
1391
1392fn merge_paragraph_border_style(
1393    inherited: Option<&ParagraphBorderStyle>,
1394    direct: Option<&ParagraphBorderStyle>,
1395) -> Option<ParagraphBorderStyle> {
1396    if inherited.is_none() && direct.is_none() {
1397        return None;
1398    }
1399
1400    let border_type = direct
1401        .map(|value| value.border_type.clone())
1402        .or_else(|| inherited.map(|value| value.border_type.clone()))?;
1403    if border_type.is_empty() {
1404        return None;
1405    }
1406
1407    Some(ParagraphBorderStyle {
1408        border_type,
1409        color: direct
1410            .and_then(|value| value.color.clone())
1411            .or_else(|| inherited.and_then(|value| value.color.clone())),
1412        size_eighth_pt: direct
1413            .and_then(|value| value.size_eighth_pt)
1414            .or_else(|| inherited.and_then(|value| value.size_eighth_pt)),
1415        space_pt: direct
1416            .and_then(|value| value.space_pt)
1417            .or_else(|| inherited.and_then(|value| value.space_pt)),
1418        frame: direct
1419            .and_then(|value| value.frame)
1420            .or_else(|| inherited.and_then(|value| value.frame)),
1421        shadow: direct
1422            .and_then(|value| value.shadow)
1423            .or_else(|| inherited.and_then(|value| value.shadow)),
1424    })
1425}
1426
1427fn merge_paragraph_border_sets(
1428    inherited: Option<&ParagraphBorderSet>,
1429    direct: Option<&ParagraphBorderSet>,
1430) -> Option<ParagraphBorderSet> {
1431    if inherited.is_none() && direct.is_none() {
1432        return None;
1433    }
1434
1435    let merged = ParagraphBorderSet {
1436        top: merge_paragraph_border_style(
1437            inherited.and_then(|value| value.top.as_ref()),
1438            direct.and_then(|value| value.top.as_ref()),
1439        ),
1440        right: merge_paragraph_border_style(
1441            inherited.and_then(|value| value.right.as_ref()),
1442            direct.and_then(|value| value.right.as_ref()),
1443        ),
1444        bottom: merge_paragraph_border_style(
1445            inherited.and_then(|value| value.bottom.as_ref()),
1446            direct.and_then(|value| value.bottom.as_ref()),
1447        ),
1448        left: merge_paragraph_border_style(
1449            inherited.and_then(|value| value.left.as_ref()),
1450            direct.and_then(|value| value.left.as_ref()),
1451        ),
1452        between: merge_paragraph_border_style(
1453            inherited.and_then(|value| value.between.as_ref()),
1454            direct.and_then(|value| value.between.as_ref()),
1455        ),
1456        bar: merge_paragraph_border_style(
1457            inherited.and_then(|value| value.bar.as_ref()),
1458            direct.and_then(|value| value.bar.as_ref()),
1459        ),
1460    };
1461
1462    if merged.top.is_none()
1463        && merged.right.is_none()
1464        && merged.bottom.is_none()
1465        && merged.left.is_none()
1466        && merged.between.is_none()
1467        && merged.bar.is_none()
1468    {
1469        return None;
1470    }
1471
1472    Some(merged)
1473}
1474
1475fn merge_paragraph_tab_stops(
1476    inherited: Option<&[ParagraphTabStop]>,
1477    direct: Option<&[ParagraphTabStop]>,
1478) -> Option<Vec<ParagraphTabStop>> {
1479    if inherited.is_none() && direct.is_none() {
1480        return None;
1481    }
1482
1483    let mut by_position = HashMap::new();
1484    for stop in inherited.unwrap_or(&[]) {
1485        if let Some(position) = stop.position_twips {
1486            by_position.insert(position, stop.clone());
1487        }
1488    }
1489    for stop in direct.unwrap_or(&[]) {
1490        if let Some(position) = stop.position_twips {
1491            by_position.insert(position, stop.clone());
1492        }
1493    }
1494
1495    if by_position.is_empty() {
1496        return None;
1497    }
1498
1499    let mut combined: Vec<ParagraphTabStop> = by_position.into_values().collect();
1500    combined.sort_by_key(|stop| stop.position_twips.unwrap_or(0));
1501    Some(combined)
1502}
1503
1504fn merge_paragraph_boolean(inherited: Option<bool>, direct: Option<bool>) -> Option<bool> {
1505    if direct.is_some() {
1506        direct
1507    } else {
1508        inherited
1509    }
1510}
1511
1512fn resolve_theme_font(theme_token: Option<&str>, theme_fonts: &ThemeFontMap) -> Option<String> {
1513    let normalized = theme_token?.to_ascii_lowercase();
1514    if normalized.starts_with("major") {
1515        if normalized.contains("eastasia") {
1516            return theme_fonts
1517                .major_east_asia
1518                .clone()
1519                .or_else(|| theme_fonts.major_latin.clone());
1520        }
1521        if normalized.contains("bidi") || normalized.contains("cs") {
1522            return theme_fonts
1523                .major_complex_script
1524                .clone()
1525                .or_else(|| theme_fonts.major_latin.clone());
1526        }
1527        return theme_fonts.major_latin.clone();
1528    }
1529    if normalized.starts_with("minor") {
1530        if normalized.contains("eastasia") {
1531            return theme_fonts
1532                .minor_east_asia
1533                .clone()
1534                .or_else(|| theme_fonts.minor_latin.clone());
1535        }
1536        if normalized.contains("bidi") || normalized.contains("cs") {
1537            return theme_fonts
1538                .minor_complex_script
1539                .clone()
1540                .or_else(|| theme_fonts.minor_latin.clone());
1541        }
1542        return theme_fonts.minor_latin.clone();
1543    }
1544    None
1545}
1546
1547fn find_drawing_rpr_attribute(xml: &str, attribute: &str) -> Option<String> {
1548    let tag = super::scan::find_tag_token_ref(xml, "a:rPr")?;
1549    get_attribute(&tag, attribute)
1550}
1551
1552fn find_drawing_latin_typeface(xml: &str) -> Option<String> {
1553    if let Some(block) = crate::xml::extract_balanced_tag_slices(xml, "a:rPr")
1554        .into_iter()
1555        .next()
1556    {
1557        if let Some(latin) = crate::xml::extract_balanced_tag_slices(&block, "a:latin")
1558            .into_iter()
1559            .next()
1560            .or_else(|| super::scan::find_tag_token_ref(&block, "a:latin"))
1561        {
1562            if let Some(typeface) = get_attribute(&latin, "typeface") {
1563                return Some(typeface);
1564            }
1565        }
1566    }
1567
1568    if let Some(block) = crate::xml::extract_balanced_tag_slices(xml, "a:defRPr")
1569        .into_iter()
1570        .next()
1571    {
1572        if let Some(latin) = crate::xml::extract_balanced_tag_slices(&block, "a:latin")
1573            .into_iter()
1574            .next()
1575            .or_else(|| super::scan::find_tag_token_ref(&block, "a:latin"))
1576        {
1577            if let Some(typeface) = get_attribute(&latin, "typeface") {
1578                return Some(typeface);
1579            }
1580        }
1581    }
1582
1583    super::scan::find_tag_token_ref(xml, "a:latin")
1584        .and_then(|latin| get_attribute(&latin, "typeface"))
1585}
1586
1587fn find_drawing_color(xml: &str) -> Option<String> {
1588    if let Some(block) = crate::xml::extract_balanced_tag_slices(xml, "a:rPr")
1589        .into_iter()
1590        .next()
1591    {
1592        if let Some(color_xml) = crate::xml::extract_balanced_tag_slices(&block, "a:solidFill")
1593            .into_iter()
1594            .next()
1595            .or_else(|| super::scan::find_tag_token_ref(&block, "a:solidFill"))
1596        {
1597            if let Some(srgb) = super::scan::find_tag_token_ref(&color_xml, "a:srgbClr") {
1598                if let Some(color) = get_attribute(&srgb, "val")
1599                    .as_deref()
1600                    .and_then(|value| normalize_hex_color(Some(value)))
1601                {
1602                    return Some(color);
1603                }
1604            }
1605        }
1606        if let Some(srgb) = super::scan::find_tag_token_ref(&block, "a:srgbClr") {
1607            if let Some(color) = get_attribute(&srgb, "val")
1608                .as_deref()
1609                .and_then(|value| normalize_hex_color(Some(value)))
1610            {
1611                return Some(color);
1612            }
1613        }
1614    }
1615    None
1616}
1617
1618fn contains_east_asia_glyphs(text: &str) -> bool {
1619    text.chars().any(|ch| {
1620        matches!(ch,
1621            '\u{2e80}'..='\u{9fff}'
1622                | '\u{ac00}'..='\u{d7af}'
1623        )
1624    })
1625}
1626
1627fn contains_complex_script_glyphs(text: &str) -> bool {
1628    text.chars().any(|ch| {
1629        matches!(ch,
1630            '\u{0590}'..='\u{08ff}'
1631                | '\u{fb1d}'..='\u{fefc}'
1632        )
1633    })
1634}
1635
1636fn collect_decoded_text_samples(xml: &str) -> String {
1637    let mut samples = String::new();
1638    for tag_name in ["w:t", "a:t"] {
1639        for block in crate::xml::extract_balanced_tag_slices(xml, tag_name) {
1640            if let Some(start) = block.find('>') {
1641                let end = block.rfind("</").unwrap_or(block.len());
1642                if start + 1 < end {
1643                    samples.push_str(&decode_xml_entities(&block[start + 1..end]));
1644                }
1645            }
1646        }
1647    }
1648    samples
1649}
1650
1651/// Mirrors TypeScript `parseTextStyleFromXml`.
1652pub fn parse_text_style_from_xml(xml: &str, theme_fonts: &ThemeFontMap) -> Option<TextStyle> {
1653    super::style::cached_definition_style(xml, theme_fonts)
1654}
1655
1656pub(crate) fn parse_definition_style_uncached(
1657    xml: &str,
1658    theme_fonts: &ThemeFontMap,
1659) -> Option<TextStyle> {
1660    if xml.is_empty() {
1661        return None;
1662    }
1663
1664    let bold = parse_on_off_attribute(xml, "b");
1665    let italic = parse_on_off_attribute(xml, "i");
1666    let underline = parse_underline_attribute(xml);
1667    let strike = parse_on_off_attribute(xml, "strike");
1668    let all_caps = parse_on_off_attribute(xml, "caps");
1669    let small_caps = parse_on_off_attribute(xml, "smallCaps");
1670
1671    let color_match = super::scan::find_attribute_value_in_tag(xml, "w:color", "w:val");
1672    let highlight_match = super::scan::find_attribute_value_in_tag(xml, "w:highlight", "w:val");
1673    let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd");
1674    let character_spacing_match =
1675        super::scan::find_attribute_value_in_tag(xml, "w:spacing", "w:val");
1676    let kerning_match = super::scan::find_attribute_value_in_tag(xml, "w:kern", "w:val");
1677    let size_match = super::scan::find_attribute_value_in_tag(xml, "w:sz", "w:val");
1678    let complex_size_match = super::scan::find_attribute_value_in_tag(xml, "w:szCs", "w:val");
1679    let run_fonts_tag = super::scan::find_tag_token_ref(xml, "w:rFonts").unwrap_or_default();
1680    let ascii_font = get_attribute(&run_fonts_tag, "w:ascii");
1681    let h_ansi_font = get_attribute(&run_fonts_tag, "w:hAnsi");
1682    let east_asia_font = get_attribute(&run_fonts_tag, "w:eastAsia");
1683    let complex_script_font = get_attribute(&run_fonts_tag, "w:cs");
1684    let ascii_theme_font = get_attribute(&run_fonts_tag, "w:asciiTheme");
1685    let h_ansi_theme_font = get_attribute(&run_fonts_tag, "w:hAnsiTheme");
1686    let east_asia_theme_font = get_attribute(&run_fonts_tag, "w:eastAsiaTheme");
1687    let complex_script_theme_font = get_attribute(&run_fonts_tag, "w:csTheme");
1688    let font_hint = get_attribute(&run_fonts_tag, "w:hint");
1689    let language_tag = super::scan::find_tag_token_ref(xml, "w:lang").unwrap_or_default();
1690    let language = get_attribute(&language_tag, "w:val");
1691    let language_east_asia = get_attribute(&language_tag, "w:eastAsia");
1692    let language_bidi = get_attribute(&language_tag, "w:bidi");
1693    let right_to_left = parse_on_off_attribute(xml, "rtl");
1694    let complex_script = parse_on_off_attribute(xml, "cs");
1695    let vertical_align_match =
1696        super::scan::find_attribute_value_in_tag(xml, "w:vertAlign", "w:val");
1697    let drawing_bold_match = find_drawing_rpr_attribute(xml, "b");
1698    let drawing_italic_match = find_drawing_rpr_attribute(xml, "i");
1699    let drawing_underline_match = find_drawing_rpr_attribute(xml, "u");
1700    let drawing_strike_match =
1701        find_drawing_rpr_attribute(xml, "strike").or_else(|| find_drawing_rpr_attribute(xml, "s"));
1702    let drawing_color_match = find_drawing_color(xml);
1703    let drawing_size_match = find_drawing_rpr_attribute(xml, "sz");
1704    let drawing_font_match = find_drawing_latin_typeface(xml);
1705    let run_border_tag = super::scan::find_tag_token_ref(xml, "w:bdr");
1706    let decoded_text_samples = collect_decoded_text_samples(xml);
1707    let contains_east_asia = contains_east_asia_glyphs(&decoded_text_samples);
1708    let contains_complex_script = contains_complex_script_glyphs(&decoded_text_samples);
1709
1710    let mut style = TextStyle {
1711        bold: None,
1712        italic: None,
1713        underline: None,
1714        strike: None,
1715        all_caps: None,
1716        small_caps: None,
1717        color: None,
1718        highlight: None,
1719        background_color: None,
1720        font_size_pt: None,
1721        font_size_cs_pt: None,
1722        font_family: None,
1723        source_font_family: None,
1724        font_family_ascii: None,
1725        font_family_h_ansi: None,
1726        font_family_east_asia: None,
1727        font_family_cs: None,
1728        font_theme_ascii: None,
1729        font_theme_h_ansi: None,
1730        font_theme_east_asia: None,
1731        font_theme_cs: None,
1732        resolved_font_family_ascii: None,
1733        resolved_font_family_h_ansi: None,
1734        resolved_font_family_east_asia: None,
1735        resolved_font_family_cs: None,
1736        font_hint: None,
1737        language: None,
1738        language_east_asia: None,
1739        language_bidi: None,
1740        right_to_left: None,
1741        complex_script: None,
1742        character_spacing_twips: None,
1743        character_scale_percent: None,
1744        source_character_scale: None,
1745        kerning_min_font_size_pt: None,
1746        ligatures: super::style::parse_text_ligatures_from_xml(xml),
1747        contextual_alternates: super::style::parse_contextual_alternates_from_xml(xml),
1748        vertical_align: None,
1749        run_border: None,
1750    };
1751
1752    style.character_scale_percent = super::style::parse_character_scale_percent_from_xml(xml);
1753    style.source_character_scale = super::scan::find_attribute_value_in_tag(xml, "w:w", "w:val");
1754    style.bold = bold;
1755    style.italic = italic;
1756    style.underline = underline;
1757    style.strike = strike;
1758    style.all_caps = all_caps;
1759    style.small_caps = small_caps;
1760
1761    if style.bold.is_none() {
1762        if let Some(value) = drawing_bold_match {
1763            let value = value.to_ascii_lowercase();
1764            style.bold = Some(value != "0" && value != "false");
1765        }
1766    }
1767    if style.italic.is_none() {
1768        if let Some(value) = drawing_italic_match {
1769            let value = value.to_ascii_lowercase();
1770            style.italic = Some(value != "0" && value != "false");
1771        }
1772    }
1773    if style.underline.is_none() {
1774        if let Some(value) = drawing_underline_match {
1775            let value = value.to_ascii_lowercase();
1776            style.underline = Some(value != "none" && value != "false" && value != "0");
1777        }
1778    }
1779    if style.strike.is_none() {
1780        if let Some(value) = drawing_strike_match {
1781            let value = value.to_ascii_lowercase();
1782            style.strike =
1783                Some(value != "nostrike" && value != "none" && value != "false" && value != "0");
1784        }
1785    }
1786
1787    if let Some(color) = normalize_hex_color(color_match.as_deref()) {
1788        style.color = Some(color);
1789    } else if let Some(color) = drawing_color_match {
1790        style.color = Some(color);
1791    }
1792
1793    if let Some(highlight) = highlight_match {
1794        style.highlight = Some(highlight);
1795    }
1796
1797    if let Some(shading_tag) = shading_tag {
1798        if let Some(shading_fill) =
1799            normalize_hex_color(get_attribute(&shading_tag, "w:fill").as_deref())
1800        {
1801            style.background_color = Some(shading_fill);
1802        }
1803    }
1804
1805    if let Some(character_spacing) = character_spacing_match.and_then(|raw| raw.parse::<i64>().ok())
1806    {
1807        style.character_spacing_twips = Some(character_spacing);
1808    }
1809
1810    if let Some(kerning_raw) = kerning_match.and_then(|raw| raw.parse::<f64>().ok()) {
1811        if kerning_raw.is_finite() && kerning_raw >= 0.0 {
1812            style.kerning_min_font_size_pt = Some(kerning_raw / 2.0);
1813        }
1814    }
1815
1816    if let Some(size_raw) = size_match.and_then(|raw| raw.parse::<f64>().ok()) {
1817        style.font_size_pt = Some(size_raw / 2.0);
1818    } else if let Some(size_raw) = drawing_size_match.and_then(|raw| raw.parse::<f64>().ok()) {
1819        style.font_size_pt = Some(size_raw / 100.0);
1820    }
1821    if let Some(size_raw) = complex_size_match.and_then(|raw| raw.parse::<f64>().ok()) {
1822        style.font_size_cs_pt = Some(size_raw / 2.0);
1823    }
1824
1825    style.font_family_ascii = ascii_font.clone();
1826    style.font_family_h_ansi = h_ansi_font.clone();
1827    style.font_family_east_asia = east_asia_font.clone();
1828    style.font_family_cs = complex_script_font.clone();
1829    style.font_theme_ascii = ascii_theme_font.clone();
1830    style.font_theme_h_ansi = h_ansi_theme_font.clone();
1831    style.font_theme_east_asia = east_asia_theme_font.clone();
1832    style.font_theme_cs = complex_script_theme_font.clone();
1833    style.resolved_font_family_ascii = ascii_theme_font
1834        .as_deref()
1835        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1836        .or_else(|| ascii_font.clone());
1837    style.resolved_font_family_h_ansi = h_ansi_theme_font
1838        .as_deref()
1839        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1840        .or_else(|| h_ansi_font.clone());
1841    style.resolved_font_family_east_asia = east_asia_theme_font
1842        .as_deref()
1843        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1844        .or_else(|| east_asia_font.clone());
1845    style.resolved_font_family_cs = complex_script_theme_font
1846        .as_deref()
1847        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1848        .or_else(|| complex_script_font.clone());
1849    style.font_hint = font_hint;
1850    style.language = language;
1851    style.language_east_asia = language_east_asia;
1852    style.language_bidi = language_bidi;
1853    style.right_to_left = right_to_left;
1854    style.complex_script = complex_script;
1855
1856    let run_font_family = ascii_font.clone().or(h_ansi_font.clone());
1857    let run_theme_font_token = ascii_theme_font.clone().or(h_ansi_theme_font.clone());
1858    let east_asia_fallback_font = if contains_east_asia {
1859        east_asia_font.clone()
1860    } else {
1861        None
1862    };
1863    let east_asia_fallback_theme_token = if contains_east_asia {
1864        east_asia_theme_font.clone()
1865    } else {
1866        None
1867    };
1868    let complex_script_fallback_font = if contains_complex_script {
1869        complex_script_font.clone()
1870    } else {
1871        None
1872    };
1873    let complex_script_fallback_theme_token = if contains_complex_script {
1874        complex_script_theme_font.clone()
1875    } else {
1876        None
1877    };
1878    let symbol_fallback_font = {
1879        let candidate = east_asia_font.clone().or(complex_script_font.clone());
1880        candidate.filter(|font| {
1881            let lower = font.to_ascii_lowercase();
1882            lower.contains("symbol")
1883                || lower.contains("emoji")
1884                || lower.contains("dingbat")
1885                || lower.contains("wingding")
1886                || lower.contains("webding")
1887        })
1888    };
1889
1890    if let Some(font_family) = run_font_family {
1891        style.font_family = Some(font_family);
1892    } else if let Some(theme_token) = run_theme_font_token.as_deref() {
1893        if let Some(font_family) = resolve_theme_font(Some(theme_token), theme_fonts) {
1894            style.font_family = Some(font_family);
1895        }
1896    } else if let Some(font_family) = east_asia_fallback_font {
1897        style.font_family = Some(font_family);
1898    } else if let Some(theme_token) = east_asia_fallback_theme_token.as_deref() {
1899        if let Some(font_family) = resolve_theme_font(Some(theme_token), theme_fonts) {
1900            style.font_family = Some(font_family);
1901        }
1902    } else if let Some(font_family) = complex_script_fallback_font {
1903        style.font_family = Some(font_family);
1904    } else if let Some(theme_token) = complex_script_fallback_theme_token.as_deref() {
1905        if let Some(font_family) = resolve_theme_font(Some(theme_token), theme_fonts) {
1906            style.font_family = Some(font_family);
1907        }
1908    } else if let Some(font_family) = symbol_fallback_font {
1909        style.font_family = Some(font_family);
1910    } else if let Some(font_family) = drawing_font_match {
1911        style.font_family = Some(font_family);
1912    }
1913    style.source_font_family = style.font_family.clone();
1914
1915    if let Some(vertical_align_value) = vertical_align_match.map(|value| value.to_ascii_lowercase())
1916    {
1917        style.vertical_align = match vertical_align_value.as_str() {
1918            "superscript" => Some(VerticalAlign::Superscript),
1919            "subscript" => Some(VerticalAlign::Subscript),
1920            _ => None,
1921        };
1922    }
1923
1924    style.run_border = parse_text_run_border_style(run_border_tag.as_deref());
1925
1926    merge_text_styles(None, Some(style))
1927}
1928
1929/// Mirrors TypeScript `parseParagraphAlignFromXml`.
1930pub fn parse_paragraph_align_from_xml(xml: &str) -> Option<ParagraphAlignment> {
1931    map_alignment(super::scan::find_attribute_value_in_tag(xml, "w:jc", "w:val").as_deref())
1932}
1933
1934pub(crate) fn parse_heading_level_from_outline(xml: &str) -> Option<Option<HeadingLevel>> {
1935    let tag = direct_table_property(xml, "w:outlineLvl")?;
1936    let outline_value = get_attribute(tag, "w:val")?;
1937    let level = outline_value.parse::<i64>().ok()? + 1;
1938    Some(match level {
1939        1 => Some(HeadingLevel::One),
1940        2 => Some(HeadingLevel::Two),
1941        3 => Some(HeadingLevel::Three),
1942        4 => Some(HeadingLevel::Four),
1943        5 => Some(HeadingLevel::Five),
1944        6 => Some(HeadingLevel::Six),
1945        7 => Some(HeadingLevel::Seven),
1946        8 => Some(HeadingLevel::Eight),
1947        9 => Some(HeadingLevel::Nine),
1948        _ => None,
1949    })
1950}
1951
1952fn parse_style_type(value: Option<&str>) -> Option<RawStyleType> {
1953    match value?.to_ascii_lowercase().as_str() {
1954        "paragraph" => Some(RawStyleType::Paragraph),
1955        "character" => Some(RawStyleType::Character),
1956        "numbering" => Some(RawStyleType::Numbering),
1957        "table" => Some(RawStyleType::Table),
1958        _ => None,
1959    }
1960}
1961
1962fn resolve_style_properties_block<'a>(style_xml: &'a str, tag_name: &str) -> &'a str {
1963    crate::xml::extract_balanced_tag_slices(style_xml, tag_name)
1964        .into_iter()
1965        .next()
1966        .or_else(|| super::scan::find_tag_token_ref(style_xml, tag_name))
1967        .unwrap_or_default()
1968}
1969
1970fn strip_table_conditional_style_blocks(style_xml: &str) -> String {
1971    if style_xml.is_empty() {
1972        return style_xml.to_string();
1973    }
1974
1975    let mut output = style_xml.to_string();
1976    loop {
1977        let before_len = output.len();
1978        output = remove_balanced_tag_blocks(&output, "w:tblStylePr");
1979        output = remove_self_closing_tags(&output, "w:tblStylePr");
1980        if output.len() == before_len {
1981            break;
1982        }
1983    }
1984    output
1985}
1986
1987fn remove_balanced_tag_blocks(xml: &str, tag_name: &str) -> String {
1988    let mut output = String::new();
1989    let mut last_index = 0;
1990    for range in extract_balanced_tag_ranges(xml, tag_name) {
1991        output.push_str(&xml[last_index..range.start]);
1992        last_index = range.end;
1993    }
1994    output.push_str(&xml[last_index..]);
1995    output
1996}
1997
1998fn remove_self_closing_tags(xml: &str, tag_name: &str) -> String {
1999    let mut output = String::new();
2000    let mut last_index = 0;
2001    let open_prefix = format!("<{tag_name}");
2002    let mut index = 0;
2003    while index < xml.len() {
2004        if xml.as_bytes().get(index) != Some(&b'<')
2005            || !super::scan::starts_with_ignore_ascii_case(&xml[index..], &open_prefix)
2006        {
2007            index += 1;
2008            continue;
2009        }
2010
2011        let after_prefix = index + open_prefix.len();
2012        let mut cursor = after_prefix;
2013        let bytes = xml.as_bytes();
2014        while cursor < bytes.len() {
2015            if bytes[cursor] == b'>' {
2016                let token = &xml[index..=cursor];
2017                if token.ends_with("/>") || token.ends_with("/ >") {
2018                    output.push_str(&xml[last_index..index]);
2019                    last_index = cursor + 1;
2020                }
2021                index = cursor + 1;
2022                break;
2023            }
2024            cursor += 1;
2025        }
2026        if cursor >= bytes.len() {
2027            break;
2028        }
2029    }
2030    output.push_str(&xml[last_index..]);
2031    output
2032}
2033
2034fn extract_balanced_tag_ranges(xml: &str, tag_name: &str) -> Vec<crate::xml::TagRange> {
2035    crate::xml::extract_balanced_tag_ranges(xml, tag_name)
2036}
2037
2038fn read_typeface(xml: &str, tag_name: &str) -> Option<String> {
2039    let token = super::scan::find_tag_token_ref(xml, &format!("a:{tag_name}"))?;
2040    let value = get_attribute(&token, "typeface")?.trim().to_string();
2041    if value.is_empty() {
2042        None
2043    } else {
2044        Some(value)
2045    }
2046}
2047
2048/// Mirrors TypeScript `parseThemeFonts`.
2049pub fn parse_theme_fonts(pkg: &OoxmlPackage) -> ThemeFontMap {
2050    let Some(theme_xml) =
2051        crate::package::related_document_part(pkg, "theme").map(|part| part.content.as_str())
2052    else {
2053        return ThemeFontMap::default();
2054    };
2055
2056    let major_font_xml = crate::xml::extract_balanced_tag_slices(theme_xml, "a:majorFont")
2057        .into_iter()
2058        .next()
2059        .unwrap_or_default();
2060    let minor_font_xml = crate::xml::extract_balanced_tag_slices(theme_xml, "a:minorFont")
2061        .into_iter()
2062        .next()
2063        .unwrap_or_default();
2064
2065    ThemeFontMap {
2066        major_latin: read_typeface(&major_font_xml, "latin"),
2067        major_east_asia: read_typeface(&major_font_xml, "ea"),
2068        major_complex_script: read_typeface(&major_font_xml, "cs"),
2069        minor_latin: read_typeface(&minor_font_xml, "latin"),
2070        minor_east_asia: read_typeface(&minor_font_xml, "ea"),
2071        minor_complex_script: read_typeface(&minor_font_xml, "cs"),
2072    }
2073}
2074
2075fn parse_theme_color_from_tag(color_xml: &str) -> Option<String> {
2076    if let Some(explicit) = super::scan::find_attribute_value_in_tag(color_xml, "a:srgbClr", "val")
2077        .as_deref()
2078        .and_then(|value| normalize_hex_color(Some(value)))
2079    {
2080        return Some(explicit);
2081    }
2082
2083    super::scan::find_attribute_value_in_tag(color_xml, "a:sysClr", "lastClr")
2084        .as_deref()
2085        .and_then(|value| normalize_hex_color(Some(value)))
2086}
2087
2088/// Mirrors TypeScript `parseThemeColors`.
2089pub fn parse_theme_colors(pkg: &OoxmlPackage) -> ThemeColorMap {
2090    let Some(theme_xml) =
2091        crate::package::related_document_part(pkg, "theme").map(|part| part.content.as_str())
2092    else {
2093        return ThemeColorMap::new();
2094    };
2095
2096    let color_scheme_xml = crate::xml::extract_balanced_tag_slices(theme_xml, "a:clrScheme")
2097        .into_iter()
2098        .next()
2099        .unwrap_or_default();
2100    if color_scheme_xml.is_empty() {
2101        return ThemeColorMap::new();
2102    }
2103
2104    let color_names = [
2105        "dk1",
2106        "lt1",
2107        "dk2",
2108        "lt2",
2109        "accent1",
2110        "accent2",
2111        "accent3",
2112        "accent4",
2113        "accent5",
2114        "accent6",
2115        "hlink",
2116        "folHlink",
2117        "bg1",
2118        "bg2",
2119        "tx1",
2120        "tx2",
2121        "followedHyperlink",
2122    ];
2123
2124    let mut colors = ThemeColorMap::new();
2125    for name in color_names {
2126        let tag_match =
2127            crate::xml::extract_balanced_tag_slices(&color_scheme_xml, &format!("a:{name}"))
2128                .into_iter()
2129                .next()
2130                .or_else(|| {
2131                    super::scan::find_tag_token_ref(&color_scheme_xml, &format!("a:{name}"))
2132                });
2133        let Some(tag_match) = tag_match else {
2134            continue;
2135        };
2136        if let Some(color) = parse_theme_color_from_tag(&tag_match) {
2137            colors.insert(name.to_ascii_lowercase(), color);
2138        }
2139    }
2140
2141    colors
2142}
2143
2144/// Font size Word uses when declared run defaults omit `w:sz`.
2145const WORD_DECLARED_DEFAULT_FONT_SIZE_PT: f64 = 10.0;
2146
2147/// Supplies the defaults Word substitutes when a styles part omits
2148/// `w:rPrDefault` or `w:pPrDefault`. Native controls on Word 16.106 show that
2149/// absent run defaults resolve to the theme minor fonts (Aptos without a theme)
2150/// at 12 pt, and absent paragraph defaults resolve to 1.15 auto line spacing
2151/// with 8 pt after. Declared, even empty, defaults keep the OOXML semantics.
2152fn with_word_application_defaults(doc_defaults_xml: &str, theme_fonts: &ThemeFontMap) -> String {
2153    let declares_run_defaults = super::scan::contains_tag(doc_defaults_xml, "w:rPrDefault");
2154    let declares_paragraph_defaults = super::scan::contains_tag(doc_defaults_xml, "w:pPrDefault");
2155    if declares_run_defaults && declares_paragraph_defaults {
2156        return doc_defaults_xml.to_string();
2157    }
2158    let mut supplied = String::new();
2159    if !declares_run_defaults {
2160        let fonts = if theme_fonts.minor_latin.is_some() {
2161            r#"<w:rFonts w:asciiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:hAnsiTheme="minorHAnsi" w:cstheme="minorBidi"/>"#
2162        } else {
2163            r#"<w:rFonts w:ascii="Aptos" w:hAnsi="Aptos"/>"#
2164        };
2165        supplied.push_str(&format!(
2166            r#"<w:rPrDefault><w:rPr>{fonts}<w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:rPrDefault>"#
2167        ));
2168    }
2169    if !declares_paragraph_defaults {
2170        supplied.push_str(
2171            r#"<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault>"#,
2172        );
2173    }
2174    match doc_defaults_xml.rfind("</w:docDefaults>") {
2175        Some(end) => format!(
2176            "{}{}{}",
2177            &doc_defaults_xml[..end],
2178            supplied,
2179            &doc_defaults_xml[end..]
2180        ),
2181        None => format!("<w:docDefaults>{supplied}</w:docDefaults>"),
2182    }
2183}
2184
2185/// Mirrors TypeScript `parseStyleSheet`.
2186pub fn parse_style_sheet(pkg: &OoxmlPackage) -> ParsedStyleSheet {
2187    parse_style_sheet_selected(pkg, None)
2188}
2189
2190pub(crate) fn parse_document_style_sheet(pkg: &OoxmlPackage) -> ParsedStyleSheet {
2191    parse_referenced_style_sheet(pkg, document_style_references(pkg))
2192}
2193
2194fn document_style_references(pkg: &OoxmlPackage) -> HashSet<String> {
2195    let mut referenced = HashSet::new();
2196    for part in pkg.parts.values() {
2197        for name in ["w:tblStyle", "w:rStyle", "w:pStyle"] {
2198            for tag in crate::xml::extract_balanced_tag_slices(&part.content, name) {
2199                if let Some(id) = get_attribute(tag, "w:val") {
2200                    referenced.insert(id);
2201                }
2202            }
2203        }
2204    }
2205    referenced
2206}
2207
2208fn parse_referenced_style_sheet(
2209    pkg: &OoxmlPackage,
2210    mut referenced: HashSet<String>,
2211) -> ParsedStyleSheet {
2212    if let Some(part) = crate::package::related_document_part(pkg, "styles") {
2213        let mut parents = HashMap::new();
2214        for style in crate::xml::extract_balanced_tag_slices(&part.content, "w:style") {
2215            let tag = super::scan::find_tag_token_ref(style, "w:style").unwrap_or("");
2216            let Some(id) = get_attribute(tag, "w:styleId") else {
2217                continue;
2218            };
2219            if let Some(parent) =
2220                super::scan::find_attribute_value_in_tag(style, "w:basedOn", "w:val")
2221            {
2222                parents.insert(id.clone(), parent);
2223            }
2224            let kind = parse_style_type(get_attribute(tag, "w:type").as_deref());
2225            if kind == Some(RawStyleType::Paragraph)
2226                || (kind == Some(RawStyleType::Table)
2227                    && get_attribute(tag, "w:default")
2228                        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "on")))
2229            {
2230                referenced.insert(id);
2231            }
2232        }
2233        let mut pending: Vec<_> = referenced.iter().cloned().collect();
2234        while let Some(id) = pending.pop() {
2235            if let Some(parent) = parents.get(&id) {
2236                if referenced.insert(parent.clone()) {
2237                    pending.push(parent.clone());
2238                }
2239            }
2240        }
2241    }
2242    parse_style_sheet_selected(pkg, Some(&referenced))
2243}
2244
2245#[cfg(not(target_arch = "wasm32"))]
2246pub(crate) type DocumentStyleSheet = std::sync::Arc<ParsedStyleSheet>;
2247#[cfg(target_arch = "wasm32")]
2248pub(crate) type DocumentStyleSheet = ParsedStyleSheet;
2249
2250#[cfg(not(target_arch = "wasm32"))]
2251struct CachedDocumentStyleSheet {
2252    styles: Option<String>,
2253    theme: Option<String>,
2254    references: Vec<String>,
2255    source_bytes: usize,
2256    parsed: std::sync::Arc<ParsedStyleSheet>,
2257}
2258
2259#[cfg(not(target_arch = "wasm32"))]
2260static DOCUMENT_STYLE_CACHE: std::sync::LazyLock<
2261    super::cache::Snapshot<std::collections::VecDeque<std::sync::Arc<CachedDocumentStyleSheet>>>,
2262> = std::sync::LazyLock::new(|| super::cache::Snapshot::new(std::collections::VecDeque::new()));
2263
2264#[cfg(not(target_arch = "wasm32"))]
2265pub(crate) fn cached_document_style_identity(styles: &ParsedStyleSheet) -> Option<DocumentStyleSheet> {
2266    DOCUMENT_STYLE_CACHE.read(|cache| cache.iter()
2267        .find(|entry| std::ptr::eq(entry.parsed.as_ref(), styles))
2268        .map(|entry| std::sync::Arc::clone(&entry.parsed)))
2269}
2270
2271pub(crate) fn acquire_document_style_sheet(pkg: &OoxmlPackage) -> DocumentStyleSheet {
2272    #[cfg(target_arch = "wasm32")]
2273    {
2274        parse_document_style_sheet(pkg)
2275    }
2276    #[cfg(not(target_arch = "wasm32"))]
2277    {
2278        let styles =
2279            crate::package::related_document_part(pkg, "styles").map(|part| part.content.as_str());
2280        let theme =
2281            crate::package::related_document_part(pkg, "theme").map(|part| part.content.as_str());
2282        if styles
2283            .map_or(0, str::len)
2284            .saturating_add(theme.map_or(0, str::len))
2285            > 512 * 1024
2286        {
2287            return std::sync::Arc::new(parse_document_style_sheet(pkg));
2288        }
2289        let references = document_style_references(pkg);
2290        if references.len() > 512 || references.iter().map(String::len).sum::<usize>() > 32 * 1024 {
2291            return std::sync::Arc::new(parse_referenced_style_sheet(pkg, references));
2292        }
2293        let mut references: Vec<_> = references.into_iter().collect();
2294        references.sort_unstable();
2295        let source_bytes = styles.map_or(0, str::len)
2296            + theme.map_or(0, str::len)
2297            + references.iter().map(String::len).sum::<usize>();
2298        let matches = |entry: &CachedDocumentStyleSheet| {
2299            entry.styles.as_deref() == styles
2300                && entry.theme.as_deref() == theme
2301                && entry.references == references
2302        };
2303        if let Some(parsed) = DOCUMENT_STYLE_CACHE.read(|cache| cache.iter()
2304            .find(|entry| matches(entry)).map(|entry| entry.parsed.clone())) {
2305            return parsed;
2306        }
2307        let parsed = std::sync::Arc::new(parse_referenced_style_sheet(
2308            pkg, references.iter().cloned().collect(),
2309        ));
2310        if source_bytes > 512 * 1024 { return parsed; }
2311        let entry = std::sync::Arc::new(CachedDocumentStyleSheet {
2312            styles: styles.map(str::to_owned),
2313            theme: theme.map(str::to_owned),
2314            references,
2315            source_bytes,
2316            parsed: parsed.clone(),
2317        });
2318        DOCUMENT_STYLE_CACHE.publish(move |cache| {
2319            if cache.iter().any(|existing| existing.styles == entry.styles
2320                && existing.theme == entry.theme && existing.references == entry.references) {
2321                return cache.clone();
2322            }
2323            let mut next = cache.clone();
2324            let mut retained = next.iter().map(|entry| entry.source_bytes).sum::<usize>();
2325            while next.len() >= 4 || retained + entry.source_bytes > 512 * 1024 {
2326                let Some(old) = next.pop_front() else { break };
2327                retained -= old.source_bytes;
2328            }
2329            next.push_back(entry);
2330            next
2331        });
2332        parsed
2333    }
2334}
2335
2336pub(crate) fn release_document_style_metadata(
2337    styles: DocumentStyleSheet,
2338) -> (Vec<crate::model::ParagraphStyleDefinition>, Option<String>) {
2339    #[cfg(not(target_arch = "wasm32"))]
2340    let styles = match std::sync::Arc::try_unwrap(styles) {
2341        Ok(styles) => styles,
2342        Err(styles) => {
2343            return (
2344                styles.paragraph_styles.clone(),
2345                styles.default_paragraph_style_id.clone(),
2346            )
2347        }
2348    };
2349    (styles.paragraph_styles, styles.default_paragraph_style_id)
2350}
2351
2352fn parse_style_sheet_selected(
2353    pkg: &OoxmlPackage,
2354    selected_styles: Option<&HashSet<String>>,
2355) -> ParsedStyleSheet {
2356    let Some(styles_xml) =
2357        crate::package::related_document_part(pkg, "styles").map(|part| part.content.as_str())
2358    else {
2359        return empty_style_sheet();
2360    };
2361
2362    let theme_fonts = parse_theme_fonts(pkg);
2363    let theme_colors = parse_theme_colors(pkg);
2364    let declared_doc_defaults_xml =
2365        crate::xml::extract_balanced_tag_slices(styles_xml, "w:docDefaults")
2366            .into_iter()
2367            .next()
2368            .unwrap_or_default();
2369    let declares_run_defaults =
2370        super::scan::contains_tag(&declared_doc_defaults_xml, "w:rPrDefault");
2371    let doc_defaults_xml = with_word_application_defaults(&declared_doc_defaults_xml, &theme_fonts);
2372
2373    let default_paragraph_style = {
2374        let paragraph_defaults = resolve_style_properties_block(&doc_defaults_xml, "w:pPr");
2375        let default_paragraph_has_num_pr =
2376            super::scan::contains_tag(&paragraph_defaults, "w:numPr");
2377        let align = parse_paragraph_align_from_xml(&paragraph_defaults);
2378        let snap_to_grid = parse_on_off_attribute(&paragraph_defaults, "snapToGrid");
2379        let text_alignment = super::style::parse_paragraph_text_alignment_from_xml(
2380            direct_table_property(&doc_defaults_xml, "w:pPrDefault")
2381                .and_then(|xml| direct_table_property(xml, "w:pPr"))
2382                .unwrap_or_default(),
2383        );
2384        let spacing = parse_paragraph_spacing_from_xml(&paragraph_defaults);
2385        let indent = parse_paragraph_indent_from_xml(&paragraph_defaults);
2386        let background_color = parse_paragraph_shading_from_xml(&paragraph_defaults);
2387        let borders = parse_paragraph_border_set_from_xml(&paragraph_defaults);
2388        let tab_stops = parse_paragraph_tab_stops_from_xml(&paragraph_defaults);
2389        let parsed_default_numbering = parse_paragraph_numbering_from_xml(&paragraph_defaults);
2390        let numbering = if default_paragraph_has_num_pr && parsed_default_numbering.is_none() {
2391            Some(ParagraphNumbering { num_id: 0, ilvl: 0 })
2392        } else {
2393            parsed_default_numbering
2394        };
2395        let contextual_spacing = parse_on_off_attribute(&paragraph_defaults, "contextualSpacing");
2396        let keep_next = parse_on_off_attribute(&paragraph_defaults, "keepNext");
2397        let keep_lines = parse_on_off_attribute(&paragraph_defaults, "keepLines");
2398        let widow_control = parse_on_off_attribute(&paragraph_defaults, "widowControl");
2399        let page_break_before = parse_on_off_attribute(&paragraph_defaults, "pageBreakBefore");
2400
2401        if align.is_none()
2402            && snap_to_grid.is_none()
2403            && text_alignment.is_none()
2404            && spacing.is_none()
2405            && indent.is_none()
2406            && background_color.is_none()
2407            && borders.is_none()
2408            && tab_stops.is_empty()
2409            && numbering.is_none()
2410            && contextual_spacing.is_none()
2411            && keep_next.is_none()
2412            && keep_lines.is_none()
2413            && widow_control.is_none()
2414            && page_break_before.is_none()
2415        {
2416            None
2417        } else {
2418            Some(ParagraphStyle {
2419                source_before_auto_spacing: None,
2420                source_after_auto_spacing: None,
2421                align,
2422                snap_to_grid,
2423                source_snap_to_grid: None,
2424                source_has_snap_to_grid: None,
2425                source_inherited_snap_to_grid: None,
2426                source_table_snap_to_grid: None,
2427                text_alignment,
2428                source_text_alignment: None,
2429                source_has_text_alignment: None,
2430                source_inherited_text_alignment: None,
2431                source_table_text_alignment: None,
2432                heading_level: None,
2433                style_id: None,
2434                style_name: None,
2435                numbering,
2436                spacing,
2437                indent,
2438                background_color,
2439                borders,
2440                tab_stops: if tab_stops.is_empty() {
2441                    None
2442                } else {
2443                    Some(tab_stops)
2444                },
2445                contextual_spacing,
2446                keep_next,
2447                keep_lines,
2448                widow_control,
2449                page_break_before,
2450                drop_cap: None,
2451            })
2452        }
2453    };
2454
2455    let mut default_run_style = parse_text_style_from_xml(
2456        &resolve_style_properties_block(&doc_defaults_xml, "w:rPr"),
2457        &theme_fonts,
2458    );
2459    if declares_run_defaults {
2460        // Declared run defaults without a size render at Word's 10 pt default.
2461        match default_run_style.as_mut() {
2462            Some(style) => {
2463                if style.font_size_pt.is_none() {
2464                    style.font_size_pt = Some(WORD_DECLARED_DEFAULT_FONT_SIZE_PT);
2465                }
2466                if style.font_size_cs_pt.is_none() {
2467                    style.font_size_cs_pt = Some(WORD_DECLARED_DEFAULT_FONT_SIZE_PT);
2468                }
2469            }
2470            None => {
2471                default_run_style = parse_text_style_from_xml(
2472                    r#"<w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/></w:rPr>"#,
2473                    &theme_fonts,
2474                );
2475            }
2476        }
2477    }
2478
2479    let mut raw_styles_by_id = HashMap::new();
2480    let mut raw_table_styles_by_id = HashMap::new();
2481    let mut default_table_style_id = None;
2482
2483    for style_xml in crate::xml::extract_balanced_tag_slices(styles_xml, "w:style") {
2484        let style_tag = super::scan::find_tag_token_ref(&style_xml, "w:style").unwrap_or_default();
2485        let style_id = get_attribute(&style_tag, "w:styleId");
2486        let style_type = parse_style_type(get_attribute(&style_tag, "w:type").as_deref());
2487        let (Some(style_id), Some(style_type)) = (style_id, style_type) else {
2488            continue;
2489        };
2490        if selected_styles.is_some_and(|ids| !ids.contains(&style_id)) {
2491            continue;
2492        }
2493
2494        let name_tag = super::scan::find_tag_token_ref(&style_xml, "w:name").unwrap_or_default();
2495        let based_on_tag =
2496            super::scan::find_tag_token_ref(&style_xml, "w:basedOn").unwrap_or_default();
2497        let next_tag = super::scan::find_tag_token_ref(&style_xml, "w:next").unwrap_or_default();
2498        let ui_priority_tag =
2499            super::scan::find_tag_token_ref(&style_xml, "w:uiPriority").unwrap_or_default();
2500        let paragraph_properties_xml = resolve_style_properties_block(&style_xml, "w:pPr");
2501        let style_has_paragraph_num_pr =
2502            super::scan::contains_tag(&paragraph_properties_xml, "w:numPr");
2503        let run_properties_xml = resolve_style_properties_block(&style_xml, "w:rPr");
2504
2505        let heading_level = normalize_heading_level(Some(&style_id))
2506            .or_else(|| normalize_heading_level(get_attribute(&name_tag, "w:val").as_deref()));
2507        let outline_heading = parse_heading_level_from_outline(&paragraph_properties_xml);
2508
2509        let parsed_style_numbering = parse_paragraph_numbering_from_xml(&paragraph_properties_xml);
2510        let tab_stops = parse_paragraph_tab_stops_from_xml(&paragraph_properties_xml);
2511
2512        raw_styles_by_id.insert(
2513            style_id.clone(),
2514            RawStyleDefinition {
2515                id: style_id.clone(),
2516                style_type,
2517                name: get_attribute(&name_tag, "w:val").unwrap_or(style_id.clone()),
2518                based_on_id: get_attribute(&based_on_tag, "w:val"),
2519                next_style_id: get_attribute(&next_tag, "w:val"),
2520                align: parse_paragraph_align_from_xml(&paragraph_properties_xml),
2521                snap_to_grid: parse_on_off_attribute(&paragraph_properties_xml, "snapToGrid"),
2522                text_alignment: super::style::parse_paragraph_text_alignment_from_xml(
2523                    direct_table_property(&style_xml, "w:pPr").unwrap_or_default(),
2524                ),
2525                heading_level,
2526                outline_heading,
2527                numbering: if style_has_paragraph_num_pr && parsed_style_numbering.is_none() {
2528                    Some(ParagraphNumbering { num_id: 0, ilvl: 0 })
2529                } else {
2530                    parsed_style_numbering
2531                },
2532                spacing: parse_paragraph_spacing_from_xml(&paragraph_properties_xml),
2533                indent: parse_paragraph_indent_from_xml(&paragraph_properties_xml),
2534                background_color: parse_paragraph_shading_from_xml(&paragraph_properties_xml),
2535                borders: parse_paragraph_border_set_from_xml(&paragraph_properties_xml),
2536                tab_stops: if tab_stops.is_empty() {
2537                    None
2538                } else {
2539                    Some(tab_stops)
2540                },
2541                contextual_spacing: parse_on_off_attribute(
2542                    &paragraph_properties_xml,
2543                    "contextualSpacing",
2544                ),
2545                keep_next: parse_on_off_attribute(&paragraph_properties_xml, "keepNext"),
2546                keep_lines: parse_on_off_attribute(&paragraph_properties_xml, "keepLines"),
2547                widow_control: parse_on_off_attribute(&paragraph_properties_xml, "widowControl"),
2548                page_break_before: parse_on_off_attribute(
2549                    &paragraph_properties_xml,
2550                    "pageBreakBefore",
2551                ),
2552                run_style: parse_text_style_from_xml(&run_properties_xml, &theme_fonts),
2553                ui_priority: get_attribute(&ui_priority_tag, "w:val")
2554                    .and_then(|raw| raw.parse::<f64>().ok())
2555                    .filter(|value| value.is_finite())
2556                    .map(|value| value.round() as i64),
2557                is_default: get_attribute(&style_tag, "w:default")
2558                    .is_some_and(|value| value == "1")
2559                    .then_some(true),
2560                is_primary: super::scan::contains_tag(&style_xml, "w:qFormat").then_some(true),
2561            },
2562        );
2563
2564        if style_type == RawStyleType::Table
2565            && selected_styles.is_none_or(|ids| ids.contains(&style_id))
2566        {
2567            if get_attribute(&style_tag, "w:default")
2568                .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "on"))
2569            {
2570                default_table_style_id = Some(style_id.clone());
2571            }
2572            let mut conditions = HashMap::new();
2573            let whole_table_source_xml = strip_table_conditional_style_blocks(&style_xml);
2574            if let Some(whole_table_condition) =
2575                parse_table_conditional_style_from_xml(&whole_table_source_xml, &theme_fonts)
2576            {
2577                conditions.insert(TableConditionalStyleType::WholeTable, whole_table_condition);
2578            }
2579
2580            for conditional_style_xml in
2581                crate::xml::extract_balanced_tag_slices(&style_xml, "w:tblStylePr")
2582            {
2583                let conditional_style_tag =
2584                    super::scan::find_tag_token_ref(&conditional_style_xml, "w:tblStylePr")
2585                        .unwrap_or_default();
2586                let Some(conditional_type) = normalize_table_conditional_style_type(
2587                    get_attribute(&conditional_style_tag, "w:type").as_deref(),
2588                ) else {
2589                    continue;
2590                };
2591                if let Some(condition) =
2592                    parse_table_conditional_style_from_xml(&conditional_style_xml, &theme_fonts)
2593                {
2594                    conditions.insert(conditional_type, condition);
2595                }
2596            }
2597
2598            raw_table_styles_by_id.insert(
2599                style_id.clone(),
2600                ParsedTableStyleDefinition {
2601                    id: style_id.clone(),
2602                    based_on_id: get_attribute(&based_on_tag, "w:val"),
2603                    name: get_attribute(&name_tag, "w:val").unwrap_or(style_id),
2604                    conditions,
2605                    floating: None,
2606                    properties: None,
2607                    has_row_cell_spacing: crate::xml::extract_balanced_tag_slices(
2608                        &style_xml, "w:trPr",
2609                    )
2610                    .into_iter()
2611                    .any(|properties| {
2612                        direct_table_property(properties, "w:tblCellSpacing").is_some()
2613                    }),
2614                },
2615            );
2616        }
2617    }
2618
2619    let mut run_style_cache = HashMap::new();
2620    fn resolve_run_style(
2621        style_id: &str,
2622        raw_styles_by_id: &HashMap<String, RawStyleDefinition>,
2623        run_style_cache: &mut HashMap<String, Option<TextStyle>>,
2624        stack: &mut HashSet<String>,
2625    ) -> Option<TextStyle> {
2626        if let Some(cached) = run_style_cache.get(style_id) {
2627            return cached.to_owned();
2628        }
2629        if stack.contains(style_id) {
2630            return None;
2631        }
2632        stack.insert(style_id.to_string());
2633
2634        let style = raw_styles_by_id.get(style_id)?;
2635        let inherited = style.based_on_id.as_deref().and_then(|based_on_id| {
2636            resolve_run_style(based_on_id, raw_styles_by_id, run_style_cache, stack)
2637        });
2638        let resolved = merge_text_styles(inherited, style.run_style.clone());
2639        run_style_cache.insert(style_id.to_string(), resolved.clone());
2640        stack.remove(style_id);
2641        resolved
2642    }
2643
2644    let mut paragraph_style_cache = HashMap::new();
2645    fn resolve_paragraph_style(
2646        style_id: &str,
2647        raw_styles_by_id: &HashMap<String, RawStyleDefinition>,
2648        paragraph_style_cache: &mut HashMap<String, Option<ParagraphStyleDefinition>>,
2649        stack: &mut HashSet<String>,
2650    ) -> Option<ParagraphStyleDefinition> {
2651        if let Some(cached) = paragraph_style_cache.get(style_id) {
2652            return cached.to_owned();
2653        }
2654        if stack.contains(style_id) {
2655            return None;
2656        }
2657        stack.insert(style_id.to_string());
2658
2659        let style = raw_styles_by_id.get(style_id)?;
2660        if style.style_type != RawStyleType::Paragraph {
2661            paragraph_style_cache.insert(style_id.to_string(), None);
2662            stack.remove(style_id);
2663            return None;
2664        }
2665
2666        let inherited = style.based_on_id.as_deref().and_then(|based_on_id| {
2667            raw_styles_by_id
2668                .get(based_on_id)
2669                .filter(|parent| parent.style_type == RawStyleType::Paragraph)
2670                .and_then(|_| {
2671                    resolve_paragraph_style(
2672                        based_on_id,
2673                        raw_styles_by_id,
2674                        paragraph_style_cache,
2675                        stack,
2676                    )
2677                })
2678        });
2679
2680        let mut outline_style = Some(style);
2681        let mut outline_seen = HashSet::new();
2682        let mut outline_heading = None;
2683        while let Some(current) = outline_style {
2684            if !outline_seen.insert(current.id.as_str()) {
2685                break;
2686            }
2687            if current.outline_heading.is_some() {
2688                outline_heading = current.outline_heading;
2689                break;
2690            }
2691            outline_style = current
2692                .based_on_id
2693                .as_deref()
2694                .and_then(|id| raw_styles_by_id.get(id))
2695                .filter(|parent| parent.style_type == RawStyleType::Paragraph);
2696        }
2697
2698        let resolved = ParagraphStyleDefinition {
2699            id: style.id.clone(),
2700            name: style.name.clone(),
2701            based_on_id: style.based_on_id.clone(),
2702            next_style_id: style.next_style_id.clone(),
2703            align: style
2704                .align
2705                .or(inherited.as_ref().and_then(|value| value.align)),
2706            snap_to_grid: style
2707                .snap_to_grid
2708                .or(inherited.as_ref().and_then(|value| value.snap_to_grid)),
2709            source_has_snap_to_grid: Some(
2710                style
2711                    .snap_to_grid
2712                    .or(inherited.as_ref().and_then(|value| value.snap_to_grid))
2713                    .is_some(),
2714            ),
2715            text_alignment: style
2716                .text_alignment
2717                .or(inherited.as_ref().and_then(|value| value.text_alignment)),
2718            source_has_text_alignment: Some(
2719                style
2720                    .text_alignment
2721                    .or(inherited.as_ref().and_then(|value| value.text_alignment))
2722                    .is_some(),
2723            ),
2724            heading_level: outline_heading.unwrap_or_else(|| {
2725                style
2726                    .heading_level
2727                    .or(inherited.as_ref().and_then(|value| value.heading_level))
2728            }),
2729            numbering: style
2730                .numbering
2731                .clone()
2732                .or_else(|| inherited.as_ref().and_then(|value| value.numbering.clone())),
2733            spacing: merge_paragraph_spacing(
2734                inherited.as_ref().and_then(|value| value.spacing.as_ref()),
2735                style.spacing.as_ref(),
2736            ),
2737            indent: merge_paragraph_indent(
2738                inherited.as_ref().and_then(|value| value.indent.as_ref()),
2739                style.indent.as_ref(),
2740            ),
2741            background_color: merge_paragraph_background_color(
2742                inherited
2743                    .as_ref()
2744                    .and_then(|value| value.background_color.as_deref()),
2745                style.background_color.as_deref(),
2746            ),
2747            borders: merge_paragraph_border_sets(
2748                inherited.as_ref().and_then(|value| value.borders.as_ref()),
2749                style.borders.as_ref(),
2750            ),
2751            tab_stops: merge_paragraph_tab_stops(
2752                inherited
2753                    .as_ref()
2754                    .and_then(|value| value.tab_stops.as_deref()),
2755                style.tab_stops.as_deref(),
2756            ),
2757            contextual_spacing: merge_paragraph_boolean(
2758                inherited
2759                    .as_ref()
2760                    .and_then(|value| value.contextual_spacing),
2761                style.contextual_spacing,
2762            ),
2763            keep_next: merge_paragraph_boolean(
2764                inherited.as_ref().and_then(|value| value.keep_next),
2765                style.keep_next,
2766            ),
2767            keep_lines: merge_paragraph_boolean(
2768                inherited.as_ref().and_then(|value| value.keep_lines),
2769                style.keep_lines,
2770            ),
2771            widow_control: merge_paragraph_boolean(
2772                inherited.as_ref().and_then(|value| value.widow_control),
2773                style.widow_control,
2774            ),
2775            page_break_before: merge_paragraph_boolean(
2776                inherited.as_ref().and_then(|value| value.page_break_before),
2777                style.page_break_before,
2778            ),
2779            run_style: merge_text_styles(
2780                inherited.as_ref().and_then(|value| value.run_style.clone()),
2781                style.run_style.clone(),
2782            ),
2783            ui_priority: style
2784                .ui_priority
2785                .or_else(|| inherited.as_ref().and_then(|value| value.ui_priority)),
2786            is_default: style.is_default,
2787            is_primary: style.is_primary,
2788        };
2789
2790        paragraph_style_cache.insert(style_id.to_string(), Some(resolved.clone()));
2791        stack.remove(style_id);
2792        Some(resolved)
2793    }
2794
2795    let mut paragraph_styles: Vec<ParagraphStyleDefinition> = raw_styles_by_id
2796        .values()
2797        .filter(|style| style.style_type == RawStyleType::Paragraph)
2798        .filter_map(|style| {
2799            let mut stack = HashSet::new();
2800            resolve_paragraph_style(
2801                &style.id,
2802                &raw_styles_by_id,
2803                &mut paragraph_style_cache,
2804                &mut stack,
2805            )
2806        })
2807        .collect();
2808
2809    for style in &mut paragraph_styles {
2810        style.snap_to_grid = style.snap_to_grid.or(default_paragraph_style
2811            .as_ref()
2812            .and_then(|style| style.snap_to_grid));
2813        style.text_alignment = style.text_alignment.or(default_paragraph_style
2814            .as_ref()
2815            .and_then(|style| style.text_alignment));
2816    }
2817
2818    paragraph_styles.sort_by(|left, right| {
2819        let left_priority = left.ui_priority.unwrap_or(9999);
2820        let right_priority = right.ui_priority.unwrap_or(9999);
2821        if left_priority != right_priority {
2822            left_priority.cmp(&right_priority)
2823        } else {
2824            left.name.cmp(&right.name)
2825        }
2826    });
2827
2828    let paragraph_style_by_id = paragraph_styles
2829        .iter()
2830        .map(|style| (style.id.clone(), style.clone()))
2831        .collect::<HashMap<_, _>>();
2832
2833    let mut run_style_by_id = HashMap::new();
2834    for style_id in raw_styles_by_id.keys() {
2835        let mut stack = HashSet::new();
2836        if let Some(resolved) = resolve_run_style(
2837            style_id,
2838            &raw_styles_by_id,
2839            &mut run_style_cache,
2840            &mut stack,
2841        ) {
2842            run_style_by_id.insert(style_id.clone(), resolved);
2843        }
2844    }
2845
2846    let mut table_style_cache = HashMap::new();
2847    fn resolve_table_style(
2848        style_id: &str,
2849        raw_table_styles_by_id: &HashMap<String, ParsedTableStyleDefinition>,
2850        table_style_cache: &mut HashMap<String, Option<ParsedTableStyleDefinition>>,
2851        stack: &mut HashSet<String>,
2852    ) -> Option<ParsedTableStyleDefinition> {
2853        if let Some(cached) = table_style_cache.get(style_id) {
2854            return cached.to_owned();
2855        }
2856        if stack.contains(style_id) {
2857            return None;
2858        }
2859        stack.insert(style_id.to_string());
2860
2861        let style = raw_table_styles_by_id.get(style_id)?;
2862        let inherited = style.based_on_id.as_deref().and_then(|based_on_id| {
2863            resolve_table_style(
2864                based_on_id,
2865                raw_table_styles_by_id,
2866                table_style_cache,
2867                stack,
2868            )
2869        });
2870
2871        let mut conditions = HashMap::new();
2872        for conditional_type in TableConditionalStyleType::ALL {
2873            if let Some(merged_condition) = merge_table_conditional_style(
2874                inherited
2875                    .as_ref()
2876                    .and_then(|value| value.conditions.get(&conditional_type)),
2877                style.conditions.get(&conditional_type),
2878            ) {
2879                conditions.insert(conditional_type, merged_condition);
2880            }
2881        }
2882
2883        let resolved = ParsedTableStyleDefinition {
2884            id: style.id.clone(),
2885            based_on_id: style.based_on_id.clone(),
2886            name: style.name.clone(),
2887            conditions,
2888            floating: style.floating.clone(),
2889            properties: style.properties.clone(),
2890            has_row_cell_spacing: style.has_row_cell_spacing
2891                || inherited
2892                    .as_ref()
2893                    .is_some_and(|value| value.has_row_cell_spacing),
2894        };
2895
2896        table_style_cache.insert(style_id.to_string(), Some(resolved.clone()));
2897        stack.remove(style_id);
2898        Some(resolved)
2899    }
2900
2901    let mut table_style_by_id = HashMap::new();
2902    for style_id in raw_table_styles_by_id.keys() {
2903        let mut stack = HashSet::new();
2904        if let Some(resolved) = resolve_table_style(
2905            style_id,
2906            &raw_table_styles_by_id,
2907            &mut table_style_cache,
2908            &mut stack,
2909        ) {
2910            table_style_by_id.insert(style_id.clone(), resolved);
2911        }
2912    }
2913
2914    let default_paragraph_style_id = paragraph_styles
2915        .iter()
2916        .find(|style| style.is_default == Some(true))
2917        .map(|style| style.id.clone())
2918        .or_else(|| {
2919            if paragraph_style_by_id.contains_key("Normal") {
2920                Some("Normal".to_string())
2921            } else {
2922                None
2923            }
2924        });
2925
2926    let resolved_default_paragraph_style = default_paragraph_style_id
2927        .as_ref()
2928        .and_then(|style_id| paragraph_style_by_id.get(style_id).cloned());
2929
2930    let merged_default_paragraph_style =
2931        resolved_default_paragraph_style
2932            .as_ref()
2933            .map(|resolved| ParagraphStyle {
2934                source_before_auto_spacing: None,
2935                source_after_auto_spacing: None,
2936                align: resolved.align.or(default_paragraph_style
2937                    .as_ref()
2938                    .and_then(|value| value.align)),
2939                snap_to_grid: resolved.snap_to_grid.or(default_paragraph_style
2940                    .as_ref()
2941                    .and_then(|value| value.snap_to_grid)),
2942                source_snap_to_grid: None,
2943                source_has_snap_to_grid: None,
2944                source_inherited_snap_to_grid: None,
2945                source_table_snap_to_grid: None,
2946                text_alignment: resolved.text_alignment.or(default_paragraph_style
2947                    .as_ref()
2948                    .and_then(|value| value.text_alignment)),
2949                source_text_alignment: None,
2950                source_has_text_alignment: None,
2951                source_inherited_text_alignment: None,
2952                source_table_text_alignment: None,
2953                heading_level: resolved.heading_level,
2954                style_id: default_paragraph_style_id.clone(),
2955                style_name: Some(resolved.name.clone()),
2956                numbering: resolved.numbering.clone().or_else(|| {
2957                    default_paragraph_style
2958                        .as_ref()
2959                        .and_then(|value| value.numbering.clone())
2960                }),
2961                spacing: merge_paragraph_spacing(
2962                    default_paragraph_style
2963                        .as_ref()
2964                        .and_then(|value| value.spacing.as_ref()),
2965                    resolved.spacing.as_ref(),
2966                ),
2967                indent: merge_paragraph_indent(
2968                    default_paragraph_style
2969                        .as_ref()
2970                        .and_then(|value| value.indent.as_ref()),
2971                    resolved.indent.as_ref(),
2972                ),
2973                background_color: merge_paragraph_background_color(
2974                    default_paragraph_style
2975                        .as_ref()
2976                        .and_then(|value| value.background_color.as_deref()),
2977                    resolved.background_color.as_deref(),
2978                ),
2979                borders: merge_paragraph_border_sets(
2980                    default_paragraph_style
2981                        .as_ref()
2982                        .and_then(|value| value.borders.as_ref()),
2983                    resolved.borders.as_ref(),
2984                ),
2985                tab_stops: merge_paragraph_tab_stops(
2986                    default_paragraph_style
2987                        .as_ref()
2988                        .and_then(|value| value.tab_stops.as_deref()),
2989                    resolved.tab_stops.as_deref(),
2990                ),
2991                contextual_spacing: merge_paragraph_boolean(
2992                    default_paragraph_style
2993                        .as_ref()
2994                        .and_then(|value| value.contextual_spacing),
2995                    resolved.contextual_spacing,
2996                ),
2997                keep_next: merge_paragraph_boolean(
2998                    default_paragraph_style
2999                        .as_ref()
3000                        .and_then(|value| value.keep_next),
3001                    resolved.keep_next,
3002                ),
3003                keep_lines: merge_paragraph_boolean(
3004                    default_paragraph_style
3005                        .as_ref()
3006                        .and_then(|value| value.keep_lines),
3007                    resolved.keep_lines,
3008                ),
3009                widow_control: merge_paragraph_boolean(
3010                    default_paragraph_style
3011                        .as_ref()
3012                        .and_then(|value| value.widow_control),
3013                    resolved.widow_control,
3014                ),
3015                page_break_before: merge_paragraph_boolean(
3016                    default_paragraph_style
3017                        .as_ref()
3018                        .and_then(|value| value.page_break_before),
3019                    resolved.page_break_before,
3020                ),
3021                drop_cap: None,
3022            });
3023
3024    let mut table_paragraph_spacing_by_style_id = HashMap::new();
3025    for (style_id, _) in raw_styles_by_id
3026        .iter()
3027        .filter(|(_, style)| style.style_type == RawStyleType::Table)
3028    {
3029        let mut chain = Vec::new();
3030        let mut cursor = Some(style_id.clone());
3031        let mut seen = HashSet::new();
3032        while let Some(id) = cursor {
3033            if !seen.insert(id.clone()) {
3034                break;
3035            }
3036            let Some(chain_style) = raw_styles_by_id.get(&id) else {
3037                break;
3038            };
3039            if chain_style.style_type != RawStyleType::Table {
3040                break;
3041            }
3042            chain.push(chain_style);
3043            cursor = chain_style.based_on_id.clone();
3044        }
3045        let mut merged_spacing: Option<crate::model::ParagraphSpacing> = None;
3046        for chain_style in chain.iter().rev() {
3047            merged_spacing = crate::parse::util::merge_paragraph_spacing(
3048                merged_spacing.as_ref(),
3049                chain_style.spacing.as_ref(),
3050            );
3051        }
3052        if let Some(spacing) = merged_spacing {
3053            table_paragraph_spacing_by_style_id.insert(style_id.clone(), spacing);
3054        }
3055    }
3056
3057    ParsedStyleSheet {
3058        paragraph_styles,
3059        paragraph_style_by_id,
3060        run_style_by_id,
3061        table_style_by_id,
3062        default_table_style_id,
3063        table_paragraph_spacing_by_style_id,
3064        doc_default_paragraph_spacing: default_paragraph_style
3065            .as_ref()
3066            .and_then(|style| style.spacing.clone()),
3067        default_paragraph_text_alignment: default_paragraph_style
3068            .as_ref()
3069            .and_then(|style| style.text_alignment),
3070        default_paragraph_style: merged_default_paragraph_style,
3071        default_paragraph_style_id,
3072        default_run_style: merge_text_styles(
3073            default_run_style,
3074            resolved_default_paragraph_style.and_then(|value| value.run_style),
3075        ),
3076        theme_colors,
3077        theme_fonts,
3078    }
3079}
3080
3081pub fn clone_paragraph_style_definition(
3082    style: &ParagraphStyleDefinition,
3083) -> ParagraphStyleDefinition {
3084    style.clone()
3085}
3086
3087pub fn clone_numbering_definitions(definitions: &NumberingDefinitionSet) -> NumberingDefinitionSet {
3088    definitions.clone()
3089}
3090
3091#[cfg(test)]
3092mod table_box_spacing_tests {
3093    use super::parse_table_box_spacing;
3094
3095    #[test]
3096    fn parses_start_end_aliases() {
3097        let xml =
3098            r#"<w:tcMar><w:start w:w="0" w:type="dxa"/><w:end w:w="0" w:type="dxa"/></w:tcMar>"#;
3099        let spacing = parse_table_box_spacing(xml).expect("spacing");
3100        assert_eq!(spacing.left_twips, Some(0));
3101        assert_eq!(spacing.right_twips, Some(0));
3102        assert_eq!(spacing.top_twips, None);
3103        assert_eq!(spacing.bottom_twips, None);
3104    }
3105}
3106
3107#[cfg(all(test, not(target_arch = "wasm32")))]
3108mod document_style_cache_tests {
3109    #[test]
3110    fn shared_definitions_track_references_theme_changes_and_eviction() {
3111        for selector in (0..12).chain((0..12).rev()) {
3112            let selected = if selector % 2 == 0 { "a" } else { "b" };
3113            let font = if selector % 3 == 0 { "Serif" } else { "Sans" };
3114            let bold = if selector % 4 == 0 { "0" } else { "1" };
3115            let mut package = crate::package::OoxmlPackage {
3116                parts: Default::default(),
3117                binary_assets: Default::default(),
3118                warnings: vec![],
3119            };
3120            for (name, content) in [
3121                ("word/document.xml", format!("<w:document><w:body><w:p><w:r><w:rPr><w:rStyle w:val='{selected}'/></w:rPr><w:t>text</w:t></w:r></w:p></w:body></w:document>")),
3122                ("word/styles.xml", format!("<w:styles><w:style w:type='paragraph' w:styleId='normal' w:default='1'><w:name w:val='Normal'/></w:style><w:style w:type='character' w:styleId='a'><w:rPr><w:b w:val='{bold}'/></w:rPr></w:style><w:style w:type='character' w:styleId='b'><w:rPr><w:i/></w:rPr></w:style></w:styles>")),
3123                ("word/theme/theme1.xml", format!("<a:theme><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface='{font}'/></a:majorFont><a:minorFont><a:latin typeface='{font}'/></a:minorFont></a:fontScheme></a:themeElements></a:theme>")),
3124            ] { package.parts.insert(name.into(), crate::package::OoxmlPart { name: name.into(), content }); }
3125            let expected = super::parse_document_style_sheet(&package);
3126            let actual = super::acquire_document_style_sheet(&package);
3127            assert_eq!(&*actual, &expected);
3128            let (mut metadata, _) = super::release_document_style_metadata(actual);
3129            if let Some(style) = metadata.first_mut() {
3130                style.name = "Changed".into();
3131            }
3132            assert_eq!(&*super::acquire_document_style_sheet(&package), &expected);
3133        }
3134    }
3135}
3136
3137
3138#[cfg(all(test, not(target_arch = "wasm32")))]
3139mod bounded_large_definition_tests {
3140    use super::*;
3141
3142    #[test]
3143    fn large_definitions_preserve_values_and_resident_budget() {
3144        for value in ["first", "second", "third", "first"] {
3145            let mut pkg = crate::package::OoxmlPackage {
3146                parts: Default::default(), binary_assets: Default::default(), warnings: vec![],
3147            };
3148            let name = format!("{value}{}", "x".repeat(140 * 1024));
3149            let content = format!("<w:styles><w:style w:type='paragraph' w:styleId='normal' w:default='1'><w:name w:val='{name}'/></w:style></w:styles>");
3150            pkg.parts.insert("word/styles.xml".into(), crate::package::OoxmlPart { name: "word/styles.xml".into(), content });
3151            let expected = parse_document_style_sheet(&pkg);
3152            for _ in 0..2 {
3153                assert_eq!(*acquire_document_style_sheet(&pkg), expected);
3154                DOCUMENT_STYLE_CACHE.read(|cache| {
3155                    assert!(cache.len() <= 4);
3156                    assert!(cache.iter().map(|entry| entry.source_bytes).sum::<usize>() <= 512 * 1024);
3157                });
3158            }
3159        }
3160    }
3161}