Skip to main content

rust_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        text_shadow: None,
1141        text_outline: None,
1142    };
1143
1144    for style in [inherited, direct].into_iter().flatten() {
1145        if style.bold.is_some() {
1146            merged.bold = style.bold;
1147        }
1148        if style.italic.is_some() {
1149            merged.italic = style.italic;
1150        }
1151        if style.underline.is_some() {
1152            merged.underline = style.underline;
1153        }
1154        if style.strike.is_some() {
1155            merged.strike = style.strike;
1156        }
1157        if style.all_caps.is_some() {
1158            merged.all_caps = style.all_caps;
1159        }
1160        if style.small_caps.is_some() {
1161            merged.small_caps = style.small_caps;
1162        }
1163        if style.color.is_some() {
1164            merged.color = style.color;
1165        }
1166        if style.highlight.is_some() {
1167            merged.highlight = style.highlight;
1168        }
1169        if style.background_color.is_some() {
1170            merged.background_color = style.background_color;
1171        }
1172        if style.font_size_pt.is_some() {
1173            merged.font_size_pt = style.font_size_pt;
1174        }
1175        if style.font_size_cs_pt.is_some() {
1176            merged.font_size_cs_pt = style.font_size_cs_pt;
1177        }
1178        if style.font_family.is_some() {
1179            merged.font_family = style.font_family;
1180        }
1181        if style.source_font_family.is_some() {
1182            merged.source_font_family = style.source_font_family;
1183        }
1184        if style.font_family_ascii.is_some() {
1185            merged.font_family_ascii = style.font_family_ascii;
1186        }
1187        if style.font_family_h_ansi.is_some() {
1188            merged.font_family_h_ansi = style.font_family_h_ansi;
1189        }
1190        if style.font_family_east_asia.is_some() {
1191            merged.font_family_east_asia = style.font_family_east_asia;
1192        }
1193        if style.font_family_cs.is_some() {
1194            merged.font_family_cs = style.font_family_cs;
1195        }
1196        if style.font_theme_ascii.is_some() {
1197            merged.font_theme_ascii = style.font_theme_ascii;
1198        }
1199        if style.font_theme_h_ansi.is_some() {
1200            merged.font_theme_h_ansi = style.font_theme_h_ansi;
1201        }
1202        if style.font_theme_east_asia.is_some() {
1203            merged.font_theme_east_asia = style.font_theme_east_asia;
1204        }
1205        if style.font_theme_cs.is_some() {
1206            merged.font_theme_cs = style.font_theme_cs;
1207        }
1208        if style.resolved_font_family_ascii.is_some() {
1209            merged.resolved_font_family_ascii = style.resolved_font_family_ascii;
1210        }
1211        if style.resolved_font_family_h_ansi.is_some() {
1212            merged.resolved_font_family_h_ansi = style.resolved_font_family_h_ansi;
1213        }
1214        if style.resolved_font_family_east_asia.is_some() {
1215            merged.resolved_font_family_east_asia = style.resolved_font_family_east_asia;
1216        }
1217        if style.resolved_font_family_cs.is_some() {
1218            merged.resolved_font_family_cs = style.resolved_font_family_cs;
1219        }
1220        if style.font_hint.is_some() {
1221            merged.font_hint = style.font_hint;
1222        }
1223        if style.language.is_some() {
1224            merged.language = style.language;
1225        }
1226        if style.language_east_asia.is_some() {
1227            merged.language_east_asia = style.language_east_asia;
1228        }
1229        if style.language_bidi.is_some() {
1230            merged.language_bidi = style.language_bidi;
1231        }
1232        if style.right_to_left.is_some() {
1233            merged.right_to_left = style.right_to_left;
1234        }
1235        if style.complex_script.is_some() {
1236            merged.complex_script = style.complex_script;
1237        }
1238        if style.character_scale_percent.is_some() || style.source_character_scale.is_some() {
1239            merged.character_scale_percent = style.character_scale_percent;
1240            merged.source_character_scale = style.source_character_scale;
1241        }
1242        if style.character_spacing_twips.is_some() {
1243            merged.character_spacing_twips = style.character_spacing_twips;
1244        }
1245        if style.kerning_min_font_size_pt.is_some() {
1246            merged.kerning_min_font_size_pt = style.kerning_min_font_size_pt;
1247        }
1248        if style.ligatures.is_some() {
1249            merged.ligatures = style.ligatures;
1250        }
1251        if style.contextual_alternates.is_some() {
1252            merged.contextual_alternates = style.contextual_alternates;
1253        }
1254        if style.vertical_align.is_some() {
1255            merged.vertical_align = style.vertical_align;
1256        }
1257        if style.run_border.is_some() {
1258            merged.run_border = style.run_border;
1259        }
1260        if style.text_shadow.is_some() {
1261            merged.text_shadow = style.text_shadow;
1262        }
1263        if style.text_outline.is_some() {
1264            merged.text_outline = style.text_outline;
1265        }
1266    }
1267
1268    let has_any = merged.bold.is_some()
1269        || merged.italic.is_some()
1270        || merged.underline.is_some()
1271        || merged.strike.is_some()
1272        || merged.all_caps.is_some()
1273        || merged.small_caps.is_some()
1274        || merged.color.is_some()
1275        || merged.highlight.is_some()
1276        || merged.background_color.is_some()
1277        || merged.font_size_pt.is_some()
1278        || merged.font_size_cs_pt.is_some()
1279        || merged.font_family.is_some()
1280        || merged.source_font_family.is_some()
1281        || merged.font_family_ascii.is_some()
1282        || merged.font_family_h_ansi.is_some()
1283        || merged.font_family_east_asia.is_some()
1284        || merged.font_family_cs.is_some()
1285        || merged.font_theme_ascii.is_some()
1286        || merged.font_theme_h_ansi.is_some()
1287        || merged.font_theme_east_asia.is_some()
1288        || merged.font_theme_cs.is_some()
1289        || merged.resolved_font_family_ascii.is_some()
1290        || merged.resolved_font_family_h_ansi.is_some()
1291        || merged.resolved_font_family_east_asia.is_some()
1292        || merged.resolved_font_family_cs.is_some()
1293        || merged.font_hint.is_some()
1294        || merged.language.is_some()
1295        || merged.language_east_asia.is_some()
1296        || merged.language_bidi.is_some()
1297        || merged.right_to_left.is_some()
1298        || merged.complex_script.is_some()
1299        || merged.character_spacing_twips.is_some()
1300        || merged.character_scale_percent.is_some()
1301        || merged.source_character_scale.is_some()
1302        || merged.kerning_min_font_size_pt.is_some()
1303        || merged.ligatures.is_some()
1304        || merged.contextual_alternates.is_some()
1305        || merged.vertical_align.is_some()
1306        || merged.run_border.is_some()
1307        || merged.text_shadow.is_some()
1308        || merged.text_outline.is_some();
1309
1310    if has_any {
1311        Some(merged)
1312    } else {
1313        None
1314    }
1315}
1316
1317fn merge_paragraph_spacing(
1318    inherited: Option<&ParagraphSpacing>,
1319    direct: Option<&ParagraphSpacing>,
1320) -> Option<ParagraphSpacing> {
1321    if inherited.is_none() && direct.is_none() {
1322        return None;
1323    }
1324
1325    let merged = ParagraphSpacing {
1326        before_twips: direct
1327            .and_then(|value| value.before_twips)
1328            .or_else(|| inherited.and_then(|value| value.before_twips)),
1329        after_twips: direct
1330            .and_then(|value| value.after_twips)
1331            .or_else(|| inherited.and_then(|value| value.after_twips)),
1332        before_auto_spacing: direct
1333            .and_then(|value| value.before_auto_spacing)
1334            .or_else(|| inherited.and_then(|value| value.before_auto_spacing)),
1335        after_auto_spacing: direct
1336            .and_then(|value| value.after_auto_spacing)
1337            .or_else(|| inherited.and_then(|value| value.after_auto_spacing)),
1338        line_twips: direct
1339            .and_then(|value| value.line_twips)
1340            .or_else(|| inherited.and_then(|value| value.line_twips)),
1341        line_rule: direct
1342            .and_then(|value| value.line_rule)
1343            .or_else(|| inherited.and_then(|value| value.line_rule)),
1344    };
1345
1346    if merged.before_twips.is_none()
1347        && merged.after_twips.is_none()
1348        && merged.before_auto_spacing.is_none()
1349        && merged.after_auto_spacing.is_none()
1350        && merged.line_twips.is_none()
1351        && merged.line_rule.is_none()
1352    {
1353        return None;
1354    }
1355
1356    Some(merged)
1357}
1358
1359fn merge_paragraph_indent(
1360    inherited: Option<&ParagraphIndent>,
1361    direct: Option<&ParagraphIndent>,
1362) -> Option<ParagraphIndent> {
1363    if inherited.is_none() && direct.is_none() {
1364        return None;
1365    }
1366
1367    let merged = ParagraphIndent {
1368        left_twips: direct
1369            .and_then(|value| value.left_twips)
1370            .or_else(|| inherited.and_then(|value| value.left_twips)),
1371        right_twips: direct
1372            .and_then(|value| value.right_twips)
1373            .or_else(|| inherited.and_then(|value| value.right_twips)),
1374        first_line_twips: direct
1375            .and_then(|value| value.first_line_twips)
1376            .or_else(|| inherited.and_then(|value| value.first_line_twips)),
1377        hanging_twips: direct
1378            .and_then(|value| value.hanging_twips)
1379            .or_else(|| inherited.and_then(|value| value.hanging_twips)),
1380    };
1381
1382    if merged.left_twips.is_none()
1383        && merged.right_twips.is_none()
1384        && merged.first_line_twips.is_none()
1385        && merged.hanging_twips.is_none()
1386    {
1387        return None;
1388    }
1389
1390    Some(merged)
1391}
1392
1393fn merge_paragraph_background_color(
1394    inherited: Option<&str>,
1395    direct: Option<&str>,
1396) -> Option<String> {
1397    direct
1398        .map(str::to_string)
1399        .or_else(|| inherited.map(str::to_string))
1400}
1401
1402fn merge_paragraph_border_style(
1403    inherited: Option<&ParagraphBorderStyle>,
1404    direct: Option<&ParagraphBorderStyle>,
1405) -> Option<ParagraphBorderStyle> {
1406    if inherited.is_none() && direct.is_none() {
1407        return None;
1408    }
1409
1410    let border_type = direct
1411        .map(|value| value.border_type.clone())
1412        .or_else(|| inherited.map(|value| value.border_type.clone()))?;
1413    if border_type.is_empty() {
1414        return None;
1415    }
1416
1417    Some(ParagraphBorderStyle {
1418        border_type,
1419        color: direct
1420            .and_then(|value| value.color.clone())
1421            .or_else(|| inherited.and_then(|value| value.color.clone())),
1422        size_eighth_pt: direct
1423            .and_then(|value| value.size_eighth_pt)
1424            .or_else(|| inherited.and_then(|value| value.size_eighth_pt)),
1425        space_pt: direct
1426            .and_then(|value| value.space_pt)
1427            .or_else(|| inherited.and_then(|value| value.space_pt)),
1428        frame: direct
1429            .and_then(|value| value.frame)
1430            .or_else(|| inherited.and_then(|value| value.frame)),
1431        shadow: direct
1432            .and_then(|value| value.shadow)
1433            .or_else(|| inherited.and_then(|value| value.shadow)),
1434    })
1435}
1436
1437fn merge_paragraph_border_sets(
1438    inherited: Option<&ParagraphBorderSet>,
1439    direct: Option<&ParagraphBorderSet>,
1440) -> Option<ParagraphBorderSet> {
1441    if inherited.is_none() && direct.is_none() {
1442        return None;
1443    }
1444
1445    let merged = ParagraphBorderSet {
1446        top: merge_paragraph_border_style(
1447            inherited.and_then(|value| value.top.as_ref()),
1448            direct.and_then(|value| value.top.as_ref()),
1449        ),
1450        right: merge_paragraph_border_style(
1451            inherited.and_then(|value| value.right.as_ref()),
1452            direct.and_then(|value| value.right.as_ref()),
1453        ),
1454        bottom: merge_paragraph_border_style(
1455            inherited.and_then(|value| value.bottom.as_ref()),
1456            direct.and_then(|value| value.bottom.as_ref()),
1457        ),
1458        left: merge_paragraph_border_style(
1459            inherited.and_then(|value| value.left.as_ref()),
1460            direct.and_then(|value| value.left.as_ref()),
1461        ),
1462        between: merge_paragraph_border_style(
1463            inherited.and_then(|value| value.between.as_ref()),
1464            direct.and_then(|value| value.between.as_ref()),
1465        ),
1466        bar: merge_paragraph_border_style(
1467            inherited.and_then(|value| value.bar.as_ref()),
1468            direct.and_then(|value| value.bar.as_ref()),
1469        ),
1470    };
1471
1472    if merged.top.is_none()
1473        && merged.right.is_none()
1474        && merged.bottom.is_none()
1475        && merged.left.is_none()
1476        && merged.between.is_none()
1477        && merged.bar.is_none()
1478    {
1479        return None;
1480    }
1481
1482    Some(merged)
1483}
1484
1485fn merge_paragraph_tab_stops(
1486    inherited: Option<&[ParagraphTabStop]>,
1487    direct: Option<&[ParagraphTabStop]>,
1488) -> Option<Vec<ParagraphTabStop>> {
1489    if inherited.is_none() && direct.is_none() {
1490        return None;
1491    }
1492
1493    let mut by_position = HashMap::new();
1494    for stop in inherited.unwrap_or(&[]) {
1495        if let Some(position) = stop.position_twips {
1496            by_position.insert(position, stop.clone());
1497        }
1498    }
1499    for stop in direct.unwrap_or(&[]) {
1500        if let Some(position) = stop.position_twips {
1501            by_position.insert(position, stop.clone());
1502        }
1503    }
1504
1505    if by_position.is_empty() {
1506        return None;
1507    }
1508
1509    let mut combined: Vec<ParagraphTabStop> = by_position.into_values().collect();
1510    combined.sort_by_key(|stop| stop.position_twips.unwrap_or(0));
1511    Some(combined)
1512}
1513
1514fn merge_paragraph_boolean(inherited: Option<bool>, direct: Option<bool>) -> Option<bool> {
1515    if direct.is_some() {
1516        direct
1517    } else {
1518        inherited
1519    }
1520}
1521
1522fn resolve_theme_font(theme_token: Option<&str>, theme_fonts: &ThemeFontMap) -> Option<String> {
1523    let normalized = theme_token?.to_ascii_lowercase();
1524    if normalized.starts_with("major") {
1525        if normalized.contains("eastasia") {
1526            return theme_fonts
1527                .major_east_asia
1528                .clone()
1529                .or_else(|| theme_fonts.major_latin.clone());
1530        }
1531        if normalized.contains("bidi") || normalized.contains("cs") {
1532            return theme_fonts
1533                .major_complex_script
1534                .clone()
1535                .or_else(|| theme_fonts.major_latin.clone());
1536        }
1537        return theme_fonts.major_latin.clone();
1538    }
1539    if normalized.starts_with("minor") {
1540        if normalized.contains("eastasia") {
1541            return theme_fonts
1542                .minor_east_asia
1543                .clone()
1544                .or_else(|| theme_fonts.minor_latin.clone());
1545        }
1546        if normalized.contains("bidi") || normalized.contains("cs") {
1547            return theme_fonts
1548                .minor_complex_script
1549                .clone()
1550                .or_else(|| theme_fonts.minor_latin.clone());
1551        }
1552        return theme_fonts.minor_latin.clone();
1553    }
1554    None
1555}
1556
1557fn find_drawing_rpr_attribute(xml: &str, attribute: &str) -> Option<String> {
1558    let tag = super::scan::find_tag_token_ref(xml, "a:rPr")?;
1559    get_attribute(&tag, attribute)
1560}
1561
1562fn find_drawing_latin_typeface(xml: &str) -> Option<String> {
1563    if let Some(block) = crate::xml::extract_balanced_tag_slices(xml, "a:rPr")
1564        .into_iter()
1565        .next()
1566    {
1567        if let Some(latin) = crate::xml::extract_balanced_tag_slices(&block, "a:latin")
1568            .into_iter()
1569            .next()
1570            .or_else(|| super::scan::find_tag_token_ref(&block, "a:latin"))
1571        {
1572            if let Some(typeface) = get_attribute(&latin, "typeface") {
1573                return Some(typeface);
1574            }
1575        }
1576    }
1577
1578    if let Some(block) = crate::xml::extract_balanced_tag_slices(xml, "a:defRPr")
1579        .into_iter()
1580        .next()
1581    {
1582        if let Some(latin) = crate::xml::extract_balanced_tag_slices(&block, "a:latin")
1583            .into_iter()
1584            .next()
1585            .or_else(|| super::scan::find_tag_token_ref(&block, "a:latin"))
1586        {
1587            if let Some(typeface) = get_attribute(&latin, "typeface") {
1588                return Some(typeface);
1589            }
1590        }
1591    }
1592
1593    super::scan::find_tag_token_ref(xml, "a:latin")
1594        .and_then(|latin| get_attribute(&latin, "typeface"))
1595}
1596
1597fn find_drawing_color(xml: &str) -> Option<String> {
1598    if let Some(block) = crate::xml::extract_balanced_tag_slices(xml, "a:rPr")
1599        .into_iter()
1600        .next()
1601    {
1602        if let Some(color_xml) = crate::xml::extract_balanced_tag_slices(&block, "a:solidFill")
1603            .into_iter()
1604            .next()
1605            .or_else(|| super::scan::find_tag_token_ref(&block, "a:solidFill"))
1606        {
1607            if let Some(srgb) = super::scan::find_tag_token_ref(&color_xml, "a:srgbClr") {
1608                if let Some(color) = get_attribute(&srgb, "val")
1609                    .as_deref()
1610                    .and_then(|value| normalize_hex_color(Some(value)))
1611                {
1612                    return Some(color);
1613                }
1614            }
1615        }
1616        if let Some(srgb) = super::scan::find_tag_token_ref(&block, "a:srgbClr") {
1617            if let Some(color) = get_attribute(&srgb, "val")
1618                .as_deref()
1619                .and_then(|value| normalize_hex_color(Some(value)))
1620            {
1621                return Some(color);
1622            }
1623        }
1624    }
1625    None
1626}
1627
1628fn contains_east_asia_glyphs(text: &str) -> bool {
1629    text.chars().any(|ch| {
1630        matches!(ch,
1631            '\u{2e80}'..='\u{9fff}'
1632                | '\u{ac00}'..='\u{d7af}'
1633        )
1634    })
1635}
1636
1637fn contains_complex_script_glyphs(text: &str) -> bool {
1638    text.chars().any(|ch| {
1639        matches!(ch,
1640            '\u{0590}'..='\u{08ff}'
1641                | '\u{fb1d}'..='\u{fefc}'
1642        )
1643    })
1644}
1645
1646fn collect_decoded_text_samples(xml: &str) -> String {
1647    let mut samples = String::new();
1648    for tag_name in ["w:t", "a:t"] {
1649        for block in crate::xml::extract_balanced_tag_slices(xml, tag_name) {
1650            if let Some(start) = block.find('>') {
1651                let end = block.rfind("</").unwrap_or(block.len());
1652                if start + 1 < end {
1653                    samples.push_str(&decode_xml_entities(&block[start + 1..end]));
1654                }
1655            }
1656        }
1657    }
1658    samples
1659}
1660
1661/// Mirrors TypeScript `parseTextStyleFromXml`.
1662pub fn parse_text_style_from_xml(xml: &str, theme_fonts: &ThemeFontMap) -> Option<TextStyle> {
1663    super::style::cached_definition_style(xml, theme_fonts)
1664}
1665
1666pub(crate) fn parse_definition_style_uncached(
1667    xml: &str,
1668    theme_fonts: &ThemeFontMap,
1669) -> Option<TextStyle> {
1670    if xml.is_empty() {
1671        return None;
1672    }
1673
1674    let bold = parse_on_off_attribute(xml, "b");
1675    let italic = parse_on_off_attribute(xml, "i");
1676    let underline = parse_underline_attribute(xml);
1677    let strike = parse_on_off_attribute(xml, "strike");
1678    let all_caps = parse_on_off_attribute(xml, "caps");
1679    let small_caps = parse_on_off_attribute(xml, "smallCaps");
1680
1681    let color_match = super::scan::find_attribute_value_in_tag(xml, "w:color", "w:val");
1682    let highlight_match = super::scan::find_attribute_value_in_tag(xml, "w:highlight", "w:val");
1683    let shading_tag = super::scan::find_tag_token_ref(xml, "w:shd");
1684    let character_spacing_match =
1685        super::scan::find_attribute_value_in_tag(xml, "w:spacing", "w:val");
1686    let kerning_match = super::scan::find_attribute_value_in_tag(xml, "w:kern", "w:val");
1687    let size_match = super::scan::find_attribute_value_in_tag(xml, "w:sz", "w:val");
1688    let complex_size_match = super::scan::find_attribute_value_in_tag(xml, "w:szCs", "w:val");
1689    let run_fonts_tag = super::scan::find_tag_token_ref(xml, "w:rFonts").unwrap_or_default();
1690    let ascii_font = get_attribute(&run_fonts_tag, "w:ascii");
1691    let h_ansi_font = get_attribute(&run_fonts_tag, "w:hAnsi");
1692    let east_asia_font = get_attribute(&run_fonts_tag, "w:eastAsia");
1693    let complex_script_font = get_attribute(&run_fonts_tag, "w:cs");
1694    let ascii_theme_font = get_attribute(&run_fonts_tag, "w:asciiTheme");
1695    let h_ansi_theme_font = get_attribute(&run_fonts_tag, "w:hAnsiTheme");
1696    let east_asia_theme_font = get_attribute(&run_fonts_tag, "w:eastAsiaTheme");
1697    let complex_script_theme_font = get_attribute(&run_fonts_tag, "w:csTheme");
1698    let font_hint = get_attribute(&run_fonts_tag, "w:hint");
1699    let language_tag = super::scan::find_tag_token_ref(xml, "w:lang").unwrap_or_default();
1700    let language = get_attribute(&language_tag, "w:val");
1701    let language_east_asia = get_attribute(&language_tag, "w:eastAsia");
1702    let language_bidi = get_attribute(&language_tag, "w:bidi");
1703    let right_to_left = parse_on_off_attribute(xml, "rtl");
1704    let complex_script = parse_on_off_attribute(xml, "cs");
1705    let vertical_align_match =
1706        super::scan::find_attribute_value_in_tag(xml, "w:vertAlign", "w:val");
1707    let drawing_bold_match = find_drawing_rpr_attribute(xml, "b");
1708    let drawing_italic_match = find_drawing_rpr_attribute(xml, "i");
1709    let drawing_underline_match = find_drawing_rpr_attribute(xml, "u");
1710    let drawing_strike_match =
1711        find_drawing_rpr_attribute(xml, "strike").or_else(|| find_drawing_rpr_attribute(xml, "s"));
1712    let drawing_color_match = find_drawing_color(xml);
1713    let drawing_size_match = find_drawing_rpr_attribute(xml, "sz");
1714    let drawing_font_match = find_drawing_latin_typeface(xml);
1715    let run_border_tag = super::scan::find_tag_token_ref(xml, "w:bdr");
1716    let decoded_text_samples = collect_decoded_text_samples(xml);
1717    let contains_east_asia = contains_east_asia_glyphs(&decoded_text_samples);
1718    let contains_complex_script = contains_complex_script_glyphs(&decoded_text_samples);
1719
1720    let mut style = TextStyle {
1721        bold: None,
1722        italic: None,
1723        underline: None,
1724        strike: None,
1725        all_caps: None,
1726        small_caps: None,
1727        color: None,
1728        highlight: None,
1729        background_color: None,
1730        font_size_pt: None,
1731        font_size_cs_pt: None,
1732        font_family: None,
1733        source_font_family: None,
1734        font_family_ascii: None,
1735        font_family_h_ansi: None,
1736        font_family_east_asia: None,
1737        font_family_cs: None,
1738        font_theme_ascii: None,
1739        font_theme_h_ansi: None,
1740        font_theme_east_asia: None,
1741        font_theme_cs: None,
1742        resolved_font_family_ascii: None,
1743        resolved_font_family_h_ansi: None,
1744        resolved_font_family_east_asia: None,
1745        resolved_font_family_cs: None,
1746        font_hint: None,
1747        language: None,
1748        language_east_asia: None,
1749        language_bidi: None,
1750        right_to_left: None,
1751        complex_script: None,
1752        character_spacing_twips: None,
1753        character_scale_percent: None,
1754        source_character_scale: None,
1755        kerning_min_font_size_pt: None,
1756        ligatures: super::style::parse_text_ligatures_from_xml(xml),
1757        contextual_alternates: super::style::parse_contextual_alternates_from_xml(xml),
1758        vertical_align: None,
1759        run_border: None,
1760        text_shadow: None,
1761        text_outline: None,
1762    };
1763
1764    style.character_scale_percent = super::style::parse_character_scale_percent_from_xml(xml);
1765    style.source_character_scale = super::scan::find_attribute_value_in_tag(xml, "w:w", "w:val");
1766    style.bold = bold;
1767    style.italic = italic;
1768    style.underline = underline;
1769    style.strike = strike;
1770    style.all_caps = all_caps;
1771    style.small_caps = small_caps;
1772
1773    if style.bold.is_none() {
1774        if let Some(value) = drawing_bold_match {
1775            let value = value.to_ascii_lowercase();
1776            style.bold = Some(value != "0" && value != "false");
1777        }
1778    }
1779    if style.italic.is_none() {
1780        if let Some(value) = drawing_italic_match {
1781            let value = value.to_ascii_lowercase();
1782            style.italic = Some(value != "0" && value != "false");
1783        }
1784    }
1785    if style.underline.is_none() {
1786        if let Some(value) = drawing_underline_match {
1787            let value = value.to_ascii_lowercase();
1788            style.underline = Some(value != "none" && value != "false" && value != "0");
1789        }
1790    }
1791    if style.strike.is_none() {
1792        if let Some(value) = drawing_strike_match {
1793            let value = value.to_ascii_lowercase();
1794            style.strike =
1795                Some(value != "nostrike" && value != "none" && value != "false" && value != "0");
1796        }
1797    }
1798
1799    if let Some(color) = normalize_hex_color(color_match.as_deref()) {
1800        style.color = Some(color);
1801    } else if let Some(color) = drawing_color_match {
1802        style.color = Some(color);
1803    }
1804
1805    if let Some(highlight) = highlight_match {
1806        style.highlight = Some(highlight);
1807    }
1808
1809    if let Some(shading_tag) = shading_tag {
1810        if let Some(shading_fill) =
1811            normalize_hex_color(get_attribute(&shading_tag, "w:fill").as_deref())
1812        {
1813            style.background_color = Some(shading_fill);
1814        }
1815    }
1816
1817    if let Some(character_spacing) = character_spacing_match.and_then(|raw| raw.parse::<i64>().ok())
1818    {
1819        style.character_spacing_twips = Some(character_spacing);
1820    }
1821
1822    if let Some(kerning_raw) = kerning_match.and_then(|raw| raw.parse::<f64>().ok()) {
1823        if kerning_raw.is_finite() && kerning_raw >= 0.0 {
1824            style.kerning_min_font_size_pt = Some(kerning_raw / 2.0);
1825        }
1826    }
1827
1828    if let Some(size_raw) = size_match.and_then(|raw| raw.parse::<f64>().ok()) {
1829        style.font_size_pt = Some(size_raw / 2.0);
1830    } else if let Some(size_raw) = drawing_size_match.and_then(|raw| raw.parse::<f64>().ok()) {
1831        style.font_size_pt = Some(size_raw / 100.0);
1832    }
1833    if let Some(size_raw) = complex_size_match.and_then(|raw| raw.parse::<f64>().ok()) {
1834        style.font_size_cs_pt = Some(size_raw / 2.0);
1835    }
1836
1837    style.font_family_ascii = ascii_font.clone();
1838    style.font_family_h_ansi = h_ansi_font.clone();
1839    style.font_family_east_asia = east_asia_font.clone();
1840    style.font_family_cs = complex_script_font.clone();
1841    style.font_theme_ascii = ascii_theme_font.clone();
1842    style.font_theme_h_ansi = h_ansi_theme_font.clone();
1843    style.font_theme_east_asia = east_asia_theme_font.clone();
1844    style.font_theme_cs = complex_script_theme_font.clone();
1845    style.resolved_font_family_ascii = ascii_theme_font
1846        .as_deref()
1847        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1848        .or_else(|| ascii_font.clone());
1849    style.resolved_font_family_h_ansi = h_ansi_theme_font
1850        .as_deref()
1851        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1852        .or_else(|| h_ansi_font.clone());
1853    style.resolved_font_family_east_asia = east_asia_theme_font
1854        .as_deref()
1855        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1856        .or_else(|| east_asia_font.clone());
1857    style.resolved_font_family_cs = complex_script_theme_font
1858        .as_deref()
1859        .and_then(|token| resolve_theme_font(Some(token), theme_fonts))
1860        .or_else(|| complex_script_font.clone());
1861    style.font_hint = font_hint;
1862    style.language = language;
1863    style.language_east_asia = language_east_asia;
1864    style.language_bidi = language_bidi;
1865    style.right_to_left = right_to_left;
1866    style.complex_script = complex_script;
1867
1868    let run_font_family = ascii_font.clone().or(h_ansi_font.clone());
1869    let run_theme_font_token = ascii_theme_font.clone().or(h_ansi_theme_font.clone());
1870    let east_asia_fallback_font = if contains_east_asia {
1871        east_asia_font.clone()
1872    } else {
1873        None
1874    };
1875    let east_asia_fallback_theme_token = if contains_east_asia {
1876        east_asia_theme_font.clone()
1877    } else {
1878        None
1879    };
1880    let complex_script_fallback_font = if contains_complex_script {
1881        complex_script_font.clone()
1882    } else {
1883        None
1884    };
1885    let complex_script_fallback_theme_token = if contains_complex_script {
1886        complex_script_theme_font.clone()
1887    } else {
1888        None
1889    };
1890    let symbol_fallback_font = {
1891        let candidate = east_asia_font.clone().or(complex_script_font.clone());
1892        candidate.filter(|font| {
1893            let lower = font.to_ascii_lowercase();
1894            lower.contains("symbol")
1895                || lower.contains("emoji")
1896                || lower.contains("dingbat")
1897                || lower.contains("wingding")
1898                || lower.contains("webding")
1899        })
1900    };
1901
1902    if let Some(font_family) = run_font_family {
1903        style.font_family = Some(font_family);
1904    } else if let Some(theme_token) = run_theme_font_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) = east_asia_fallback_font {
1909        style.font_family = Some(font_family);
1910    } else if let Some(theme_token) = east_asia_fallback_theme_token.as_deref() {
1911        if let Some(font_family) = resolve_theme_font(Some(theme_token), theme_fonts) {
1912            style.font_family = Some(font_family);
1913        }
1914    } else if let Some(font_family) = complex_script_fallback_font {
1915        style.font_family = Some(font_family);
1916    } else if let Some(theme_token) = complex_script_fallback_theme_token.as_deref() {
1917        if let Some(font_family) = resolve_theme_font(Some(theme_token), theme_fonts) {
1918            style.font_family = Some(font_family);
1919        }
1920    } else if let Some(font_family) = symbol_fallback_font {
1921        style.font_family = Some(font_family);
1922    } else if let Some(font_family) = drawing_font_match {
1923        style.font_family = Some(font_family);
1924    }
1925    style.source_font_family = style.font_family.clone();
1926
1927    if let Some(vertical_align_value) = vertical_align_match.map(|value| value.to_ascii_lowercase())
1928    {
1929        style.vertical_align = match vertical_align_value.as_str() {
1930            "superscript" => Some(VerticalAlign::Superscript),
1931            "subscript" => Some(VerticalAlign::Subscript),
1932            _ => None,
1933        };
1934    }
1935
1936    style.run_border = parse_text_run_border_style(run_border_tag.as_deref());
1937    super::text_effects::parse_into(&mut style, xml);
1938
1939    merge_text_styles(None, Some(style))
1940}
1941
1942/// Mirrors TypeScript `parseParagraphAlignFromXml`.
1943pub fn parse_paragraph_align_from_xml(xml: &str) -> Option<ParagraphAlignment> {
1944    map_alignment(super::scan::find_attribute_value_in_tag(xml, "w:jc", "w:val").as_deref())
1945}
1946
1947pub(crate) fn parse_heading_level_from_outline(xml: &str) -> Option<Option<HeadingLevel>> {
1948    let tag = direct_table_property(xml, "w:outlineLvl")?;
1949    let outline_value = get_attribute(tag, "w:val")?;
1950    let level = outline_value.parse::<i64>().ok()? + 1;
1951    Some(match level {
1952        1 => Some(HeadingLevel::One),
1953        2 => Some(HeadingLevel::Two),
1954        3 => Some(HeadingLevel::Three),
1955        4 => Some(HeadingLevel::Four),
1956        5 => Some(HeadingLevel::Five),
1957        6 => Some(HeadingLevel::Six),
1958        7 => Some(HeadingLevel::Seven),
1959        8 => Some(HeadingLevel::Eight),
1960        9 => Some(HeadingLevel::Nine),
1961        _ => None,
1962    })
1963}
1964
1965fn parse_style_type(value: Option<&str>) -> Option<RawStyleType> {
1966    match value?.to_ascii_lowercase().as_str() {
1967        "paragraph" => Some(RawStyleType::Paragraph),
1968        "character" => Some(RawStyleType::Character),
1969        "numbering" => Some(RawStyleType::Numbering),
1970        "table" => Some(RawStyleType::Table),
1971        _ => None,
1972    }
1973}
1974
1975fn resolve_style_properties_block<'a>(style_xml: &'a str, tag_name: &str) -> &'a str {
1976    crate::xml::extract_balanced_tag_slices(style_xml, tag_name)
1977        .into_iter()
1978        .next()
1979        .or_else(|| super::scan::find_tag_token_ref(style_xml, tag_name))
1980        .unwrap_or_default()
1981}
1982
1983fn strip_table_conditional_style_blocks(style_xml: &str) -> String {
1984    if style_xml.is_empty() {
1985        return style_xml.to_string();
1986    }
1987
1988    let mut output = style_xml.to_string();
1989    loop {
1990        let before_len = output.len();
1991        output = remove_balanced_tag_blocks(&output, "w:tblStylePr");
1992        output = remove_self_closing_tags(&output, "w:tblStylePr");
1993        if output.len() == before_len {
1994            break;
1995        }
1996    }
1997    output
1998}
1999
2000fn remove_balanced_tag_blocks(xml: &str, tag_name: &str) -> String {
2001    let mut output = String::new();
2002    let mut last_index = 0;
2003    for range in extract_balanced_tag_ranges(xml, tag_name) {
2004        output.push_str(&xml[last_index..range.start]);
2005        last_index = range.end;
2006    }
2007    output.push_str(&xml[last_index..]);
2008    output
2009}
2010
2011fn remove_self_closing_tags(xml: &str, tag_name: &str) -> String {
2012    let mut output = String::new();
2013    let mut last_index = 0;
2014    let open_prefix = format!("<{tag_name}");
2015    let mut index = 0;
2016    while index < xml.len() {
2017        if xml.as_bytes().get(index) != Some(&b'<')
2018            || !super::scan::starts_with_ignore_ascii_case(&xml[index..], &open_prefix)
2019        {
2020            index += 1;
2021            continue;
2022        }
2023
2024        let after_prefix = index + open_prefix.len();
2025        let mut cursor = after_prefix;
2026        let bytes = xml.as_bytes();
2027        while cursor < bytes.len() {
2028            if bytes[cursor] == b'>' {
2029                let token = &xml[index..=cursor];
2030                if token.ends_with("/>") || token.ends_with("/ >") {
2031                    output.push_str(&xml[last_index..index]);
2032                    last_index = cursor + 1;
2033                }
2034                index = cursor + 1;
2035                break;
2036            }
2037            cursor += 1;
2038        }
2039        if cursor >= bytes.len() {
2040            break;
2041        }
2042    }
2043    output.push_str(&xml[last_index..]);
2044    output
2045}
2046
2047fn extract_balanced_tag_ranges(xml: &str, tag_name: &str) -> Vec<crate::xml::TagRange> {
2048    crate::xml::extract_balanced_tag_ranges(xml, tag_name)
2049}
2050
2051fn read_typeface(xml: &str, tag_name: &str) -> Option<String> {
2052    let token = super::scan::find_tag_token_ref(xml, &format!("a:{tag_name}"))?;
2053    let value = get_attribute(&token, "typeface")?.trim().to_string();
2054    if value.is_empty() {
2055        None
2056    } else {
2057        Some(value)
2058    }
2059}
2060
2061/// Mirrors TypeScript `parseThemeFonts`.
2062pub fn parse_theme_fonts(pkg: &OoxmlPackage) -> ThemeFontMap {
2063    let Some(theme_xml) =
2064        crate::package::related_document_part(pkg, "theme").map(|part| part.content.as_str())
2065    else {
2066        return ThemeFontMap::default();
2067    };
2068
2069    let major_font_xml = crate::xml::extract_balanced_tag_slices(theme_xml, "a:majorFont")
2070        .into_iter()
2071        .next()
2072        .unwrap_or_default();
2073    let minor_font_xml = crate::xml::extract_balanced_tag_slices(theme_xml, "a:minorFont")
2074        .into_iter()
2075        .next()
2076        .unwrap_or_default();
2077
2078    ThemeFontMap {
2079        major_latin: read_typeface(&major_font_xml, "latin"),
2080        major_east_asia: read_typeface(&major_font_xml, "ea"),
2081        major_complex_script: read_typeface(&major_font_xml, "cs"),
2082        minor_latin: read_typeface(&minor_font_xml, "latin"),
2083        minor_east_asia: read_typeface(&minor_font_xml, "ea"),
2084        minor_complex_script: read_typeface(&minor_font_xml, "cs"),
2085    }
2086}
2087
2088fn parse_theme_color_from_tag(color_xml: &str) -> Option<String> {
2089    if let Some(explicit) = super::scan::find_attribute_value_in_tag(color_xml, "a:srgbClr", "val")
2090        .as_deref()
2091        .and_then(|value| normalize_hex_color(Some(value)))
2092    {
2093        return Some(explicit);
2094    }
2095
2096    super::scan::find_attribute_value_in_tag(color_xml, "a:sysClr", "lastClr")
2097        .as_deref()
2098        .and_then(|value| normalize_hex_color(Some(value)))
2099}
2100
2101/// Mirrors TypeScript `parseThemeColors`.
2102pub fn parse_theme_colors(pkg: &OoxmlPackage) -> ThemeColorMap {
2103    let Some(theme_xml) =
2104        crate::package::related_document_part(pkg, "theme").map(|part| part.content.as_str())
2105    else {
2106        return ThemeColorMap::new();
2107    };
2108
2109    let color_scheme_xml = crate::xml::extract_balanced_tag_slices(theme_xml, "a:clrScheme")
2110        .into_iter()
2111        .next()
2112        .unwrap_or_default();
2113    if color_scheme_xml.is_empty() {
2114        return ThemeColorMap::new();
2115    }
2116
2117    let color_names = [
2118        "dk1",
2119        "lt1",
2120        "dk2",
2121        "lt2",
2122        "accent1",
2123        "accent2",
2124        "accent3",
2125        "accent4",
2126        "accent5",
2127        "accent6",
2128        "hlink",
2129        "folHlink",
2130        "bg1",
2131        "bg2",
2132        "tx1",
2133        "tx2",
2134        "followedHyperlink",
2135    ];
2136
2137    let mut colors = ThemeColorMap::new();
2138    for name in color_names {
2139        let tag_match =
2140            crate::xml::extract_balanced_tag_slices(&color_scheme_xml, &format!("a:{name}"))
2141                .into_iter()
2142                .next()
2143                .or_else(|| {
2144                    super::scan::find_tag_token_ref(&color_scheme_xml, &format!("a:{name}"))
2145                });
2146        let Some(tag_match) = tag_match else {
2147            continue;
2148        };
2149        if let Some(color) = parse_theme_color_from_tag(&tag_match) {
2150            colors.insert(name.to_ascii_lowercase(), color);
2151        }
2152    }
2153
2154    colors
2155}
2156
2157/// Font size Word uses when declared run defaults omit `w:sz`.
2158const WORD_DECLARED_DEFAULT_FONT_SIZE_PT: f64 = 10.0;
2159
2160/// Supplies the defaults Word substitutes when a styles part omits
2161/// `w:rPrDefault` or `w:pPrDefault`. Native controls on Word 16.106 show that
2162/// absent run defaults resolve to the theme minor fonts (Aptos without a theme)
2163/// at 12 pt, and absent paragraph defaults resolve to 1.15 auto line spacing
2164/// with 8 pt after. Declared, even empty, defaults keep the OOXML semantics.
2165fn with_word_application_defaults(doc_defaults_xml: &str, theme_fonts: &ThemeFontMap) -> String {
2166    let declares_run_defaults = super::scan::contains_tag(doc_defaults_xml, "w:rPrDefault");
2167    let declares_paragraph_defaults = super::scan::contains_tag(doc_defaults_xml, "w:pPrDefault");
2168    if declares_run_defaults && declares_paragraph_defaults {
2169        return doc_defaults_xml.to_string();
2170    }
2171    let mut supplied = String::new();
2172    if !declares_run_defaults {
2173        let fonts = if theme_fonts.minor_latin.is_some() {
2174            r#"<w:rFonts w:asciiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:hAnsiTheme="minorHAnsi" w:cstheme="minorBidi"/>"#
2175        } else {
2176            r#"<w:rFonts w:ascii="Aptos" w:hAnsi="Aptos"/>"#
2177        };
2178        supplied.push_str(&format!(
2179            r#"<w:rPrDefault><w:rPr>{fonts}<w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:rPrDefault>"#
2180        ));
2181    }
2182    if !declares_paragraph_defaults {
2183        supplied.push_str(
2184            r#"<w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault>"#,
2185        );
2186    }
2187    match doc_defaults_xml.rfind("</w:docDefaults>") {
2188        Some(end) => format!(
2189            "{}{}{}",
2190            &doc_defaults_xml[..end],
2191            supplied,
2192            &doc_defaults_xml[end..]
2193        ),
2194        None => format!("<w:docDefaults>{supplied}</w:docDefaults>"),
2195    }
2196}
2197
2198/// Mirrors TypeScript `parseStyleSheet`.
2199pub fn parse_style_sheet(pkg: &OoxmlPackage) -> ParsedStyleSheet {
2200    parse_style_sheet_selected(pkg, None)
2201}
2202
2203pub(crate) fn parse_document_style_sheet(pkg: &OoxmlPackage) -> ParsedStyleSheet {
2204    parse_referenced_style_sheet(pkg, document_style_references(pkg))
2205}
2206
2207fn document_style_references(pkg: &OoxmlPackage) -> HashSet<String> {
2208    let mut referenced = HashSet::new();
2209    for part in pkg.parts.values() {
2210        for name in ["w:tblStyle", "w:rStyle", "w:pStyle"] {
2211            for tag in crate::xml::extract_balanced_tag_slices(&part.content, name) {
2212                if let Some(id) = get_attribute(tag, "w:val") {
2213                    referenced.insert(id);
2214                }
2215            }
2216        }
2217    }
2218    referenced
2219}
2220
2221fn parse_referenced_style_sheet(
2222    pkg: &OoxmlPackage,
2223    mut referenced: HashSet<String>,
2224) -> ParsedStyleSheet {
2225    if let Some(part) = crate::package::related_document_part(pkg, "styles") {
2226        let mut parents = HashMap::new();
2227        for style in crate::xml::extract_balanced_tag_slices(&part.content, "w:style") {
2228            let tag = super::scan::find_tag_token_ref(style, "w:style").unwrap_or("");
2229            let Some(id) = get_attribute(tag, "w:styleId") else {
2230                continue;
2231            };
2232            if let Some(parent) =
2233                super::scan::find_attribute_value_in_tag(style, "w:basedOn", "w:val")
2234            {
2235                parents.insert(id.clone(), parent);
2236            }
2237            let kind = parse_style_type(get_attribute(tag, "w:type").as_deref());
2238            if kind == Some(RawStyleType::Paragraph)
2239                || (kind == Some(RawStyleType::Table)
2240                    && get_attribute(tag, "w:default")
2241                        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "on")))
2242            {
2243                referenced.insert(id);
2244            }
2245        }
2246        let mut pending: Vec<_> = referenced.iter().cloned().collect();
2247        while let Some(id) = pending.pop() {
2248            if let Some(parent) = parents.get(&id) {
2249                if referenced.insert(parent.clone()) {
2250                    pending.push(parent.clone());
2251                }
2252            }
2253        }
2254    }
2255    parse_style_sheet_selected(pkg, Some(&referenced))
2256}
2257
2258#[cfg(not(target_arch = "wasm32"))]
2259pub(crate) type DocumentStyleSheet = std::sync::Arc<ParsedStyleSheet>;
2260#[cfg(target_arch = "wasm32")]
2261pub(crate) type DocumentStyleSheet = ParsedStyleSheet;
2262
2263#[cfg(not(target_arch = "wasm32"))]
2264struct CachedDocumentStyleSheet {
2265    styles: Option<String>,
2266    theme: Option<String>,
2267    references: Vec<String>,
2268    source_bytes: usize,
2269    parsed: std::sync::Arc<ParsedStyleSheet>,
2270}
2271
2272#[cfg(not(target_arch = "wasm32"))]
2273static DOCUMENT_STYLE_CACHE: std::sync::LazyLock<
2274    super::cache::Snapshot<std::collections::VecDeque<std::sync::Arc<CachedDocumentStyleSheet>>>,
2275> = std::sync::LazyLock::new(|| super::cache::Snapshot::new(std::collections::VecDeque::new()));
2276
2277#[cfg(not(target_arch = "wasm32"))]
2278pub(crate) fn cached_document_style_identity(styles: &ParsedStyleSheet) -> Option<DocumentStyleSheet> {
2279    DOCUMENT_STYLE_CACHE.read(|cache| cache.iter()
2280        .find(|entry| std::ptr::eq(entry.parsed.as_ref(), styles))
2281        .map(|entry| std::sync::Arc::clone(&entry.parsed)))
2282}
2283
2284pub(crate) fn acquire_document_style_sheet(pkg: &OoxmlPackage) -> DocumentStyleSheet {
2285    #[cfg(target_arch = "wasm32")]
2286    {
2287        parse_document_style_sheet(pkg)
2288    }
2289    #[cfg(not(target_arch = "wasm32"))]
2290    {
2291        let styles =
2292            crate::package::related_document_part(pkg, "styles").map(|part| part.content.as_str());
2293        let theme =
2294            crate::package::related_document_part(pkg, "theme").map(|part| part.content.as_str());
2295        if styles
2296            .map_or(0, str::len)
2297            .saturating_add(theme.map_or(0, str::len))
2298            > 512 * 1024
2299        {
2300            return std::sync::Arc::new(parse_document_style_sheet(pkg));
2301        }
2302        let references = document_style_references(pkg);
2303        if references.len() > 512 || references.iter().map(String::len).sum::<usize>() > 32 * 1024 {
2304            return std::sync::Arc::new(parse_referenced_style_sheet(pkg, references));
2305        }
2306        let mut references: Vec<_> = references.into_iter().collect();
2307        references.sort_unstable();
2308        let source_bytes = styles.map_or(0, str::len)
2309            + theme.map_or(0, str::len)
2310            + references.iter().map(String::len).sum::<usize>();
2311        let matches = |entry: &CachedDocumentStyleSheet| {
2312            entry.styles.as_deref() == styles
2313                && entry.theme.as_deref() == theme
2314                && entry.references == references
2315        };
2316        if let Some(parsed) = DOCUMENT_STYLE_CACHE.read(|cache| cache.iter()
2317            .find(|entry| matches(entry)).map(|entry| entry.parsed.clone())) {
2318            return parsed;
2319        }
2320        let parsed = std::sync::Arc::new(parse_referenced_style_sheet(
2321            pkg, references.iter().cloned().collect(),
2322        ));
2323        if source_bytes > 512 * 1024 { return parsed; }
2324        let entry = std::sync::Arc::new(CachedDocumentStyleSheet {
2325            styles: styles.map(str::to_owned),
2326            theme: theme.map(str::to_owned),
2327            references,
2328            source_bytes,
2329            parsed: parsed.clone(),
2330        });
2331        DOCUMENT_STYLE_CACHE.publish(move |cache| {
2332            if cache.iter().any(|existing| existing.styles == entry.styles
2333                && existing.theme == entry.theme && existing.references == entry.references) {
2334                return cache.clone();
2335            }
2336            let mut next = cache.clone();
2337            let mut retained = next.iter().map(|entry| entry.source_bytes).sum::<usize>();
2338            while next.len() >= 4 || retained + entry.source_bytes > 512 * 1024 {
2339                let Some(old) = next.pop_front() else { break };
2340                retained -= old.source_bytes;
2341            }
2342            next.push_back(entry);
2343            next
2344        });
2345        parsed
2346    }
2347}
2348
2349pub(crate) fn release_document_style_metadata(
2350    styles: DocumentStyleSheet,
2351) -> (Vec<crate::model::ParagraphStyleDefinition>, Option<String>) {
2352    #[cfg(not(target_arch = "wasm32"))]
2353    let styles = match std::sync::Arc::try_unwrap(styles) {
2354        Ok(styles) => styles,
2355        Err(styles) => {
2356            return (
2357                styles.paragraph_styles.clone(),
2358                styles.default_paragraph_style_id.clone(),
2359            )
2360        }
2361    };
2362    (styles.paragraph_styles, styles.default_paragraph_style_id)
2363}
2364
2365fn parse_style_sheet_selected(
2366    pkg: &OoxmlPackage,
2367    selected_styles: Option<&HashSet<String>>,
2368) -> ParsedStyleSheet {
2369    let Some(styles_xml) =
2370        crate::package::related_document_part(pkg, "styles").map(|part| part.content.as_str())
2371    else {
2372        return empty_style_sheet();
2373    };
2374
2375    let theme_fonts = parse_theme_fonts(pkg);
2376    let theme_colors = parse_theme_colors(pkg);
2377    let declared_doc_defaults_xml =
2378        crate::xml::extract_balanced_tag_slices(styles_xml, "w:docDefaults")
2379            .into_iter()
2380            .next()
2381            .unwrap_or_default();
2382    let declares_run_defaults =
2383        super::scan::contains_tag(&declared_doc_defaults_xml, "w:rPrDefault");
2384    let doc_defaults_xml = with_word_application_defaults(&declared_doc_defaults_xml, &theme_fonts);
2385
2386    let default_paragraph_style = {
2387        let paragraph_defaults = resolve_style_properties_block(&doc_defaults_xml, "w:pPr");
2388        let default_paragraph_has_num_pr =
2389            super::scan::contains_tag(&paragraph_defaults, "w:numPr");
2390        let align = parse_paragraph_align_from_xml(&paragraph_defaults);
2391        let snap_to_grid = parse_on_off_attribute(&paragraph_defaults, "snapToGrid");
2392        let text_alignment = super::style::parse_paragraph_text_alignment_from_xml(
2393            direct_table_property(&doc_defaults_xml, "w:pPrDefault")
2394                .and_then(|xml| direct_table_property(xml, "w:pPr"))
2395                .unwrap_or_default(),
2396        );
2397        let spacing = parse_paragraph_spacing_from_xml(&paragraph_defaults);
2398        let indent = parse_paragraph_indent_from_xml(&paragraph_defaults);
2399        let background_color = parse_paragraph_shading_from_xml(&paragraph_defaults);
2400        let borders = parse_paragraph_border_set_from_xml(&paragraph_defaults);
2401        let tab_stops = parse_paragraph_tab_stops_from_xml(&paragraph_defaults);
2402        let parsed_default_numbering = parse_paragraph_numbering_from_xml(&paragraph_defaults);
2403        let numbering = if default_paragraph_has_num_pr && parsed_default_numbering.is_none() {
2404            Some(ParagraphNumbering { num_id: 0, ilvl: 0 })
2405        } else {
2406            parsed_default_numbering
2407        };
2408        let contextual_spacing = parse_on_off_attribute(&paragraph_defaults, "contextualSpacing");
2409        let keep_next = parse_on_off_attribute(&paragraph_defaults, "keepNext");
2410        let keep_lines = parse_on_off_attribute(&paragraph_defaults, "keepLines");
2411        let widow_control = parse_on_off_attribute(&paragraph_defaults, "widowControl");
2412        let page_break_before = parse_on_off_attribute(&paragraph_defaults, "pageBreakBefore");
2413
2414        if align.is_none()
2415            && snap_to_grid.is_none()
2416            && text_alignment.is_none()
2417            && spacing.is_none()
2418            && indent.is_none()
2419            && background_color.is_none()
2420            && borders.is_none()
2421            && tab_stops.is_empty()
2422            && numbering.is_none()
2423            && contextual_spacing.is_none()
2424            && keep_next.is_none()
2425            && keep_lines.is_none()
2426            && widow_control.is_none()
2427            && page_break_before.is_none()
2428        {
2429            None
2430        } else {
2431            Some(ParagraphStyle {
2432                source_before_auto_spacing: None,
2433                source_after_auto_spacing: None,
2434                align,
2435                snap_to_grid,
2436                source_snap_to_grid: None,
2437                source_has_snap_to_grid: None,
2438                source_inherited_snap_to_grid: None,
2439                source_table_snap_to_grid: None,
2440                text_alignment,
2441                source_text_alignment: None,
2442                source_has_text_alignment: None,
2443                source_inherited_text_alignment: None,
2444                source_table_text_alignment: None,
2445                heading_level: None,
2446                style_id: None,
2447                style_name: None,
2448                numbering,
2449                spacing,
2450                indent,
2451                background_color,
2452                borders,
2453                tab_stops: if tab_stops.is_empty() {
2454                    None
2455                } else {
2456                    Some(tab_stops)
2457                },
2458                contextual_spacing,
2459                keep_next,
2460                keep_lines,
2461                widow_control,
2462                page_break_before,
2463                drop_cap: None,
2464            })
2465        }
2466    };
2467
2468    let mut default_run_style = parse_text_style_from_xml(
2469        &resolve_style_properties_block(&doc_defaults_xml, "w:rPr"),
2470        &theme_fonts,
2471    );
2472    if declares_run_defaults {
2473        // Declared run defaults without a size render at Word's 10 pt default.
2474        match default_run_style.as_mut() {
2475            Some(style) => {
2476                if style.font_size_pt.is_none() {
2477                    style.font_size_pt = Some(WORD_DECLARED_DEFAULT_FONT_SIZE_PT);
2478                }
2479                if style.font_size_cs_pt.is_none() {
2480                    style.font_size_cs_pt = Some(WORD_DECLARED_DEFAULT_FONT_SIZE_PT);
2481                }
2482            }
2483            None => {
2484                default_run_style = parse_text_style_from_xml(
2485                    r#"<w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/></w:rPr>"#,
2486                    &theme_fonts,
2487                );
2488            }
2489        }
2490    }
2491
2492    let mut raw_styles_by_id = HashMap::new();
2493    let mut raw_table_styles_by_id = HashMap::new();
2494    let mut default_table_style_id = None;
2495
2496    for style_xml in crate::xml::extract_balanced_tag_slices(styles_xml, "w:style") {
2497        let style_tag = super::scan::find_tag_token_ref(&style_xml, "w:style").unwrap_or_default();
2498        let style_id = get_attribute(&style_tag, "w:styleId");
2499        let style_type = parse_style_type(get_attribute(&style_tag, "w:type").as_deref());
2500        let (Some(style_id), Some(style_type)) = (style_id, style_type) else {
2501            continue;
2502        };
2503        if selected_styles.is_some_and(|ids| !ids.contains(&style_id)) {
2504            continue;
2505        }
2506
2507        let name_tag = super::scan::find_tag_token_ref(&style_xml, "w:name").unwrap_or_default();
2508        let based_on_tag =
2509            super::scan::find_tag_token_ref(&style_xml, "w:basedOn").unwrap_or_default();
2510        let next_tag = super::scan::find_tag_token_ref(&style_xml, "w:next").unwrap_or_default();
2511        let ui_priority_tag =
2512            super::scan::find_tag_token_ref(&style_xml, "w:uiPriority").unwrap_or_default();
2513        let paragraph_properties_xml = resolve_style_properties_block(&style_xml, "w:pPr");
2514        let style_has_paragraph_num_pr =
2515            super::scan::contains_tag(&paragraph_properties_xml, "w:numPr");
2516        let run_properties_xml = resolve_style_properties_block(&style_xml, "w:rPr");
2517
2518        let heading_level = normalize_heading_level(Some(&style_id))
2519            .or_else(|| normalize_heading_level(get_attribute(&name_tag, "w:val").as_deref()));
2520        let outline_heading = parse_heading_level_from_outline(&paragraph_properties_xml);
2521
2522        let parsed_style_numbering = parse_paragraph_numbering_from_xml(&paragraph_properties_xml);
2523        let tab_stops = parse_paragraph_tab_stops_from_xml(&paragraph_properties_xml);
2524
2525        raw_styles_by_id.insert(
2526            style_id.clone(),
2527            RawStyleDefinition {
2528                id: style_id.clone(),
2529                style_type,
2530                name: get_attribute(&name_tag, "w:val").unwrap_or(style_id.clone()),
2531                based_on_id: get_attribute(&based_on_tag, "w:val"),
2532                next_style_id: get_attribute(&next_tag, "w:val"),
2533                align: parse_paragraph_align_from_xml(&paragraph_properties_xml),
2534                snap_to_grid: parse_on_off_attribute(&paragraph_properties_xml, "snapToGrid"),
2535                text_alignment: super::style::parse_paragraph_text_alignment_from_xml(
2536                    direct_table_property(&style_xml, "w:pPr").unwrap_or_default(),
2537                ),
2538                heading_level,
2539                outline_heading,
2540                numbering: if style_has_paragraph_num_pr && parsed_style_numbering.is_none() {
2541                    Some(ParagraphNumbering { num_id: 0, ilvl: 0 })
2542                } else {
2543                    parsed_style_numbering
2544                },
2545                spacing: parse_paragraph_spacing_from_xml(&paragraph_properties_xml),
2546                indent: parse_paragraph_indent_from_xml(&paragraph_properties_xml),
2547                background_color: parse_paragraph_shading_from_xml(&paragraph_properties_xml),
2548                borders: parse_paragraph_border_set_from_xml(&paragraph_properties_xml),
2549                tab_stops: if tab_stops.is_empty() {
2550                    None
2551                } else {
2552                    Some(tab_stops)
2553                },
2554                contextual_spacing: parse_on_off_attribute(
2555                    &paragraph_properties_xml,
2556                    "contextualSpacing",
2557                ),
2558                keep_next: parse_on_off_attribute(&paragraph_properties_xml, "keepNext"),
2559                keep_lines: parse_on_off_attribute(&paragraph_properties_xml, "keepLines"),
2560                widow_control: parse_on_off_attribute(&paragraph_properties_xml, "widowControl"),
2561                page_break_before: parse_on_off_attribute(
2562                    &paragraph_properties_xml,
2563                    "pageBreakBefore",
2564                ),
2565                run_style: parse_text_style_from_xml(&run_properties_xml, &theme_fonts),
2566                ui_priority: get_attribute(&ui_priority_tag, "w:val")
2567                    .and_then(|raw| raw.parse::<f64>().ok())
2568                    .filter(|value| value.is_finite())
2569                    .map(|value| value.round() as i64),
2570                is_default: get_attribute(&style_tag, "w:default")
2571                    .is_some_and(|value| value == "1")
2572                    .then_some(true),
2573                is_primary: super::scan::contains_tag(&style_xml, "w:qFormat").then_some(true),
2574            },
2575        );
2576
2577        if style_type == RawStyleType::Table
2578            && selected_styles.is_none_or(|ids| ids.contains(&style_id))
2579        {
2580            if get_attribute(&style_tag, "w:default")
2581                .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "on"))
2582            {
2583                default_table_style_id = Some(style_id.clone());
2584            }
2585            let mut conditions = HashMap::new();
2586            let whole_table_source_xml = strip_table_conditional_style_blocks(&style_xml);
2587            if let Some(whole_table_condition) =
2588                parse_table_conditional_style_from_xml(&whole_table_source_xml, &theme_fonts)
2589            {
2590                conditions.insert(TableConditionalStyleType::WholeTable, whole_table_condition);
2591            }
2592
2593            for conditional_style_xml in
2594                crate::xml::extract_balanced_tag_slices(&style_xml, "w:tblStylePr")
2595            {
2596                let conditional_style_tag =
2597                    super::scan::find_tag_token_ref(&conditional_style_xml, "w:tblStylePr")
2598                        .unwrap_or_default();
2599                let Some(conditional_type) = normalize_table_conditional_style_type(
2600                    get_attribute(&conditional_style_tag, "w:type").as_deref(),
2601                ) else {
2602                    continue;
2603                };
2604                if let Some(condition) =
2605                    parse_table_conditional_style_from_xml(&conditional_style_xml, &theme_fonts)
2606                {
2607                    conditions.insert(conditional_type, condition);
2608                }
2609            }
2610
2611            raw_table_styles_by_id.insert(
2612                style_id.clone(),
2613                ParsedTableStyleDefinition {
2614                    id: style_id.clone(),
2615                    based_on_id: get_attribute(&based_on_tag, "w:val"),
2616                    name: get_attribute(&name_tag, "w:val").unwrap_or(style_id),
2617                    conditions,
2618                    floating: None,
2619                    properties: None,
2620                    has_row_cell_spacing: crate::xml::extract_balanced_tag_slices(
2621                        &style_xml, "w:trPr",
2622                    )
2623                    .into_iter()
2624                    .any(|properties| {
2625                        direct_table_property(properties, "w:tblCellSpacing").is_some()
2626                    }),
2627                },
2628            );
2629        }
2630    }
2631
2632    let mut run_style_cache = HashMap::new();
2633    fn resolve_run_style(
2634        style_id: &str,
2635        raw_styles_by_id: &HashMap<String, RawStyleDefinition>,
2636        run_style_cache: &mut HashMap<String, Option<TextStyle>>,
2637        stack: &mut HashSet<String>,
2638    ) -> Option<TextStyle> {
2639        if let Some(cached) = run_style_cache.get(style_id) {
2640            return cached.to_owned();
2641        }
2642        if stack.contains(style_id) {
2643            return None;
2644        }
2645        stack.insert(style_id.to_string());
2646
2647        let style = raw_styles_by_id.get(style_id)?;
2648        let inherited = style.based_on_id.as_deref().and_then(|based_on_id| {
2649            resolve_run_style(based_on_id, raw_styles_by_id, run_style_cache, stack)
2650        });
2651        let resolved = merge_text_styles(inherited, style.run_style.clone());
2652        run_style_cache.insert(style_id.to_string(), resolved.clone());
2653        stack.remove(style_id);
2654        resolved
2655    }
2656
2657    let mut paragraph_style_cache = HashMap::new();
2658    fn resolve_paragraph_style(
2659        style_id: &str,
2660        raw_styles_by_id: &HashMap<String, RawStyleDefinition>,
2661        paragraph_style_cache: &mut HashMap<String, Option<ParagraphStyleDefinition>>,
2662        stack: &mut HashSet<String>,
2663    ) -> Option<ParagraphStyleDefinition> {
2664        if let Some(cached) = paragraph_style_cache.get(style_id) {
2665            return cached.to_owned();
2666        }
2667        if stack.contains(style_id) {
2668            return None;
2669        }
2670        stack.insert(style_id.to_string());
2671
2672        let style = raw_styles_by_id.get(style_id)?;
2673        if style.style_type != RawStyleType::Paragraph {
2674            paragraph_style_cache.insert(style_id.to_string(), None);
2675            stack.remove(style_id);
2676            return None;
2677        }
2678
2679        let inherited = style.based_on_id.as_deref().and_then(|based_on_id| {
2680            raw_styles_by_id
2681                .get(based_on_id)
2682                .filter(|parent| parent.style_type == RawStyleType::Paragraph)
2683                .and_then(|_| {
2684                    resolve_paragraph_style(
2685                        based_on_id,
2686                        raw_styles_by_id,
2687                        paragraph_style_cache,
2688                        stack,
2689                    )
2690                })
2691        });
2692
2693        let mut outline_style = Some(style);
2694        let mut outline_seen = HashSet::new();
2695        let mut outline_heading = None;
2696        while let Some(current) = outline_style {
2697            if !outline_seen.insert(current.id.as_str()) {
2698                break;
2699            }
2700            if current.outline_heading.is_some() {
2701                outline_heading = current.outline_heading;
2702                break;
2703            }
2704            outline_style = current
2705                .based_on_id
2706                .as_deref()
2707                .and_then(|id| raw_styles_by_id.get(id))
2708                .filter(|parent| parent.style_type == RawStyleType::Paragraph);
2709        }
2710
2711        let resolved = ParagraphStyleDefinition {
2712            id: style.id.clone(),
2713            name: style.name.clone(),
2714            based_on_id: style.based_on_id.clone(),
2715            next_style_id: style.next_style_id.clone(),
2716            align: style
2717                .align
2718                .or(inherited.as_ref().and_then(|value| value.align)),
2719            snap_to_grid: style
2720                .snap_to_grid
2721                .or(inherited.as_ref().and_then(|value| value.snap_to_grid)),
2722            source_has_snap_to_grid: Some(
2723                style
2724                    .snap_to_grid
2725                    .or(inherited.as_ref().and_then(|value| value.snap_to_grid))
2726                    .is_some(),
2727            ),
2728            text_alignment: style
2729                .text_alignment
2730                .or(inherited.as_ref().and_then(|value| value.text_alignment)),
2731            source_has_text_alignment: Some(
2732                style
2733                    .text_alignment
2734                    .or(inherited.as_ref().and_then(|value| value.text_alignment))
2735                    .is_some(),
2736            ),
2737            heading_level: outline_heading.unwrap_or_else(|| {
2738                style
2739                    .heading_level
2740                    .or(inherited.as_ref().and_then(|value| value.heading_level))
2741            }),
2742            numbering: style
2743                .numbering
2744                .clone()
2745                .or_else(|| inherited.as_ref().and_then(|value| value.numbering.clone())),
2746            spacing: merge_paragraph_spacing(
2747                inherited.as_ref().and_then(|value| value.spacing.as_ref()),
2748                style.spacing.as_ref(),
2749            ),
2750            indent: merge_paragraph_indent(
2751                inherited.as_ref().and_then(|value| value.indent.as_ref()),
2752                style.indent.as_ref(),
2753            ),
2754            background_color: merge_paragraph_background_color(
2755                inherited
2756                    .as_ref()
2757                    .and_then(|value| value.background_color.as_deref()),
2758                style.background_color.as_deref(),
2759            ),
2760            borders: merge_paragraph_border_sets(
2761                inherited.as_ref().and_then(|value| value.borders.as_ref()),
2762                style.borders.as_ref(),
2763            ),
2764            tab_stops: merge_paragraph_tab_stops(
2765                inherited
2766                    .as_ref()
2767                    .and_then(|value| value.tab_stops.as_deref()),
2768                style.tab_stops.as_deref(),
2769            ),
2770            contextual_spacing: merge_paragraph_boolean(
2771                inherited
2772                    .as_ref()
2773                    .and_then(|value| value.contextual_spacing),
2774                style.contextual_spacing,
2775            ),
2776            keep_next: merge_paragraph_boolean(
2777                inherited.as_ref().and_then(|value| value.keep_next),
2778                style.keep_next,
2779            ),
2780            keep_lines: merge_paragraph_boolean(
2781                inherited.as_ref().and_then(|value| value.keep_lines),
2782                style.keep_lines,
2783            ),
2784            widow_control: merge_paragraph_boolean(
2785                inherited.as_ref().and_then(|value| value.widow_control),
2786                style.widow_control,
2787            ),
2788            page_break_before: merge_paragraph_boolean(
2789                inherited.as_ref().and_then(|value| value.page_break_before),
2790                style.page_break_before,
2791            ),
2792            run_style: merge_text_styles(
2793                inherited.as_ref().and_then(|value| value.run_style.clone()),
2794                style.run_style.clone(),
2795            ),
2796            ui_priority: style
2797                .ui_priority
2798                .or_else(|| inherited.as_ref().and_then(|value| value.ui_priority)),
2799            is_default: style.is_default,
2800            is_primary: style.is_primary,
2801        };
2802
2803        paragraph_style_cache.insert(style_id.to_string(), Some(resolved.clone()));
2804        stack.remove(style_id);
2805        Some(resolved)
2806    }
2807
2808    let mut paragraph_styles: Vec<ParagraphStyleDefinition> = raw_styles_by_id
2809        .values()
2810        .filter(|style| style.style_type == RawStyleType::Paragraph)
2811        .filter_map(|style| {
2812            let mut stack = HashSet::new();
2813            resolve_paragraph_style(
2814                &style.id,
2815                &raw_styles_by_id,
2816                &mut paragraph_style_cache,
2817                &mut stack,
2818            )
2819        })
2820        .collect();
2821
2822    for style in &mut paragraph_styles {
2823        style.snap_to_grid = style.snap_to_grid.or(default_paragraph_style
2824            .as_ref()
2825            .and_then(|style| style.snap_to_grid));
2826        style.text_alignment = style.text_alignment.or(default_paragraph_style
2827            .as_ref()
2828            .and_then(|style| style.text_alignment));
2829    }
2830
2831    paragraph_styles.sort_by(|left, right| {
2832        let left_priority = left.ui_priority.unwrap_or(9999);
2833        let right_priority = right.ui_priority.unwrap_or(9999);
2834        if left_priority != right_priority {
2835            left_priority.cmp(&right_priority)
2836        } else {
2837            left.name.cmp(&right.name)
2838        }
2839    });
2840
2841    let paragraph_style_by_id = paragraph_styles
2842        .iter()
2843        .map(|style| (style.id.clone(), style.clone()))
2844        .collect::<HashMap<_, _>>();
2845
2846    let mut run_style_by_id = HashMap::new();
2847    for style_id in raw_styles_by_id.keys() {
2848        let mut stack = HashSet::new();
2849        if let Some(resolved) = resolve_run_style(
2850            style_id,
2851            &raw_styles_by_id,
2852            &mut run_style_cache,
2853            &mut stack,
2854        ) {
2855            run_style_by_id.insert(style_id.clone(), resolved);
2856        }
2857    }
2858
2859    let mut table_style_cache = HashMap::new();
2860    fn resolve_table_style(
2861        style_id: &str,
2862        raw_table_styles_by_id: &HashMap<String, ParsedTableStyleDefinition>,
2863        table_style_cache: &mut HashMap<String, Option<ParsedTableStyleDefinition>>,
2864        stack: &mut HashSet<String>,
2865    ) -> Option<ParsedTableStyleDefinition> {
2866        if let Some(cached) = table_style_cache.get(style_id) {
2867            return cached.to_owned();
2868        }
2869        if stack.contains(style_id) {
2870            return None;
2871        }
2872        stack.insert(style_id.to_string());
2873
2874        let style = raw_table_styles_by_id.get(style_id)?;
2875        let inherited = style.based_on_id.as_deref().and_then(|based_on_id| {
2876            resolve_table_style(
2877                based_on_id,
2878                raw_table_styles_by_id,
2879                table_style_cache,
2880                stack,
2881            )
2882        });
2883
2884        let mut conditions = HashMap::new();
2885        for conditional_type in TableConditionalStyleType::ALL {
2886            if let Some(merged_condition) = merge_table_conditional_style(
2887                inherited
2888                    .as_ref()
2889                    .and_then(|value| value.conditions.get(&conditional_type)),
2890                style.conditions.get(&conditional_type),
2891            ) {
2892                conditions.insert(conditional_type, merged_condition);
2893            }
2894        }
2895
2896        let resolved = ParsedTableStyleDefinition {
2897            id: style.id.clone(),
2898            based_on_id: style.based_on_id.clone(),
2899            name: style.name.clone(),
2900            conditions,
2901            floating: style.floating.clone(),
2902            properties: style.properties.clone(),
2903            has_row_cell_spacing: style.has_row_cell_spacing
2904                || inherited
2905                    .as_ref()
2906                    .is_some_and(|value| value.has_row_cell_spacing),
2907        };
2908
2909        table_style_cache.insert(style_id.to_string(), Some(resolved.clone()));
2910        stack.remove(style_id);
2911        Some(resolved)
2912    }
2913
2914    let mut table_style_by_id = HashMap::new();
2915    for style_id in raw_table_styles_by_id.keys() {
2916        let mut stack = HashSet::new();
2917        if let Some(resolved) = resolve_table_style(
2918            style_id,
2919            &raw_table_styles_by_id,
2920            &mut table_style_cache,
2921            &mut stack,
2922        ) {
2923            table_style_by_id.insert(style_id.clone(), resolved);
2924        }
2925    }
2926
2927    let default_paragraph_style_id = paragraph_styles
2928        .iter()
2929        .find(|style| style.is_default == Some(true))
2930        .map(|style| style.id.clone())
2931        .or_else(|| {
2932            if paragraph_style_by_id.contains_key("Normal") {
2933                Some("Normal".to_string())
2934            } else {
2935                None
2936            }
2937        });
2938
2939    let resolved_default_paragraph_style = default_paragraph_style_id
2940        .as_ref()
2941        .and_then(|style_id| paragraph_style_by_id.get(style_id).cloned());
2942
2943    let merged_default_paragraph_style =
2944        resolved_default_paragraph_style
2945            .as_ref()
2946            .map(|resolved| ParagraphStyle {
2947                source_before_auto_spacing: None,
2948                source_after_auto_spacing: None,
2949                align: resolved.align.or(default_paragraph_style
2950                    .as_ref()
2951                    .and_then(|value| value.align)),
2952                snap_to_grid: resolved.snap_to_grid.or(default_paragraph_style
2953                    .as_ref()
2954                    .and_then(|value| value.snap_to_grid)),
2955                source_snap_to_grid: None,
2956                source_has_snap_to_grid: None,
2957                source_inherited_snap_to_grid: None,
2958                source_table_snap_to_grid: None,
2959                text_alignment: resolved.text_alignment.or(default_paragraph_style
2960                    .as_ref()
2961                    .and_then(|value| value.text_alignment)),
2962                source_text_alignment: None,
2963                source_has_text_alignment: None,
2964                source_inherited_text_alignment: None,
2965                source_table_text_alignment: None,
2966                heading_level: resolved.heading_level,
2967                style_id: default_paragraph_style_id.clone(),
2968                style_name: Some(resolved.name.clone()),
2969                numbering: resolved.numbering.clone().or_else(|| {
2970                    default_paragraph_style
2971                        .as_ref()
2972                        .and_then(|value| value.numbering.clone())
2973                }),
2974                spacing: merge_paragraph_spacing(
2975                    default_paragraph_style
2976                        .as_ref()
2977                        .and_then(|value| value.spacing.as_ref()),
2978                    resolved.spacing.as_ref(),
2979                ),
2980                indent: merge_paragraph_indent(
2981                    default_paragraph_style
2982                        .as_ref()
2983                        .and_then(|value| value.indent.as_ref()),
2984                    resolved.indent.as_ref(),
2985                ),
2986                background_color: merge_paragraph_background_color(
2987                    default_paragraph_style
2988                        .as_ref()
2989                        .and_then(|value| value.background_color.as_deref()),
2990                    resolved.background_color.as_deref(),
2991                ),
2992                borders: merge_paragraph_border_sets(
2993                    default_paragraph_style
2994                        .as_ref()
2995                        .and_then(|value| value.borders.as_ref()),
2996                    resolved.borders.as_ref(),
2997                ),
2998                tab_stops: merge_paragraph_tab_stops(
2999                    default_paragraph_style
3000                        .as_ref()
3001                        .and_then(|value| value.tab_stops.as_deref()),
3002                    resolved.tab_stops.as_deref(),
3003                ),
3004                contextual_spacing: merge_paragraph_boolean(
3005                    default_paragraph_style
3006                        .as_ref()
3007                        .and_then(|value| value.contextual_spacing),
3008                    resolved.contextual_spacing,
3009                ),
3010                keep_next: merge_paragraph_boolean(
3011                    default_paragraph_style
3012                        .as_ref()
3013                        .and_then(|value| value.keep_next),
3014                    resolved.keep_next,
3015                ),
3016                keep_lines: merge_paragraph_boolean(
3017                    default_paragraph_style
3018                        .as_ref()
3019                        .and_then(|value| value.keep_lines),
3020                    resolved.keep_lines,
3021                ),
3022                widow_control: merge_paragraph_boolean(
3023                    default_paragraph_style
3024                        .as_ref()
3025                        .and_then(|value| value.widow_control),
3026                    resolved.widow_control,
3027                ),
3028                page_break_before: merge_paragraph_boolean(
3029                    default_paragraph_style
3030                        .as_ref()
3031                        .and_then(|value| value.page_break_before),
3032                    resolved.page_break_before,
3033                ),
3034                drop_cap: None,
3035            });
3036
3037    let mut table_paragraph_spacing_by_style_id = HashMap::new();
3038    for (style_id, _) in raw_styles_by_id
3039        .iter()
3040        .filter(|(_, style)| style.style_type == RawStyleType::Table)
3041    {
3042        let mut chain = Vec::new();
3043        let mut cursor = Some(style_id.clone());
3044        let mut seen = HashSet::new();
3045        while let Some(id) = cursor {
3046            if !seen.insert(id.clone()) {
3047                break;
3048            }
3049            let Some(chain_style) = raw_styles_by_id.get(&id) else {
3050                break;
3051            };
3052            if chain_style.style_type != RawStyleType::Table {
3053                break;
3054            }
3055            chain.push(chain_style);
3056            cursor = chain_style.based_on_id.clone();
3057        }
3058        let mut merged_spacing: Option<crate::model::ParagraphSpacing> = None;
3059        for chain_style in chain.iter().rev() {
3060            merged_spacing = crate::parse::util::merge_paragraph_spacing(
3061                merged_spacing.as_ref(),
3062                chain_style.spacing.as_ref(),
3063            );
3064        }
3065        if let Some(spacing) = merged_spacing {
3066            table_paragraph_spacing_by_style_id.insert(style_id.clone(), spacing);
3067        }
3068    }
3069
3070    ParsedStyleSheet {
3071        paragraph_styles,
3072        paragraph_style_by_id,
3073        run_style_by_id,
3074        table_style_by_id,
3075        default_table_style_id,
3076        table_paragraph_spacing_by_style_id,
3077        doc_default_paragraph_spacing: default_paragraph_style
3078            .as_ref()
3079            .and_then(|style| style.spacing.clone()),
3080        default_paragraph_text_alignment: default_paragraph_style
3081            .as_ref()
3082            .and_then(|style| style.text_alignment),
3083        default_paragraph_style: merged_default_paragraph_style,
3084        default_paragraph_style_id,
3085        default_run_style: merge_text_styles(
3086            default_run_style,
3087            resolved_default_paragraph_style.and_then(|value| value.run_style),
3088        ),
3089        theme_colors,
3090        theme_fonts,
3091    }
3092}
3093
3094pub fn clone_paragraph_style_definition(
3095    style: &ParagraphStyleDefinition,
3096) -> ParagraphStyleDefinition {
3097    style.clone()
3098}
3099
3100pub fn clone_numbering_definitions(definitions: &NumberingDefinitionSet) -> NumberingDefinitionSet {
3101    definitions.clone()
3102}
3103
3104#[cfg(test)]
3105mod table_box_spacing_tests {
3106    use super::parse_table_box_spacing;
3107
3108    #[test]
3109    fn parses_start_end_aliases() {
3110        let xml =
3111            r#"<w:tcMar><w:start w:w="0" w:type="dxa"/><w:end w:w="0" w:type="dxa"/></w:tcMar>"#;
3112        let spacing = parse_table_box_spacing(xml).expect("spacing");
3113        assert_eq!(spacing.left_twips, Some(0));
3114        assert_eq!(spacing.right_twips, Some(0));
3115        assert_eq!(spacing.top_twips, None);
3116        assert_eq!(spacing.bottom_twips, None);
3117    }
3118}
3119
3120#[cfg(all(test, not(target_arch = "wasm32")))]
3121mod document_style_cache_tests {
3122    #[test]
3123    fn shared_definitions_track_references_theme_changes_and_eviction() {
3124        for selector in (0..12).chain((0..12).rev()) {
3125            let selected = if selector % 2 == 0 { "a" } else { "b" };
3126            let font = if selector % 3 == 0 { "Serif" } else { "Sans" };
3127            let bold = if selector % 4 == 0 { "0" } else { "1" };
3128            let mut package = crate::package::OoxmlPackage {
3129                parts: Default::default(),
3130                binary_assets: Default::default(),
3131                warnings: vec![],
3132            };
3133            for (name, content) in [
3134                ("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>")),
3135                ("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>")),
3136                ("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>")),
3137            ] { package.parts.insert(name.into(), crate::package::OoxmlPart { name: name.into(), content }); }
3138            let expected = super::parse_document_style_sheet(&package);
3139            let actual = super::acquire_document_style_sheet(&package);
3140            assert_eq!(&*actual, &expected);
3141            let (mut metadata, _) = super::release_document_style_metadata(actual);
3142            if let Some(style) = metadata.first_mut() {
3143                style.name = "Changed".into();
3144            }
3145            assert_eq!(&*super::acquire_document_style_sheet(&package), &expected);
3146        }
3147    }
3148}
3149
3150
3151#[cfg(all(test, not(target_arch = "wasm32")))]
3152mod bounded_large_definition_tests {
3153    use super::*;
3154
3155    #[test]
3156    fn large_definitions_preserve_values_and_resident_budget() {
3157        for value in ["first", "second", "third", "first"] {
3158            let mut pkg = crate::package::OoxmlPackage {
3159                parts: Default::default(), binary_assets: Default::default(), warnings: vec![],
3160            };
3161            let name = format!("{value}{}", "x".repeat(140 * 1024));
3162            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>");
3163            pkg.parts.insert("word/styles.xml".into(), crate::package::OoxmlPart { name: "word/styles.xml".into(), content });
3164            let expected = parse_document_style_sheet(&pkg);
3165            for _ in 0..2 {
3166                assert_eq!(*acquire_document_style_sheet(&pkg), expected);
3167                DOCUMENT_STYLE_CACHE.read(|cache| {
3168                    assert!(cache.len() <= 4);
3169                    assert!(cache.iter().map(|entry| entry.source_bytes).sum::<usize>() <= 512 * 1024);
3170                });
3171            }
3172        }
3173    }
3174}