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