1use std::cell::OnceCell;
4use std::collections::HashMap;
5
6use crate::model::*;
7use crate::package::{with_part, OoxmlPackage, OoxmlPart};
8use crate::xml::{decode_xml_entities, extract_balanced_tag_ranges, get_attribute};
9use crate::zip::{create_minimal_docx_package, package_to_bytes};
10
11const REL_TYPE_IMAGE: &str =
12 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
13const REL_TYPE_HYPERLINK: &str =
14 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
15const REL_TYPE_COMMENTS: &str =
16 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
17const REL_TYPE_COMMENTS_EXTENDED: &str =
18 "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
19const REL_TYPE_SETTINGS: &str =
20 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
21const RELS_XMLNS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
22const WORD_MAIN_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
23const OFFICE_REL_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
24const DRAWING_MAIN_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
25const DRAWING_WORD_NS: &str =
26 "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
27const DRAWING_PICTURE_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/picture";
28const WORD_2010_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordml";
29const WORD_2012_NS: &str = "http://schemas.microsoft.com/office/word/2012/wordml";
30const MARKUP_COMPATIBILITY_NS: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
31
32const COMMENTS_CONTENT_TYPE: &str =
33 "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml";
34const COMMENTS_EXTENDED_CONTENT_TYPE: &str =
35 "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml";
36const SETTINGS_CONTENT_TYPE: &str =
37 "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
38
39const SETTINGS_TAIL_ORDER: &[&str] = &[
40 "w:defaultTabStop",
41 "w:autoHyphenation",
42 "w:consecutiveHyphenLimit",
43 "w:hyphenationZone",
44 "w:doNotHyphenateCaps",
45 "w:showEnvelope",
46 "w:summaryLength",
47 "w:clickAndTypeStyle",
48 "w:defaultTableStyle",
49 "w:evenAndOddHeaders",
50 "w:bookFoldRevPrinting",
51 "w:bookFoldPrinting",
52 "w:bookFoldPrintingSheets",
53 "w:drawingGridHorizontalSpacing",
54 "w:drawingGridVerticalSpacing",
55 "w:displayHorizontalDrawingGridEvery",
56 "w:displayVerticalDrawingGridEvery",
57 "w:doNotUseMarginsForDrawingGridOrigin",
58 "w:drawingGridHorizontalOrigin",
59 "w:drawingGridVerticalOrigin",
60 "w:doNotShadeFormData",
61 "w:noPunctuationKerning",
62 "w:characterSpacingControl",
63 "w:printTwoOnOne",
64 "w:strictFirstAndLastChars",
65 "w:noLineBreaksAfter",
66 "w:noLineBreaksBefore",
67 "w:savePreviewPicture",
68 "w:doNotValidateAgainstSchema",
69 "w:saveInvalidXml",
70 "w:ignoreMixedContent",
71 "w:alwaysShowPlaceholderText",
72 "w:doNotDemarcateInvalidXml",
73 "w:saveXmlDataOnly",
74 "w:useXSLTWhenSaving",
75 "w:saveThroughXslt",
76 "w:showXMLTags",
77 "w:alwaysMergeEmptyNamespace",
78 "w:updateFields",
79 "w:hdrShapeDefaults",
80 "w:footnotePr",
81 "w:endnotePr",
82 "w:compat",
83 "w:docVars",
84 "w:rsids",
85 "m:mathPr",
86 "w:uiCompat97To2003",
87 "w:attachedSchema",
88 "w:themeFontLang",
89 "w:clrSchemeMapping",
90 "w:doNotIncludeSubdocsInStats",
91 "w:doNotAutoCompressPictures",
92 "w:forceUpgrade",
93 "w:captions",
94 "w:readModeInkLockDown",
95 "sl:schemaLibrary",
96 "w:shapeDefaults",
97 "w:decimalSymbol",
98 "w:listSeparator",
99 "w14:docId",
100 "w14:discardImageEditingData",
101 "w14:defaultImageDpi",
102 "w14:conflictMode",
103 "w15:chartTrackingRefBased",
104 "w15:docId",
105];
106
107const DEFAULT_SECTION_PROPERTIES_XML: &str = r#"<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr>"#;
108
109const MIME_BY_EXTENSION: &[(&str, &str)] = &[
110 ("png", "image/png"),
111 ("jpg", "image/jpeg"),
112 ("jpeg", "image/jpeg"),
113 ("gif", "image/gif"),
114 ("bmp", "image/bmp"),
115 ("webp", "image/webp"),
116 ("svg", "image/svg+xml"),
117];
118
119const HIGHLIGHT_TO_WORD: &[(&str, &str)] = &[
120 ("#ffff00", "yellow"),
121 ("#ff0000", "red"),
122 ("#00ff00", "green"),
123 ("#00ffff", "cyan"),
124 ("#0000ff", "blue"),
125 ("#ff00ff", "magenta"),
126 ("#000000", "black"),
127 ("#ffffff", "white"),
128 ("#808080", "darkGray"),
129 ("#d3d3d3", "lightGray"),
130 ("yellow", "yellow"),
131 ("red", "red"),
132 ("green", "green"),
133 ("cyan", "cyan"),
134 ("blue", "blue"),
135 ("magenta", "magenta"),
136 ("black", "black"),
137 ("white", "white"),
138 ("darkgray", "darkGray"),
139 ("lightgray", "lightGray"),
140];
141
142const WORD_HIGHLIGHT_HEX_VALUES: &[(&str, &str)] = &[
143 ("#ffff00", "yellow"),
144 ("#ff0000", "red"),
145 ("#00ff00", "green"),
146 ("#00ffff", "cyan"),
147 ("#0000ff", "blue"),
148 ("#ff00ff", "magenta"),
149 ("#000000", "black"),
150 ("#ffffff", "white"),
151 ("#808080", "darkGray"),
152 ("#d3d3d3", "lightGray"),
153];
154
155#[derive(Clone, Debug, PartialEq, Eq)]
156struct Relationship {
157 id: String,
158 r#type: String,
159 target: String,
160 target_mode: Option<String>,
161}
162
163struct ImageSerializationState<'a> {
164 owner_part_name: String,
165 next_image_index: i64,
166 next_relationship_index: i64,
167 relationships: Vec<Relationship>,
168 relationship_by_target: HashMap<String, Relationship>,
169 pkg: &'a mut OoxmlPackage,
170 theme_fonts: OnceCell<crate::parse::ThemeFontMap>,
171 paragraph_text_alignment_by_style_id: HashMap<String, ParagraphTextAlignment>,
172 default_paragraph_text_alignment: Option<ParagraphTextAlignment>,
173 paragraph_style_alignment_owners: HashMap<String, ParagraphTextAlignment>,
174 table_styles: HashMap<String, crate::parse::ParsedTableStyleDefinition>,
175 current_table_text_alignment: Option<ParagraphTextAlignment>,
176 paragraph_snap_to_grid_by_style_id: HashMap<String, bool>,
177 default_paragraph_snap_to_grid: Option<bool>,
178 paragraph_style_grid_owners: HashMap<String, bool>,
179 current_table_snap_to_grid: Option<bool>,
180}
181
182fn escape_xml(text: &str) -> String {
183 text.replace('&', "&")
184 .replace('<', "<")
185 .replace('>', ">")
186 .replace('"', """)
187 .replace('\'', "'")
188}
189
190fn should_preserve_whitespace(text: &str) -> bool {
191 text.starts_with(char::is_whitespace)
192 || text.ends_with(char::is_whitespace)
193 || text
194 .as_bytes()
195 .windows(2)
196 .any(|window| window[0].is_ascii_whitespace() && window[1].is_ascii_whitespace())
197}
198
199fn render_text_tokens(text: &str) -> String {
200 let mut tokens: Vec<String> = Vec::new();
201 let mut buffer = String::new();
202
203 let flush_buffer = |buffer: &mut String, tokens: &mut Vec<String>| {
204 if buffer.is_empty() {
205 return;
206 }
207 let preserve = if should_preserve_whitespace(buffer) {
208 r#" xml:space="preserve""#
209 } else {
210 ""
211 };
212 tokens.push(format!("<w:t{preserve}>{}</w:t>", escape_xml(buffer)));
213 buffer.clear();
214 };
215
216 for character in text.chars() {
217 if character == '\n' {
218 flush_buffer(&mut buffer, &mut tokens);
219 tokens.push("<w:br/>".to_string());
220 continue;
221 }
222 if character == '\t' {
223 flush_buffer(&mut buffer, &mut tokens);
224 tokens.push("<w:tab/>".to_string());
225 continue;
226 }
227 buffer.push(character);
228 }
229
230 flush_buffer(&mut buffer, &mut tokens);
231 if tokens.is_empty() {
232 tokens.push("<w:t/>".to_string());
233 }
234
235 tokens.join("")
236}
237
238fn normalize_hex_color(value: &str) -> Option<String> {
239 let trimmed = value.trim().to_ascii_lowercase();
240 if let Some(caps) = trimmed.strip_prefix('#').and_then(|rest| {
241 if rest.len() == 3 && rest.chars().all(|ch| ch.is_ascii_hexdigit()) {
242 let chars: Vec<char> = rest.chars().collect();
243 Some(format!(
244 "#{}{}{}{}{}{}",
245 chars[0], chars[0], chars[1], chars[1], chars[2], chars[2]
246 ))
247 } else {
248 None
249 }
250 }) {
251 return Some(caps);
252 }
253
254 if let Some(rest) = trimmed.strip_prefix('#') {
255 if rest.len() == 6 && rest.chars().all(|ch| ch.is_ascii_hexdigit()) {
256 return Some(format!("#{rest}"));
257 }
258 }
259
260 None
261}
262
263fn parse_hsl_color(value: &str) -> Option<(f64, f64, f64)> {
264 let trimmed = value.trim();
265 let open = trimmed.find("hsl")?;
266 let inner = trimmed[open + 3..].trim_start_matches('a').trim();
267 let inner = inner.strip_prefix('(')?.strip_suffix(')')?;
268 let parts: Vec<&str> = inner.split('/').collect();
269 let main = parts[0];
270 let components: Vec<&str> = main.split(',').map(str::trim).collect();
271 if components.len() < 3 {
272 return None;
273 }
274 let h: f64 = components[0].parse().ok()?;
275 let s_str = components[1].trim_end_matches('%');
276 let l_str = components[2].trim_end_matches('%');
277 let s: f64 = s_str.parse().ok()?;
278 let l: f64 = l_str.parse().ok()?;
279 if !h.is_finite() || !s.is_finite() || !l.is_finite() {
280 return None;
281 }
282 Some((h, s, l))
283}
284
285fn hsl_to_hex(h: f64, s: f64, l: f64) -> String {
286 let hue = ((h % 360.0) + 360.0) % 360.0;
287 let saturation = (s.min(100.0).max(0.0)) / 100.0;
288 let lightness = (l.min(100.0).max(0.0)) / 100.0;
289 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
290 let hue_prime = hue / 60.0;
291 let x = chroma * (1.0 - ((hue_prime % 2.0) - 1.0).abs());
292 let (mut r, mut g, mut b) = (0.0, 0.0, 0.0);
293
294 if hue_prime >= 0.0 && hue_prime < 1.0 {
295 r = chroma;
296 g = x;
297 } else if hue_prime < 2.0 {
298 r = x;
299 g = chroma;
300 } else if hue_prime < 3.0 {
301 g = chroma;
302 b = x;
303 } else if hue_prime < 4.0 {
304 g = x;
305 b = chroma;
306 } else if hue_prime < 5.0 {
307 r = x;
308 b = chroma;
309 } else {
310 r = chroma;
311 b = x;
312 }
313
314 let m = lightness - chroma / 2.0;
315 let to_hex =
316 |channel: f64| -> String { format!("{:02x}", ((channel + m) * 255.0).round() as i64) };
317
318 format!("#{}{}{}", to_hex(r), to_hex(g), to_hex(b))
319}
320
321fn color_distance(a: &str, b: &str) -> f64 {
322 let parse = |hex: &str| -> (f64, f64, f64) {
323 let bytes = &hex.as_bytes()[1..];
324 let r = i64::from_str_radix(std::str::from_utf8(&bytes[0..2]).unwrap_or("00"), 16)
325 .unwrap_or(0) as f64;
326 let g = i64::from_str_radix(std::str::from_utf8(&bytes[2..4]).unwrap_or("00"), 16)
327 .unwrap_or(0) as f64;
328 let b = i64::from_str_radix(std::str::from_utf8(&bytes[4..6]).unwrap_or("00"), 16)
329 .unwrap_or(0) as f64;
330 (r, g, b)
331 };
332 let (ar, ag, ab) = parse(a);
333 let (br, bg, bb) = parse(b);
334 let dr = ar - br;
335 let dg = ag - bg;
336 let db = ab - bb;
337 dr * dr + dg * dg + db * db
338}
339
340fn closest_word_highlight(hex: &str) -> Option<&'static str> {
341 let mut best: Option<&'static str> = None;
342 let mut best_distance = f64::INFINITY;
343
344 for (candidate_hex, candidate_value) in WORD_HIGHLIGHT_HEX_VALUES {
345 let distance = color_distance(hex, candidate_hex);
346 if distance < best_distance {
347 best_distance = distance;
348 best = Some(candidate_value);
349 }
350 }
351
352 best
353}
354
355fn highlight_lookup(normalized: &str) -> Option<&'static str> {
356 HIGHLIGHT_TO_WORD
357 .iter()
358 .find(|(key, _)| *key == normalized)
359 .map(|(_, value)| *value)
360}
361
362fn normalize_word_highlight(value: Option<&str>) -> Option<String> {
363 let value = value?;
364 let normalized = value.trim().to_ascii_lowercase();
365 if normalized.is_empty() {
366 return None;
367 }
368
369 if let Some(mapped) = highlight_lookup(&normalized) {
370 return Some(mapped.to_string());
371 }
372
373 if let Some(hex) = normalize_hex_color(&normalized) {
374 return highlight_lookup(&hex)
375 .or_else(|| closest_word_highlight(&hex))
376 .map(str::to_string);
377 }
378
379 if let Some((h, s, l)) = parse_hsl_color(&normalized) {
380 let hsl_hex = hsl_to_hex(h, s, l);
381 return highlight_lookup(&hsl_hex)
382 .or_else(|| closest_word_highlight(&hsl_hex))
383 .map(str::to_string);
384 }
385
386 None
387}
388
389fn serialized_font_theme<'a>(
390 family: Option<&str>,
391 resolved: Option<&str>,
392 theme: Option<&'a str>,
393 state: &ImageSerializationState<'_>,
394) -> Option<&'a str> {
395 if let (Some(family), Some(resolved), Some(theme)) = (family, resolved, theme) {
396 if family == resolved {
397 let fonts = state.theme_fonts.get_or_init(|| crate::parse::parse_theme_fonts(state.pkg));
398 if crate::parse::resolve_theme_font(Some(theme), fonts)
399 .is_some_and(|value| value != resolved)
400 {
401 return None;
402 }
403 }
404 }
405 theme
406}
407
408fn run_properties_xml(style: Option<&TextStyle>, state: &ImageSerializationState<'_>) -> String {
409 let Some(style) = style else {
410 return String::new();
411 };
412
413 let mut fragments: Vec<String> = Vec::new();
414 if style.bold == Some(true) {
415 fragments.push("<w:b/>".to_string());
416 }
417 if style.italic == Some(true) {
418 fragments.push("<w:i/>".to_string());
419 }
420 if style.underline == Some(true) {
421 fragments.push(r#"<w:u w:val="single"/>"#.to_string());
422 }
423 if style.strike == Some(true) {
424 fragments.push("<w:strike/>".to_string());
425 }
426 for (name, value) in [("caps", style.all_caps), ("smallCaps", style.small_caps)] {
427 if value.is_some() {
428 fragments.push(on_off_element_xml(name, value));
429 }
430 }
431 if let Some(color) = &style.color {
432 fragments.push(format!(r#"<w:color w:val="{}"/>"#, color.replace('#', "")));
433 }
434
435 if let Some(highlight) = normalize_word_highlight(style.highlight.as_deref()) {
436 fragments.push(format!(r#"<w:highlight w:val="{highlight}"/>"#));
437 }
438
439 if let Some(background_color) = &style.background_color {
440 fragments.push(format!(
441 r#"<w:shd w:val="clear" w:color="auto" w:fill="{}"/>"#,
442 background_color.replace('#', "").to_ascii_uppercase()
443 ));
444 }
445
446 if let Some(spacing) = style.character_spacing_twips {
447 fragments.push(format!(r#"<w:spacing w:val="{spacing}"/>"#));
448 }
449
450 let percent = style.character_scale_percent.filter(|value| *value <= 600);
451 let original_scale = style
452 .source_character_scale
453 .as_deref()
454 .filter(|raw| crate::parse::parse_character_scale_percent_value(raw) == percent);
455 if let Some(raw) = original_scale {
456 fragments.push(format!(r#"<w:w w:val="{}"/>"#, escape_xml(raw)));
457 } else if let Some(percent) = percent {
458 fragments.push(format!(r#"<w:w w:val="{percent}"/>"#));
459 }
460
461 if let Some(kerning_min_font_size_pt) = style.kerning_min_font_size_pt {
462 if kerning_min_font_size_pt.is_finite() && kerning_min_font_size_pt >= 0.0 {
463 fragments.push(format!(
464 r#"<w:kern w:val="{}"/>"#,
465 (kerning_min_font_size_pt * 2.0).round() as i64
466 ));
467 }
468 }
469
470 if let Some(font_size_pt) = style.font_size_pt {
471 if font_size_pt.is_finite() {
472 fragments.push(format!(
473 r#"<w:sz w:val="{}"/>"#,
474 (font_size_pt * 2.0).round() as i64
475 ));
476 }
477 }
478 if let Some(font_size_pt) = style.font_size_cs_pt {
479 if font_size_pt.is_finite() {
480 fragments.push(format!(
481 r#"<w:szCs w:val="{}"/>"#,
482 (font_size_pt * 2.0).round() as i64
483 ));
484 }
485 }
486
487 let has_explicit_font_slots = style.font_family_ascii.is_some()
488 || style.font_family_h_ansi.is_some()
489 || style.font_family_east_asia.is_some()
490 || style.font_family_cs.is_some()
491 || style.font_theme_ascii.is_some()
492 || style.font_theme_h_ansi.is_some()
493 || style.font_theme_east_asia.is_some()
494 || style.font_theme_cs.is_some()
495 || style.font_hint.is_some();
496 let font_family_was_edited = style.source_font_family.is_some()
497 && style.font_family.as_deref() != style.source_font_family.as_deref();
498 let mut font_attrs = Vec::new();
499 if font_family_was_edited {
500 if let Some(font_family) = &style.font_family {
501 let escaped_font = escape_xml(font_family);
502 font_attrs.extend([
503 format!(r#"w:ascii="{escaped_font}""#),
504 format!(r#"w:hAnsi="{escaped_font}""#),
505 format!(r#"w:cs="{escaped_font}""#),
506 ]);
507 }
508 } else if has_explicit_font_slots {
509 for (name, value) in [
510 ("w:ascii", style.font_family_ascii.as_deref()),
511 ("w:hAnsi", style.font_family_h_ansi.as_deref()),
512 ("w:eastAsia", style.font_family_east_asia.as_deref()),
513 ("w:cs", style.font_family_cs.as_deref()),
514 ("w:asciiTheme", serialized_font_theme(
515 style.font_family_ascii.as_deref(),
516 style.resolved_font_family_ascii.as_deref(),
517 style.font_theme_ascii.as_deref(),
518 state,
519 )),
520 ("w:hAnsiTheme", serialized_font_theme(
521 style.font_family_h_ansi.as_deref(),
522 style.resolved_font_family_h_ansi.as_deref(),
523 style.font_theme_h_ansi.as_deref(),
524 state,
525 )),
526 ("w:eastAsiaTheme", serialized_font_theme(
527 style.font_family_east_asia.as_deref(),
528 style.resolved_font_family_east_asia.as_deref(),
529 style.font_theme_east_asia.as_deref(),
530 state,
531 )),
532 ("w:csTheme", serialized_font_theme(
533 style.font_family_cs.as_deref(),
534 style.resolved_font_family_cs.as_deref(),
535 style.font_theme_cs.as_deref(),
536 state,
537 )),
538 ("w:hint", style.font_hint.as_deref()),
539 ] {
540 if let Some(value) = value {
541 font_attrs.push(format!(r#"{name}="{}""#, escape_xml(value)));
542 }
543 }
544 } else if let Some(font_family) = &style.font_family {
545 let escaped_font = escape_xml(font_family);
546 font_attrs.extend([
547 format!(r#"w:ascii="{escaped_font}""#),
548 format!(r#"w:hAnsi="{escaped_font}""#),
549 format!(r#"w:cs="{escaped_font}""#),
550 ]);
551 }
552 if !font_attrs.is_empty() {
553 fragments.push(format!("<w:rFonts {}/>", font_attrs.join(" ")));
554 }
555
556 let mut language_attrs = Vec::new();
557 for (name, value) in [
558 ("w:val", style.language.as_deref()),
559 ("w:eastAsia", style.language_east_asia.as_deref()),
560 ("w:bidi", style.language_bidi.as_deref()),
561 ] {
562 if let Some(value) = value {
563 language_attrs.push(format!(r#"{name}="{}""#, escape_xml(value)));
564 }
565 }
566 if !language_attrs.is_empty() {
567 fragments.push(format!("<w:lang {}/>", language_attrs.join(" ")));
568 }
569
570 if let Some(right_to_left) = style.right_to_left {
571 fragments.push(if right_to_left {
572 "<w:rtl/>".to_string()
573 } else {
574 r#"<w:rtl w:val="0"/>"#.to_string()
575 });
576 }
577 if let Some(complex_script) = style.complex_script {
578 fragments.push(if complex_script {
579 "<w:cs/>".to_string()
580 } else {
581 r#"<w:cs w:val="0"/>"#.to_string()
582 });
583 }
584
585 if let Some(vertical_align) = style.vertical_align {
586 let val = match vertical_align {
587 VerticalAlign::Superscript => "superscript",
588 VerticalAlign::Subscript => "subscript",
589 };
590 fragments.push(format!(r#"<w:vertAlign w:val="{val}"/>"#));
591 }
592
593 if let Some(run_border) = &style.run_border {
594 if !run_border.border_type.trim().is_empty() {
595 let mut attrs = vec![format!(
596 r#"w:val="{}""#,
597 escape_xml(&run_border.border_type.trim().to_ascii_lowercase())
598 )];
599 if let Some(size) = run_border.size_eighth_pt {
600 if size.is_finite() && size >= 0.0 {
601 attrs.push(format!(r#"w:sz="{}""#, size.round() as i64));
602 }
603 }
604 if let Some(space) = run_border.space_pt {
605 if space.is_finite() && space >= 0.0 {
606 attrs.push(format!(r#"w:space="{}""#, space.round() as i64));
607 }
608 }
609 let color = run_border
610 .color
611 .as_deref()
612 .map(|c| c.replace('#', "").to_ascii_uppercase())
613 .unwrap_or_else(|| "auto".to_string());
614 attrs.push(format!(r#"w:color="{color}""#));
615 if let Some(frame) = run_border.frame {
616 attrs.push(format!(r#"w:frame="{}""#, if frame { "1" } else { "0" }));
617 }
618 if let Some(shadow) = run_border.shadow {
619 attrs.push(format!(r#"w:shadow="{}""#, if shadow { "1" } else { "0" }));
620 }
621 fragments.push(format!("<w:bdr {}/>", attrs.join(" ")));
622 }
623 }
624
625 fragments.extend(crate::parse::text_effects::serialize(style));
626
627 if let Some(ligatures) = style.ligatures {
628 fragments.push(format!(
629 r#"<w14:ligatures w14:val="{}"/>"#,
630 ligatures.xml_value()
631 ));
632 }
633 if let Some(contextual_alternates) = style.contextual_alternates {
634 fragments.push(format!(
635 r#"<w14:cntxtAlts w14:val="{}"/>"#,
636 if contextual_alternates { "1" } else { "0" }
637 ));
638 }
639
640 if fragments.is_empty() {
641 String::new()
642 } else {
643 format!("<w:rPr>{}</w:rPr>", fragments.join(""))
644 }
645}
646
647fn paragraph_border_edge_xml(side: &str, border: Option<&ParagraphBorderStyle>) -> String {
648 let Some(border) = border else {
649 return String::new();
650 };
651 let border_type = border.border_type.trim().to_ascii_lowercase();
652 if border_type.is_empty() {
653 return String::new();
654 }
655
656 let mut attrs = vec![format!(r#"w:val="{border_type}""#)];
657 if let Some(size) = border.size_eighth_pt {
658 if size.is_finite() && size >= 0.0 {
659 attrs.push(format!(r#"w:sz="{}""#, size.round() as i64));
660 }
661 }
662 if let Some(space) = border.space_pt {
663 if space.is_finite() && space >= 0.0 {
664 attrs.push(format!(r#"w:space="{}""#, space.round() as i64));
665 }
666 }
667 let color = border
668 .color
669 .as_deref()
670 .map(|c| c.replace('#', "").to_ascii_uppercase())
671 .unwrap_or_else(|| "auto".to_string());
672 attrs.push(format!(r#"w:color="{color}""#));
673 if let Some(frame) = border.frame {
674 attrs.push(format!(r#"w:frame="{}""#, if frame { "1" } else { "0" }));
675 }
676 if let Some(shadow) = border.shadow {
677 attrs.push(format!(r#"w:shadow="{}""#, if shadow { "1" } else { "0" }));
678 }
679
680 format!("<w:{side} {}/>", attrs.join(" "))
681}
682
683fn paragraph_borders_xml(borders: Option<&ParagraphBorderSet>) -> String {
684 let Some(borders) = borders else {
685 return String::new();
686 };
687
688 let fragments = [
689 paragraph_border_edge_xml("top", borders.top.as_ref()),
690 paragraph_border_edge_xml("left", borders.left.as_ref()),
691 paragraph_border_edge_xml("bottom", borders.bottom.as_ref()),
692 paragraph_border_edge_xml("right", borders.right.as_ref()),
693 paragraph_border_edge_xml("between", borders.between.as_ref()),
694 paragraph_border_edge_xml("bar", borders.bar.as_ref()),
695 ]
696 .into_iter()
697 .filter(|fragment| !fragment.is_empty())
698 .collect::<Vec<_>>();
699
700 if fragments.is_empty() {
701 String::new()
702 } else {
703 format!("<w:pBdr>{}</w:pBdr>", fragments.join(""))
704 }
705}
706
707fn paragraph_alignment_str(align: ParagraphAlignment) -> &'static str {
708 match align {
709 ParagraphAlignment::Left => "left",
710 ParagraphAlignment::Center => "center",
711 ParagraphAlignment::Right => "right",
712 ParagraphAlignment::Justify => "both",
714 }
715}
716
717fn heading_level_number(level: HeadingLevel) -> u8 {
718 match level {
719 HeadingLevel::One => 1,
720 HeadingLevel::Two => 2,
721 HeadingLevel::Three => 3,
722 HeadingLevel::Four => 4,
723 HeadingLevel::Five => 5,
724 HeadingLevel::Six => 6,
725 HeadingLevel::Seven => 7,
726 HeadingLevel::Eight => 8,
727 HeadingLevel::Nine => 9,
728 }
729}
730
731fn paragraph_line_rule_str(rule: ParagraphLineRule) -> &'static str {
732 match rule {
733 ParagraphLineRule::Auto => "auto",
734 ParagraphLineRule::Exact => "exact",
735 ParagraphLineRule::AtLeast => "atLeast",
736 }
737}
738
739fn tab_stop_alignment_str(alignment: ParagraphTabStopAlignment) -> &'static str {
740 match alignment {
741 ParagraphTabStopAlignment::Clear => "clear",
742 ParagraphTabStopAlignment::Left => "left",
743 ParagraphTabStopAlignment::Center => "center",
744 ParagraphTabStopAlignment::Right => "right",
745 ParagraphTabStopAlignment::Decimal => "decimal",
746 ParagraphTabStopAlignment::Bar => "bar",
747 }
748}
749
750fn tab_stop_leader_str(leader: ParagraphTabStopLeader) -> &'static str {
751 match leader {
752 ParagraphTabStopLeader::LeaderNone => "none",
753 ParagraphTabStopLeader::Dot => "dot",
754 ParagraphTabStopLeader::Hyphen => "hyphen",
755 ParagraphTabStopLeader::Underscore => "underscore",
756 ParagraphTabStopLeader::MiddleDot => "middleDot",
757 }
758}
759
760fn on_off_element_xml(tag_name: &str, value: Option<bool>) -> String {
761 match value {
762 Some(true) => format!("<w:{tag_name}/>"),
763 Some(false) => format!(r#"<w:{tag_name} w:val="0"/>"#),
764 None => String::new(),
765 }
766}
767
768fn paragraph_properties_xml(
769 style: Option<&ParagraphStyle>,
770 text_alignment: Option<ParagraphTextAlignment>,
771 snap_to_grid: Option<bool>,
772) -> String {
773 let Some(style) = style else {
774 return String::new();
775 };
776
777 let mut fragments: Vec<String> = Vec::new();
778 let paragraph_style_id = style.style_id.clone().or_else(|| {
779 style
780 .heading_level
781 .map(|level| format!("Heading{}", heading_level_number(level)))
782 });
783 if let Some(paragraph_style_id) = paragraph_style_id {
784 fragments.push(format!(
785 r#"<w:pStyle w:val="{}"/>"#,
786 escape_xml(¶graph_style_id)
787 ));
788 }
789
790 for fragment in [
791 on_off_element_xml("keepNext", style.keep_next),
792 on_off_element_xml("keepLines", style.keep_lines),
793 on_off_element_xml("pageBreakBefore", style.page_break_before),
794 ] {
795 if !fragment.is_empty() {
796 fragments.push(fragment);
797 }
798 }
799
800 if let Some(drop_cap) = &style.drop_cap {
801 let mut frame_fragments = vec![format!(
802 r#"w:dropCap="{}""#,
803 escape_xml(match drop_cap.drop_cap_type {
804 ParagraphDropCapType::Drop => "drop",
805 ParagraphDropCapType::Margin => "margin",
806 })
807 )];
808 if let Some(lines) = drop_cap.lines {
809 if lines > 0 {
810 frame_fragments.push(format!(r#"w:lines="{lines}""#));
811 }
812 }
813 if let Some(wrap) = &drop_cap.wrap {
814 frame_fragments.push(format!(r#"w:wrap="{}""#, escape_xml(wrap)));
815 }
816 if let Some(horizontal_anchor) = &drop_cap.horizontal_anchor {
817 frame_fragments.push(format!(r#"w:hAnchor="{}""#, escape_xml(horizontal_anchor)));
818 }
819 if let Some(vertical_anchor) = &drop_cap.vertical_anchor {
820 frame_fragments.push(format!(r#"w:vAnchor="{}""#, escape_xml(vertical_anchor)));
821 }
822 if let Some(x) = twips_to_xml_non_negative(drop_cap.x_twips) {
823 frame_fragments.push(format!(r#"w:x="{x}""#));
824 }
825 if let Some(y) = twips_to_xml_non_negative(drop_cap.y_twips) {
826 frame_fragments.push(format!(r#"w:y="{y}""#));
827 }
828 if let Some(h_space) = twips_to_xml_non_negative(drop_cap.horizontal_space_twips) {
829 frame_fragments.push(format!(r#"w:hSpace="{h_space}""#));
830 }
831 if let Some(v_space) = twips_to_xml_non_negative(drop_cap.vertical_space_twips) {
832 frame_fragments.push(format!(r#"w:vSpace="{v_space}""#));
833 }
834 fragments.push(format!("<w:framePr {}/>", frame_fragments.join(" ")));
835 }
836
837 let widow_control_xml = on_off_element_xml("widowControl", style.widow_control);
838 if !widow_control_xml.is_empty() {
839 fragments.push(widow_control_xml);
840 }
841
842 if let Some(numbering) = &style.numbering {
843 if numbering.num_id > 0 {
844 let ilvl = numbering.ilvl.max(0);
845 let num_id = numbering.num_id;
846 fragments.push(format!(
847 r#"<w:numPr><w:ilvl w:val="{ilvl}"/><w:numId w:val="{num_id}"/></w:numPr>"#
848 ));
849 }
850 }
851
852 let paragraph_border_xml = paragraph_borders_xml(style.borders.as_ref());
853 if !paragraph_border_xml.is_empty() {
854 fragments.push(paragraph_border_xml);
855 }
856
857 if let Some(background_color) = &style.background_color {
858 let fill = background_color.replace('#', "").to_ascii_uppercase();
859 if !fill.is_empty() {
860 fragments.push(format!(
861 r#"<w:shd w:val="clear" w:color="auto" w:fill="{}"/>"#,
862 escape_xml(&fill)
863 ));
864 }
865 }
866
867 if let Some(tab_stops) = &style.tab_stops {
868 let tabs = tab_stops
869 .iter()
870 .filter_map(|tab_stop| {
871 let position = tab_stop.position_twips?;
872 let alignment = tab_stop_alignment_str(
873 tab_stop
874 .alignment
875 .unwrap_or(ParagraphTabStopAlignment::Left),
876 );
877 let leader = tab_stop
878 .leader
879 .filter(|leader| *leader != ParagraphTabStopLeader::LeaderNone)
880 .map(|leader| format!(r#" w:leader="{}""#, tab_stop_leader_str(leader)))
881 .unwrap_or_default();
882 Some(format!(
883 r#"<w:tab w:val="{alignment}"{leader} w:pos="{position}"/>"#
884 ))
885 })
886 .collect::<Vec<_>>();
887 if !tabs.is_empty() {
888 fragments.push(format!("<w:tabs>{}</w:tabs>", tabs.join("")));
889 }
890 }
891
892 let snap_to_grid_xml = on_off_element_xml("snapToGrid", snap_to_grid);
893 if !snap_to_grid_xml.is_empty() {
894 fragments.push(snap_to_grid_xml);
895 }
896 let mut spacing_fragments: Vec<String> = Vec::new();
897 if let Some(before) =
898 twips_to_xml_non_negative(style.spacing.as_ref().and_then(|s| s.before_twips))
899 {
900 spacing_fragments.push(format!(r#"w:before="{before}""#));
901 }
902 if let Some(after) =
903 twips_to_xml_non_negative(style.spacing.as_ref().and_then(|s| s.after_twips))
904 {
905 spacing_fragments.push(format!(r#"w:after="{after}""#));
906 }
907 for (name, value) in [
908 (
909 "w:beforeAutospacing",
910 style.spacing.as_ref().and_then(|s| s.before_auto_spacing),
911 ),
912 (
913 "w:afterAutospacing",
914 style.spacing.as_ref().and_then(|s| s.after_auto_spacing),
915 ),
916 ] {
917 if let Some(value) = value {
918 spacing_fragments.push(format!(r#"{name}="{}""#, if value { "1" } else { "0" }));
919 }
920 }
921 if let Some(line) = twips_to_xml_non_negative(style.spacing.as_ref().and_then(|s| s.line_twips))
922 {
923 spacing_fragments.push(format!(r#"w:line="{line}""#));
924 }
925 if let Some(line_rule) = style.spacing.as_ref().and_then(|s| s.line_rule) {
926 let line_rule = match line_rule {
927 ParagraphLineRule::AtLeast => "atLeast",
928 other => paragraph_line_rule_str(other),
929 };
930 spacing_fragments.push(format!(r#"w:lineRule="{line_rule}""#));
931 }
932 if !spacing_fragments.is_empty() {
933 fragments.push(format!("<w:spacing {}/>", spacing_fragments.join(" ")));
934 }
935
936 let mut indent_fragments: Vec<String> = Vec::new();
937 if let Some(left) = style.indent.as_ref().and_then(|i| i.left_twips)
938 {
939 indent_fragments.push(format!(r#"w:left="{left}""#));
940 }
941 if let Some(right) =
942 style.indent.as_ref().and_then(|i| i.right_twips)
943 {
944 indent_fragments.push(format!(r#"w:right="{right}""#));
945 }
946 if let Some(first_line) =
947 twips_to_xml_non_negative(style.indent.as_ref().and_then(|i| i.first_line_twips))
948 {
949 indent_fragments.push(format!(r#"w:firstLine="{first_line}""#));
950 }
951 if let Some(hanging) =
952 twips_to_xml_non_negative(style.indent.as_ref().and_then(|i| i.hanging_twips))
953 {
954 indent_fragments.push(format!(r#"w:hanging="{hanging}""#));
955 }
956 if !indent_fragments.is_empty() {
957 fragments.push(format!("<w:ind {}/>", indent_fragments.join(" ")));
958 }
959
960 let contextual_spacing_xml = on_off_element_xml("contextualSpacing", style.contextual_spacing);
961 if !contextual_spacing_xml.is_empty() {
962 fragments.push(contextual_spacing_xml);
963 }
964
965 if let Some(align) = style.align {
966 fragments.push(format!(
967 r#"<w:jc w:val="{}"/>"#,
968 paragraph_alignment_str(align)
969 ));
970 }
971
972 if let Some(text_alignment) = text_alignment {
973 fragments.push(format!(
974 r#"<w:textAlignment w:val="{}"/>"#,
975 paragraph_text_alignment_str(text_alignment)
976 ));
977 }
978
979 if let Some(level) = style.heading_level {
980 fragments.push(format!(
981 r#"<w:outlineLvl w:val="{}"/>"#,
982 heading_level_number(level) - 1
983 ));
984 }
985
986 if fragments.is_empty() {
987 String::new()
988 } else {
989 format!("<w:pPr>{}</w:pPr>", fragments.join(""))
990 }
991}
992
993fn twips_to_xml(value: Option<i64>) -> Option<i64> {
994 let value = value?;
995 let rounded = value;
996 if rounded > 0 {
997 Some(rounded)
998 } else {
999 None
1000 }
1001}
1002
1003fn twips_to_xml_non_negative(value: Option<i64>) -> Option<i64> {
1004 let value = value?;
1005 if value >= 0 {
1006 Some(value)
1007 } else {
1008 None
1009 }
1010}
1011
1012fn table_box_spacing_xml(spacing: Option<&TableBoxSpacing>, wrapper_tag_name: &str) -> String {
1013 let Some(spacing) = spacing else {
1014 return String::new();
1015 };
1016
1017 let top = twips_to_xml_non_negative(spacing.top_twips);
1018 let right = twips_to_xml_non_negative(spacing.right_twips);
1019 let bottom = twips_to_xml_non_negative(spacing.bottom_twips);
1020 let left = twips_to_xml_non_negative(spacing.left_twips);
1021
1022 let mut edges: Vec<String> = Vec::new();
1023 if let Some(top) = top {
1024 edges.push(format!(r#"<w:top w:w="{top}" w:type="dxa"/>"#));
1025 }
1026 if let Some(right) = right {
1027 edges.push(format!(r#"<w:right w:w="{right}" w:type="dxa"/>"#));
1028 }
1029 if let Some(bottom) = bottom {
1030 edges.push(format!(r#"<w:bottom w:w="{bottom}" w:type="dxa"/>"#));
1031 }
1032 if let Some(left) = left {
1033 edges.push(format!(r#"<w:left w:w="{left}" w:type="dxa"/>"#));
1034 }
1035
1036 if edges.is_empty() {
1037 String::new()
1038 } else {
1039 format!(
1040 "<{wrapper_tag_name}>{}</{wrapper_tag_name}>",
1041 edges.join("")
1042 )
1043 }
1044}
1045
1046fn table_border_edge_xml(side: &str, border: Option<&TableBorderStyle>) -> String {
1047 let Some(border) = border else {
1048 return String::new();
1049 };
1050 let border_type = border.border_type.trim().to_ascii_lowercase();
1051 if border_type.is_empty() {
1052 return String::new();
1053 }
1054
1055 let size = if let Some(size_eighth_pt) = border.size_eighth_pt {
1056 if size_eighth_pt.is_finite() && size_eighth_pt >= 0.0 {
1057 size_eighth_pt.round() as i64
1058 } else if border_type == "none" || border_type == "nil" {
1059 0
1060 } else {
1061 4
1062 }
1063 } else if border_type == "none" || border_type == "nil" {
1064 0
1065 } else {
1066 4
1067 };
1068 let color = border
1069 .color
1070 .as_deref()
1071 .map(|c| c.replace('#', "").to_ascii_uppercase())
1072 .unwrap_or_else(|| "auto".to_string());
1073
1074 format!(r#"<w:{side} w:val="{border_type}" w:sz="{size}" w:space="0" w:color="{color}"/>"#)
1075}
1076
1077fn table_borders_xml(borders: Option<&TableBorderSet>, wrapper_tag_name: &str) -> String {
1078 let Some(borders) = borders else {
1079 return String::new();
1080 };
1081
1082 let edges = [
1083 table_border_edge_xml("top", borders.top.as_ref()),
1084 table_border_edge_xml("left", borders.left.as_ref()),
1085 table_border_edge_xml("bottom", borders.bottom.as_ref()),
1086 table_border_edge_xml("right", borders.right.as_ref()),
1087 table_border_edge_xml("insideH", borders.inside_h.as_ref()),
1088 table_border_edge_xml("insideV", borders.inside_v.as_ref()),
1089 table_border_edge_xml("tl2br", borders.tl2br.as_ref()),
1090 table_border_edge_xml("tr2bl", borders.tr2bl.as_ref()),
1091 ]
1092 .into_iter()
1093 .filter(|edge| !edge.is_empty())
1094 .collect::<Vec<_>>();
1095
1096 if edges.is_empty() {
1097 String::new()
1098 } else {
1099 format!(
1100 "<{wrapper_tag_name}>{}</{wrapper_tag_name}>",
1101 edges.join("")
1102 )
1103 }
1104}
1105
1106fn parse_relationships_xml(xml: &str) -> Vec<Relationship> {
1107 let mut relationships = Vec::new();
1108 let mut search_from = 0;
1109
1110 while let Some(start) = xml[search_from..].find("<Relationship") {
1111 let abs_start = search_from + start;
1112 let Some(end_rel) = xml[abs_start..].find('>') else {
1113 break;
1114 };
1115 let tag = &xml[abs_start..abs_start + end_rel + 1];
1116 search_from = abs_start + end_rel + 1;
1117
1118 let id = extract_attribute(tag, "Id").map(|value| decode_xml_entities(&value));
1119 let rel_type = extract_attribute(tag, "Type").map(|value| decode_xml_entities(&value));
1120 let target = extract_attribute(tag, "Target").map(|value| decode_xml_entities(&value));
1121 let target_mode =
1122 extract_attribute(tag, "TargetMode").map(|value| decode_xml_entities(&value));
1123
1124 if let (Some(id), Some(rel_type), Some(target)) = (id, rel_type, target) {
1125 relationships.push(Relationship {
1126 id,
1127 r#type: rel_type,
1128 target,
1129 target_mode,
1130 });
1131 }
1132 }
1133
1134 relationships
1135}
1136
1137fn extract_attribute(tag: &str, attribute: &str) -> Option<String> {
1138 let patterns = [
1139 format!(r#"{}=""#, attribute),
1140 format!(r#"{}=""#, attribute.to_ascii_lowercase()),
1141 ];
1142 for pattern in &patterns {
1143 if let Some(start) = tag.find(pattern) {
1144 let value_start = start + pattern.len();
1145 if let Some(end) = tag[value_start..].find('"') {
1146 return Some(tag[value_start..value_start + end].to_string());
1147 }
1148 }
1149 }
1150 None
1151}
1152
1153fn normalize_word_part_name(part_name: &str) -> String {
1154 part_name.strip_prefix('/').unwrap_or(part_name).to_string()
1155}
1156
1157fn relationship_part_name_for_word_part(part_name: &str) -> String {
1158 let normalized_part_name = normalize_word_part_name(part_name);
1159 if let Some(slash_index) = normalized_part_name.rfind('/') {
1160 let directory = &normalized_part_name[..slash_index];
1161 let basename = &normalized_part_name[slash_index + 1..];
1162 format!("{directory}/_rels/{basename}.rels")
1163 } else {
1164 format!("_rels/{normalized_part_name}.rels")
1165 }
1166}
1167
1168fn render_relationships_xml(relationships: &[Relationship]) -> String {
1169 let serialized = relationships
1170 .iter()
1171 .map(|relationship| {
1172 let target_mode = relationship
1173 .target_mode
1174 .as_ref()
1175 .map(|mode| format!(r#" TargetMode="{}""#, escape_xml(mode)))
1176 .unwrap_or_default();
1177 format!(
1178 r#"<Relationship Id="{}" Type="{}" Target="{}"{target_mode}/>"#,
1179 escape_xml(&relationship.id),
1180 escape_xml(&relationship.r#type),
1181 escape_xml(&relationship.target)
1182 )
1183 })
1184 .collect::<String>();
1185
1186 format!(
1187 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="{RELS_XMLNS}">{serialized}</Relationships>"#
1188 )
1189}
1190
1191fn strip_rid_prefix(id: &str) -> &str {
1192 if id.len() >= 3 && id[..3].eq_ignore_ascii_case("rId") {
1193 &id[3..]
1194 } else {
1195 id
1196 }
1197}
1198
1199fn next_relationship_index(relationships: &[Relationship]) -> i64 {
1200 relationships
1201 .iter()
1202 .fold(1i64, |largest, relationship| {
1203 let parsed = strip_rid_prefix(&relationship.id)
1204 .parse::<f64>()
1205 .ok()
1206 .filter(|value| value.is_finite());
1207 if let Some(parsed) = parsed {
1208 ((parsed + 1.0).max(largest as f64)) as i64
1209 } else {
1210 largest
1211 }
1212 })
1213 .max(1)
1214}
1215
1216fn decode_base64(input: &str) -> Option<Vec<u8>> {
1217 const DECODE: [i8; 128] = {
1218 let mut table = [-1i8; 128];
1219 let mut i = 0u8;
1220 while i < 26 {
1221 table[(b'A' + i) as usize] = i as i8;
1222 table[(b'a' + i) as usize] = (i + 26) as i8;
1223 i += 1;
1224 }
1225 let mut i = 0u8;
1226 while i < 10 {
1227 table[(b'0' + i) as usize] = (i + 52) as i8;
1228 i += 1;
1229 }
1230 table[b'+' as usize] = 62;
1231 table[b'/' as usize] = 63;
1232 table
1233 };
1234
1235 let mut output = Vec::new();
1236 let mut buffer = 0u32;
1237 let mut bits = 0u32;
1238
1239 for byte in input.bytes() {
1240 if byte == b'=' {
1241 break;
1242 }
1243 if byte.is_ascii_whitespace() {
1244 continue;
1245 }
1246 if byte >= 128 {
1247 return None;
1248 }
1249 let value = DECODE[byte as usize];
1250 if value < 0 {
1251 return None;
1252 }
1253 buffer = (buffer << 6) | value as u32;
1254 bits += 6;
1255 if bits >= 8 {
1256 bits -= 8;
1257 output.push(((buffer >> bits) & 0xff) as u8);
1258 }
1259 }
1260
1261 Some(output)
1262}
1263
1264fn decode_uri_octets(value: &str) -> Option<Vec<u8>> {
1265 let bytes = value.as_bytes();
1266 let mut result = Vec::with_capacity(bytes.len());
1267 let mut offset = 0;
1268 let hex = |byte: u8| match byte {
1269 b'0'..=b'9' => Some(byte - b'0'),
1270 b'a'..=b'f' => Some(byte - b'a' + 10),
1271 b'A'..=b'F' => Some(byte - b'A' + 10),
1272 _ => None,
1273 };
1274 while let Some(&byte) = bytes.get(offset) {
1275 if byte == b'%' {
1276 let high = hex(*bytes.get(offset + 1)?)?;
1277 let low = hex(*bytes.get(offset + 2)?)?;
1278 result.push(high * 16 + low);
1279 offset += 3;
1280 } else {
1281 result.push(byte);
1282 offset += 1;
1283 }
1284 }
1285 Some(result)
1286}
1287
1288fn data_uri_token(value: &str) -> bool {
1289 !value.is_empty()
1290 && value
1291 .bytes()
1292 .all(|byte| byte.is_ascii_graphic() && !b"()<>@,;:\\\"/[]?=".contains(&byte))
1293}
1294
1295fn decode_data_uri(data_uri: &str) -> Option<(String, Vec<u8>)> {
1296 if !data_uri.get(..5)?.eq_ignore_ascii_case("data:") {
1297 return None;
1298 }
1299 let (metadata, payload) = data_uri[5..].split_once(',')?;
1300 let mut fields = metadata.split(';').peekable();
1301 let encoded_mime_type = fields.next()?;
1302 let mime_type = if encoded_mime_type.is_empty() {
1303 "text/plain".to_string()
1304 } else {
1305 let decoded = String::from_utf8(decode_uri_octets(encoded_mime_type)?).ok()?;
1306 let (kind, subtype) = decoded.split_once('/')?;
1307 if !data_uri_token(kind) || !data_uri_token(subtype) {
1308 return None;
1309 }
1310 decoded
1311 };
1312 let mut base64 = false;
1313 while let Some(field) = fields.next() {
1314 if field.eq_ignore_ascii_case("base64") {
1315 if fields.peek().is_some() {
1316 return None;
1317 }
1318 base64 = true;
1319 } else {
1320 let (attribute, value) = field.split_once('=')?;
1321 let attribute = String::from_utf8(decode_uri_octets(attribute)?).ok()?;
1322 if !data_uri_token(&attribute) {
1323 return None;
1324 }
1325 decode_uri_octets(value)?;
1326 }
1327 }
1328 let data = if base64 {
1329 if payload.contains('%') {
1330 let decoded = decode_uri_octets(payload)?;
1331 decode_base64(std::str::from_utf8(&decoded).ok()?)?
1332 } else {
1333 decode_base64(payload)?
1334 }
1335 } else {
1336 decode_uri_octets(payload)?
1337 };
1338 Some((mime_type, data))
1339}
1340
1341fn extension_from_mime_type(mime_type: Option<&str>) -> Option<&'static str> {
1342 let mime_type = mime_type?;
1343 let normalized = mime_type.to_ascii_lowercase();
1344 MIME_BY_EXTENSION
1345 .iter()
1346 .find(|(_, candidate)| *candidate == normalized)
1347 .map(|(extension, _)| *extension)
1348}
1349
1350fn extension_from_part_name(part_name: Option<&str>) -> Option<String> {
1351 let part_name = part_name?;
1352 let dot_index = part_name.rfind('.')?;
1353 if dot_index == part_name.len() - 1 {
1354 return None;
1355 }
1356 Some(part_name[dot_index + 1..].to_ascii_lowercase())
1357}
1358
1359fn target_from_part_name(part_name: &str) -> String {
1360 part_name
1361 .strip_prefix("word/")
1362 .unwrap_or(part_name)
1363 .to_string()
1364}
1365
1366fn emu_from_px(px: Option<f64>, fallback_px: f64) -> i64 {
1367 ((px.unwrap_or(fallback_px)) * 9525.0).round() as i64
1368}
1369
1370fn is_external_target(target: &str) -> bool {
1371 let mut chars = target.chars();
1372 let Some(first) = chars.next() else {
1373 return false;
1374 };
1375 if !first.is_ascii_alphabetic() {
1376 return false;
1377 }
1378 for ch in chars {
1379 if ch == ':' {
1380 return true;
1381 }
1382 if !(ch.is_ascii_alphanumeric() || matches!(ch, '+' | '.' | '-')) {
1383 return false;
1384 }
1385 }
1386 false
1387}
1388
1389fn content_types_has_extension(content: &str, extension: &str) -> bool {
1390 let needle = format!(r#"Extension="{extension}""#);
1391 content
1392 .as_bytes()
1393 .windows(needle.len())
1394 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
1395}
1396
1397fn ensure_content_type_default(pkg: &mut OoxmlPackage, extension: &str, content_type: &str) {
1398 let Some(part) = pkg.parts.get("[Content_Types].xml").cloned() else {
1399 return;
1400 };
1401
1402 if content_types_has_extension(&part.content, extension) {
1403 return;
1404 }
1405
1406 let default_entry =
1407 format!(r#"<Default Extension="{extension}" ContentType="{content_type}"/>"#);
1408 let updated = if let Some(index) = part.content.rfind("</Types>") {
1409 format!(
1410 "{}{}{}",
1411 &part.content[..index],
1412 default_entry,
1413 &part.content[index..]
1414 )
1415 } else {
1416 part.content.clone()
1417 };
1418
1419 pkg.parts.insert(
1420 "[Content_Types].xml".to_string(),
1421 OoxmlPart {
1422 name: "[Content_Types].xml".to_string(),
1423 content: updated,
1424 },
1425 );
1426}
1427
1428fn ensure_content_type_override(pkg: &mut OoxmlPackage, part_name: &str, content_type: &str) {
1429 let Some(part) = pkg.parts.get("[Content_Types].xml").cloned() else {
1430 return;
1431 };
1432 let normalized_part_name = if part_name.starts_with('/') {
1433 part_name.to_string()
1434 } else {
1435 format!("/{part_name}")
1436 };
1437 for range in extract_balanced_tag_ranges(&part.content, "Override") {
1438 let Some(open_end) = opening_tag_end(&part.content, range.start) else {
1439 continue;
1440 };
1441 let open_tag = &part.content[range.start..open_end];
1442 if !get_attribute(open_tag, "PartName")
1443 .is_some_and(|value| value.eq_ignore_ascii_case(&normalized_part_name))
1444 {
1445 continue;
1446 }
1447 if get_attribute(open_tag, "ContentType").as_deref() == Some(content_type) {
1448 return;
1449 }
1450 let updated_open_tag = set_xml_attribute(open_tag, "ContentType", content_type);
1451 let mut updated = part.content.clone();
1452 updated.replace_range(range.start..open_end, &updated_open_tag);
1453 pkg.parts.insert(
1454 "[Content_Types].xml".to_string(),
1455 OoxmlPart {
1456 name: "[Content_Types].xml".to_string(),
1457 content: updated,
1458 },
1459 );
1460 return;
1461 }
1462
1463 let entry = format!(
1464 r#"<Override PartName="{}" ContentType="{}"/>"#,
1465 escape_xml(&normalized_part_name),
1466 escape_xml(content_type)
1467 );
1468 let updated = if let Some(index) = part.content.rfind("</Types>") {
1469 format!(
1470 "{}{}{}",
1471 &part.content[..index],
1472 entry,
1473 &part.content[index..]
1474 )
1475 } else {
1476 part.content
1477 };
1478 pkg.parts.insert(
1479 "[Content_Types].xml".to_string(),
1480 OoxmlPart {
1481 name: "[Content_Types].xml".to_string(),
1482 content: updated,
1483 },
1484 );
1485}
1486
1487fn ensure_image_part_and_relationship(
1488 image: &ImageRunNode,
1489 state: &mut ImageSerializationState<'_>,
1490) -> Option<(String, f64, f64, String)> {
1491 let mut part_name = image.part_name.clone();
1492 let mut image_data = image.data.clone();
1493 let mut content_type = image.content_type.clone();
1494
1495 if part_name.is_none() || image_data.is_none() {
1496 if let Some(src) = &image.src {
1497 if let Some((decoded_mime, decoded_data)) = decode_data_uri(src) {
1498 image_data = Some(decoded_data);
1499 if content_type.is_none() {
1500 content_type = Some(decoded_mime);
1501 }
1502 if part_name.is_none() {
1503 let extension =
1504 extension_from_mime_type(content_type.as_deref()).unwrap_or("png");
1505 part_name = Some(format!(
1506 "word/media/image{}.{}",
1507 state.next_image_index, extension
1508 ));
1509 state.next_image_index += 1;
1510 }
1511 }
1512 }
1513 }
1514
1515 let part_name = part_name?;
1516
1517 if let Some(data) = &image_data {
1518 if !state.pkg.binary_assets.contains_key(&part_name) {
1519 state
1520 .pkg
1521 .binary_assets
1522 .insert(part_name.clone(), data.clone());
1523 }
1524 }
1525
1526 let extension = extension_from_part_name(Some(&part_name))
1527 .or_else(|| extension_from_mime_type(content_type.as_deref()).map(str::to_string))
1528 .unwrap_or_else(|| "png".to_string());
1529 let resolved_content_type = content_type.unwrap_or_else(|| {
1530 MIME_BY_EXTENSION
1531 .iter()
1532 .find(|(ext, _)| *ext == extension)
1533 .map(|(_, mime)| mime.to_string())
1534 .unwrap_or_else(|| "application/octet-stream".to_string())
1535 });
1536 ensure_content_type_default(state.pkg, &extension, &resolved_content_type);
1537
1538 let target = target_from_part_name(&part_name);
1539 let relationship = if let Some(existing) = state.relationship_by_target.get(&target) {
1540 existing.clone()
1541 } else {
1542 let relationship = Relationship {
1543 id: format!("rId{}", state.next_relationship_index),
1544 r#type: REL_TYPE_IMAGE.to_string(),
1545 target: target.clone(),
1546 target_mode: None,
1547 };
1548 state.next_relationship_index += 1;
1549 state.relationships.push(relationship.clone());
1550 state
1551 .relationship_by_target
1552 .insert(target.clone(), relationship.clone());
1553 relationship
1554 };
1555
1556 Some((
1557 relationship.id,
1558 image.width_px.unwrap_or(240.0),
1559 image.height_px.unwrap_or(160.0),
1560 image
1561 .alt
1562 .clone()
1563 .unwrap_or_else(|| "DOCX image".to_string()),
1564 ))
1565}
1566
1567fn ensure_hyperlink_relationship(target: &str, state: &mut ImageSerializationState<'_>) -> String {
1568 let target_mode = if is_external_target(target) {
1569 Some("External".to_string())
1570 } else {
1571 None
1572 };
1573
1574 if let Some(existing) = state.relationships.iter().find(|candidate| {
1575 candidate.r#type == REL_TYPE_HYPERLINK
1576 && candidate.target == target
1577 && candidate.target_mode == target_mode
1578 }) {
1579 return existing.id.clone();
1580 }
1581
1582 let relationship = Relationship {
1583 id: format!("rId{}", state.next_relationship_index),
1584 r#type: REL_TYPE_HYPERLINK.to_string(),
1585 target: target.to_string(),
1586 target_mode,
1587 };
1588 state.next_relationship_index += 1;
1589 let id = relationship.id.clone();
1590 state.relationships.push(relationship);
1591 id
1592}
1593
1594fn image_horizontal_align_str(align: ImageHorizontalAlign) -> &'static str {
1595 match align {
1596 ImageHorizontalAlign::Left => "left",
1597 ImageHorizontalAlign::Center => "center",
1598 ImageHorizontalAlign::Right => "right",
1599 ImageHorizontalAlign::Inside => "inside",
1600 ImageHorizontalAlign::Outside => "outside",
1601 }
1602}
1603
1604fn image_vertical_align_str(align: ImageVerticalAlign) -> &'static str {
1605 match align {
1606 ImageVerticalAlign::Top => "top",
1607 ImageVerticalAlign::Center => "center",
1608 ImageVerticalAlign::Bottom => "bottom",
1609 ImageVerticalAlign::Inside => "inside",
1610 ImageVerticalAlign::Outside => "outside",
1611 }
1612}
1613
1614fn image_wrap_text_attr(wrap_text: Option<ImageWrapText>) -> String {
1615 wrap_text
1616 .map(|value| {
1617 let text = match value {
1618 ImageWrapText::BothSides => "bothSides",
1619 ImageWrapText::Left => "left",
1620 ImageWrapText::Right => "right",
1621 ImageWrapText::Largest => "largest",
1622 };
1623 format!(r#" wrapText="{}""#, escape_xml(text))
1624 })
1625 .unwrap_or_default()
1626}
1627
1628const VML_NS: &str = "urn:schemas-microsoft-com:vml";
1629const VML_WORD_NS: &str = "urn:schemas-microsoft-com:office:word";
1630const VML_SOURCE_NAMESPACES: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w10="urn:schemas-microsoft-com:office:word" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006""#;
1631
1632fn picture_source_namespaces(
1633 package: &OoxmlPackage,
1634 owner_part_name: &str,
1635) -> Option<std::collections::BTreeMap<String, String>> {
1636 let mut namespaces = std::collections::BTreeMap::new();
1637 if let Some(part) = package.parts.get(owner_part_name) {
1638 let mut reader = quick_xml::Reader::from_str(&part.content);
1639 loop {
1640 match reader.read_event().ok()? {
1641 quick_xml::events::Event::Start(tag) | quick_xml::events::Event::Empty(tag) => {
1642 for attribute in tag.attributes() {
1643 let attribute = attribute.ok()?;
1644 let name = attribute.key.as_ref();
1645 if name == "xmlns" {
1646 namespaces.insert(
1647 String::new(),
1648 decode_xml_entities(attribute.value.as_ref()),
1649 );
1650 } else if let Some(prefix) = name.strip_prefix("xmlns:") {
1651 namespaces.insert(
1652 prefix.to_owned(),
1653 decode_xml_entities(attribute.value.as_ref()),
1654 );
1655 }
1656 }
1657 break;
1658 }
1659 quick_xml::events::Event::Eof => return None,
1660 _ => {}
1661 }
1662 }
1663 }
1664 for (prefix, uri) in [
1665 ("w", WORD_MAIN_NS),
1666 ("r", OFFICE_REL_NS),
1667 ("a", DRAWING_MAIN_NS),
1668 ("wp", DRAWING_WORD_NS),
1669 ("pic", DRAWING_PICTURE_NS),
1670 ("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
1671 ("am3d", "http://schemas.microsoft.com/office/drawing/2017/model3d"),
1672 ("aink", "http://schemas.microsoft.com/office/drawing/2016/ink"),
1673 ("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
1674 ("v", VML_NS),
1675 ("o", "urn:schemas-microsoft-com:office:office"),
1676 ("w10", VML_WORD_NS),
1677 ("w14", WORD_2010_NS),
1678 ("mc", MARKUP_COMPATIBILITY_NS),
1679 (
1680 "a14",
1681 "http://schemas.microsoft.com/office/drawing/2010/main",
1682 ),
1683 (
1684 "asvg",
1685 "http://schemas.microsoft.com/office/drawing/2016/SVG/main",
1686 ),
1687 (
1688 "wp14",
1689 "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
1690 ),
1691 (
1692 "wps",
1693 "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
1694 ),
1695 (
1696 "wpg",
1697 "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup",
1698 ),
1699 ] {
1700 namespaces
1701 .entry(prefix.to_owned())
1702 .or_insert_with(|| uri.to_owned());
1703 }
1704 Some(namespaces)
1705}
1706
1707fn preserved_picture_run_xml(
1708 image: &ImageRunNode,
1709 owner_part_name: &str,
1710 relationships: &[Relationship],
1711 package: &OoxmlPackage,
1712) -> Option<(String, i64)> {
1713 if image.synthetic_text_box == Some(true)
1714 || image.content_nodes.is_some()
1715 || image.text_box_text.is_some()
1716 || image.math_ml.is_some()
1717 || image.chart_part_name.is_some()
1718 || image.chart_data.is_some()
1719 || !valid_image_paint_geometry(image)
1720 {
1721 return None;
1722 }
1723 let source = image.source_xml.as_deref()?;
1724 let namespaces = picture_source_namespaces(package, owner_part_name)?;
1725 let declarations = namespaces
1726 .iter()
1727 .map(|(prefix, uri)| {
1728 if prefix.is_empty() {
1729 format!(" xmlns=\"{}\"", escape_xml(uri))
1730 } else {
1731 format!(" xmlns:{prefix}=\"{}\"", escape_xml(uri))
1732 }
1733 })
1734 .collect::<String>();
1735 let open = format!("<root{declarations}>");
1736 let wrapped = format!("{open}{source}</root>");
1737 let document = roxmltree::Document::parse(&wrapped).ok()?;
1738 let root = document.root_element();
1739 if root
1740 .children()
1741 .any(|node| node.is_text() && node.text().is_some_and(|text| !text.trim().is_empty()))
1742 {
1743 return None;
1744 }
1745 let top: Vec<_> = root.children().filter(|node| node.is_element()).collect();
1746 let [top] = top.as_slice() else {
1747 return None;
1748 };
1749 let is_visual = |node: roxmltree::Node<'_, '_>| {
1750 node.has_tag_name((WORD_MAIN_NS, "drawing"))
1751 || node.has_tag_name((WORD_MAIN_NS, "pict"))
1752 || node.has_tag_name((MARKUP_COMPATIBILITY_NS, "AlternateContent"))
1753 };
1754 let is_run = top.has_tag_name((WORD_MAIN_NS, "r"));
1755 if is_run {
1756 let children: Vec<_> = top.children().filter(|node| node.is_element()).collect();
1757 if children.iter().filter(|node| is_visual(**node)).count() != 1
1758 || children
1759 .iter()
1760 .any(|node| !is_visual(*node) && !node.has_tag_name((WORD_MAIN_NS, "rPr")))
1761 {
1762 return None;
1763 }
1764 } else if !is_visual(*top) {
1765 return None;
1766 }
1767 if document
1768 .descendants()
1769 .filter(|node| node.is_element() && *node != root)
1770 .any(|node| {
1771 if node.tag_name().namespace() != Some(WORD_MAIN_NS) {
1772 return false;
1773 }
1774 if (node == *top && is_run) || matches!(node.tag_name().name(), "drawing" | "pict") {
1775 return false;
1776 }
1777 let inside_run_properties = is_run
1778 && node.ancestors().any(|ancestor| {
1779 ancestor.has_tag_name((WORD_MAIN_NS, "rPr")) && ancestor.parent() == Some(*top)
1780 });
1781 !inside_run_properties
1782 })
1783 {
1784 return None;
1785 }
1786
1787 let mut targets = HashMap::new();
1788 for relationship in relationships {
1789 let target = if relationship.target_mode.as_deref() == Some("External") {
1790 relationship.target.clone()
1791 } else {
1792 crate::parse::resolve_part_path(owner_part_name, &relationship.target)
1793 };
1794 targets.insert(relationship.id.clone(), target);
1795 }
1796 let mut reserved_relationship_index = 1i64;
1797 for (node, attribute) in document
1798 .descendants()
1799 .flat_map(|node| node.attributes().map(move |attribute| (node, attribute)))
1800 {
1801 let namespace = attribute.namespace();
1802 if namespace != Some(OFFICE_REL_NS)
1803 && !(namespace == Some("urn:schemas-microsoft-com:office:office")
1804 && attribute.name() == "relid")
1805 {
1806 continue;
1807 }
1808 let diagram_relationship = node.has_tag_name((
1809 "http://schemas.openxmlformats.org/drawingml/2006/diagram", "relIds",
1810 )).then(|| match attribute.name() {
1811 "dm" => Some("diagramData"),
1812 "lo" => Some("diagramLayout"),
1813 "qs" => Some("diagramQuickStyle"),
1814 "cs" => Some("diagramColors"),
1815 _ => None,
1816 }).flatten();
1817 if diagram_relationship.is_none()
1818 && !matches!(attribute.name(), "id" | "embed" | "link" | "href" | "relid") {
1819 return None;
1820 }
1821 let mut matching = relationships
1822 .iter()
1823 .filter(|relationship| relationship.id == attribute.value());
1824 let Some(relationship) = matching.next() else {
1825 if diagram_relationship.is_some() {
1826 return None;
1827 }
1828 if let Some(index) = attribute
1829 .value()
1830 .strip_prefix("rId")
1831 .and_then(|value| value.parse::<i64>().ok())
1832 {
1833 reserved_relationship_index =
1834 reserved_relationship_index.max(index.checked_add(1)?);
1835 }
1836 continue;
1837 };
1838 if matching.next().is_some() || relationship.target.is_empty() {
1839 return None;
1840 }
1841 let hyperlink = node.tag_name().namespace() == Some(DRAWING_MAIN_NS)
1842 && matches!(node.tag_name().name(), "hlinkClick" | "hlinkHover");
1843 let visual_source_relationship = if node.has_tag_name((
1844 "http://schemas.microsoft.com/office/drawing/2017/model3d", "model3d",
1845 )) && attribute.name() == "embed" {
1846 Some(("http://schemas.microsoft.com/office/2017/06/relationships/model3d", false))
1847 } else if node.has_tag_name((WORD_2010_NS, "contentPart")) && attribute.name() == "id" {
1848 Some(("http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml", true))
1849 } else {
1850 None
1851 };
1852 if let Some(kind) = diagram_relationship {
1853 if relationship.r#type != format!("{OFFICE_REL_NS}/{kind}")
1854 || !matches!(relationship.target_mode.as_deref(), None | Some("Internal"))
1855 || !package.parts.contains_key(&crate::parse::resolve_part_path(owner_part_name, &relationship.target))
1856 {
1857 return None;
1858 }
1859 } else if let Some((kind, xml_part)) = visual_source_relationship {
1860 let target = crate::parse::resolve_part_path(owner_part_name, &relationship.target);
1861 if relationship.r#type != kind
1862 || !matches!(relationship.target_mode.as_deref(), None | Some("Internal"))
1863 || if xml_part { !package.parts.contains_key(&target) } else { !package.binary_assets.contains_key(&target) }
1864 {
1865 return None;
1866 }
1867 } else if hyperlink {
1868 if relationship.r#type != REL_TYPE_HYPERLINK
1869 || relationship.target_mode.as_deref() != Some("External")
1870 {
1871 return None;
1872 }
1873 } else if relationship.r#type != REL_TYPE_IMAGE
1874 || !matches!(
1875 relationship.target_mode.as_deref(),
1876 None | Some("Internal" | "External")
1877 )
1878 {
1879 return None;
1880 }
1881 }
1882 let context = crate::parse::ParseContext {
1883 relationships: targets,
1884 content_types: crate::parse::parse_content_types(package),
1885 parts: &package.parts,
1886 binary_assets: &package.binary_assets,
1887 style_sheet: crate::parse::parse_style_sheet(package),
1888 warnings: std::cell::RefCell::new(Vec::new()),
1889 };
1890 let parsed = crate::parse::parse_run_images(source, &context);
1891 let [parsed] = parsed.as_slice() else {
1892 return None;
1893 };
1894 if parsed.src.is_none()
1895 || parsed.src != image.src
1896 || parsed.alt != image.alt
1897 || parsed.width_px != image.width_px
1898 || parsed.height_px != image.height_px
1899 || parsed.part_name != image.part_name
1900 || parsed.content_type != image.content_type
1901 || parsed.data != image.data
1902 || parsed.crop != image.crop
1903 || parsed.css_filter != image.css_filter
1904 || parsed.css_opacity != image.css_opacity
1905 || parsed.paint_bounds != image.paint_bounds
1906 || parsed.floating != image.floating
1907 || parsed.presentation != image.presentation
1908 || parsed.synthetic_text_box != image.synthetic_text_box
1909 {
1910 return None;
1911 }
1912 if is_run {
1913 let start = top.range().start - open.len();
1914 let end = opening_tag_end(source, start)?;
1915 let mut tag = source[start..end].to_owned();
1916 for (prefix, uri) in namespaces {
1917 if prefix.is_empty() {
1918 if get_attribute(&tag, "xmlns").is_none() {
1919 tag.insert_str(tag.len() - 1, &format!(" xmlns=\"{}\"", escape_xml(&uri)));
1920 }
1921 } else {
1922 tag = ensure_namespace(&tag, &prefix, &escape_xml(&uri));
1923 }
1924 }
1925 Some((
1926 format!("{}{}{}", &source[..start], tag, &source[end..]),
1927 reserved_relationship_index,
1928 ))
1929 } else {
1930 Some((
1931 format!("<w:r{declarations}>{source}</w:r>"),
1932 reserved_relationship_index,
1933 ))
1934 }
1935}
1936
1937fn vml_source_frame(root: roxmltree::Node<'_, '_>) -> Option<(f64, f64, Option<ImageFloating>)> {
1938 let style = root.attribute("style")?;
1939 let mut declarations = HashMap::new();
1940 for entry in style
1941 .split(';')
1942 .map(str::trim)
1943 .filter(|entry| !entry.is_empty())
1944 {
1945 let (key, value) = entry.split_once(':')?;
1946 let key = key.trim().to_ascii_lowercase();
1947 let value = value.trim().to_ascii_lowercase();
1948 if key.is_empty() || value.is_empty() || declarations.insert(key, value).is_some() {
1949 return None;
1950 }
1951 }
1952 let dimension = |name: &str| -> Option<f64> {
1953 let value = declarations.get(name)?;
1954 let split = value.find(|c: char| c.is_ascii_alphabetic())?;
1955 let number: f64 = value[..split].trim().parse().ok()?;
1956 let scale = match value[split..].trim() {
1957 "px" => 1.0,
1958 "pt" => 96.0 / 72.0,
1959 "in" => 96.0,
1960 "cm" => 96.0 / 2.54,
1961 "mm" => 96.0 / 25.4,
1962 _ => return None,
1963 };
1964 let pixels = number * scale;
1965 (pixels.is_finite() && pixels > 0.0).then_some(pixels)
1966 };
1967 let width = dimension("width")?;
1968 let height = dimension("height")?;
1969 let wraps: Vec<_> = root
1970 .children()
1971 .filter(|node| node.has_tag_name((VML_WORD_NS, "wrap")))
1972 .collect();
1973 if wraps.len() > 1 {
1974 return None;
1975 }
1976 let wrap = wraps
1977 .first()
1978 .map(|node| {
1979 let attributes = ["type", "side"]
1980 .into_iter()
1981 .filter_map(|name| {
1982 node.attribute(name)
1983 .map(|value| format!(" {name}=\"{}\"", escape_xml(value)))
1984 })
1985 .collect::<String>();
1986 format!("<w10:wrap{attributes}/>")
1987 })
1988 .unwrap_or_default();
1989 let synthetic = format!("<v:shape style=\"{}\">{wrap}</v:shape>", escape_xml(style));
1990 let mut floating = crate::parse::parse_vml_floating_anchor_from_run_xml(&synthetic);
1991 if let Some(anchor) = floating.as_mut() {
1992 if anchor.horizontal_align.is_none() {
1993 if let Some(value) = declarations.get("margin-left") {
1994 anchor.x_px = Some(crate::parse::vml_pixels(value)?);
1995 }
1996 }
1997 if anchor.vertical_align.is_none() {
1998 if let Some(value) = declarations.get("margin-top") {
1999 anchor.y_px = Some(crate::parse::vml_pixels(value)?);
2000 }
2001 }
2002 }
2003 let inert = declarations.keys().all(|key| {
2004 matches!(
2005 key.as_str(),
2006 "width"
2007 | "height"
2008 | "position"
2009 | "mso-position-horizontal-relative"
2010 | "mso-position-vertical-relative"
2011 )
2012 }) && declarations
2013 .get("position")
2014 .is_none_or(|value| matches!(value.as_str(), "static" | "relative"));
2015 if inert {
2016 if let Some(anchor) = floating.as_mut() {
2017 anchor.horizontal_relative_to = None;
2018 anchor.vertical_relative_to = None;
2019 if anchor.x_px.is_none()
2020 && anchor.y_px.is_none()
2021 && anchor.horizontal_align.is_none()
2022 && anchor.vertical_align.is_none()
2023 && anchor.dist_l_px.is_none()
2024 && anchor.dist_r_px.is_none()
2025 && anchor.dist_t_px.is_none()
2026 && anchor.dist_b_px.is_none()
2027 && anchor.wrap_type.is_none()
2028 && anchor.wrap_text.is_none()
2029 && anchor.behind_document.is_none()
2030 && anchor.z_index.is_none()
2031 {
2032 floating = None;
2033 }
2034 }
2035 }
2036 Some((width, height, floating))
2037}
2038
2039fn vml_paragraph_bookmarks(
2040 paragraph: roxmltree::Node<'_, '_>,
2041 source: &str,
2042 seen_ids: &mut std::collections::HashSet<String>,
2043) -> Option<(String, String)> {
2044 let text_runs: Vec<_> = paragraph
2045 .children()
2046 .filter(|node| {
2047 node.has_tag_name((WORD_MAIN_NS, "r"))
2048 && node.children().any(|child| {
2049 child.has_tag_name((WORD_MAIN_NS, "t"))
2050 && child.text().is_some_and(|text| !text.is_empty())
2051 || child.tag_name().namespace() == Some(WORD_MAIN_NS)
2052 && matches!(child.tag_name().name(), "tab" | "br" | "cr")
2053 })
2054 })
2055 .collect();
2056 let mut open = std::collections::HashSet::new();
2057 let mut before = String::new();
2058 let mut after = String::new();
2059 for marker in paragraph.children().filter(|node| {
2060 node.has_tag_name((WORD_MAIN_NS, "bookmarkStart"))
2061 || node.has_tag_name((WORD_MAIN_NS, "bookmarkEnd"))
2062 }) {
2063 let raw = &source[marker.range()];
2064 if !raw.starts_with(&format!("<w:{}", marker.tag_name().name()))
2065 || marker.children().any(|node| {
2066 node.is_element() || node.text().is_some_and(|text| !text.trim().is_empty())
2067 })
2068 || marker.attributes().any(|attribute| {
2069 attribute.namespace() != Some(WORD_MAIN_NS)
2070 || &source[attribute.range_qname()] != format!("w:{}", attribute.name())
2071 })
2072 {
2073 return None;
2074 }
2075 let id = marker.attribute((WORD_MAIN_NS, "id"))?;
2076 if id.is_empty() {
2077 return None;
2078 }
2079 if marker.has_tag_name((WORD_MAIN_NS, "bookmarkStart")) {
2080 if !open.insert(id) || !seen_ids.insert(id.to_owned()) {
2081 return None;
2082 }
2083 } else if !open.remove(id) {
2084 return None;
2085 }
2086 if let (Some(first), Some(last)) = (text_runs.first(), text_runs.last()) {
2087 if marker.range().start > first.range().start && marker.range().end < last.range().end {
2088 return None;
2089 }
2090 if marker.range().start < first.range().start {
2091 before.push_str(raw);
2092 } else {
2093 after.push_str(raw);
2094 }
2095 } else {
2096 before.push_str(raw);
2097 }
2098 }
2099 open.is_empty().then_some((before, after))
2100}
2101
2102struct VmlTextboxSourcePlan {
2103 wrapped: String,
2104 open_len: usize,
2105 paragraphs: Vec<(
2106 std::ops::Range<usize>,
2107 std::ops::Range<usize>,
2108 Vec<(String, String)>,
2109 )>,
2110 run_start: Option<usize>,
2111 is_pict: bool,
2112 namespaces: Vec<(String, String)>,
2113}
2114
2115fn finite_coordinates<const N: usize>(value: &str) -> Option<[f64; N]> {
2116 let values = value
2117 .split(|character: char| character == ',' || character.is_ascii_whitespace())
2118 .filter(|value| !value.is_empty())
2119 .map(|value| value.parse::<f64>().ok().filter(|value| value.is_finite()))
2120 .collect::<Option<Vec<_>>>()?;
2121 values.try_into().ok()
2122}
2123
2124fn valid_image_paint_geometry(image: &ImageRunNode) -> bool {
2125 let Some(bounds) = image.paint_bounds.as_deref() else {
2126 return true;
2127 };
2128 let Some((width, height)) = image.width_px.zip(image.height_px) else {
2129 return false;
2130 };
2131 let scale_x = width / bounds.layout_width_px;
2132 let scale_y = height / bounds.layout_height_px;
2133 bounds.x_px.is_finite()
2134 && bounds.y_px.is_finite()
2135 && [
2136 bounds.width_px,
2137 bounds.height_px,
2138 bounds.layout_width_px,
2139 bounds.layout_height_px,
2140 ]
2141 .into_iter()
2142 .all(|value| value.is_finite() && value > 0.0)
2143 && image
2144 .width_px
2145 .is_some_and(|value| value.is_finite() && value > 0.0)
2146 && image
2147 .height_px
2148 .is_some_and(|value| value.is_finite() && value > 0.0)
2149 && [
2150 scale_x,
2151 scale_y,
2152 bounds.width_px * scale_x,
2153 bounds.height_px * scale_y,
2154 ]
2155 .into_iter()
2156 .all(|value| value.is_finite() && value > 0.0)
2157 && (bounds.x_px * scale_x).is_finite()
2158 && (bounds.y_px * scale_y).is_finite()
2159 && (bounds.x_px + bounds.width_px).is_finite()
2160 && (bounds.y_px + bounds.height_px).is_finite()
2161 && (bounds.x_px * scale_x + bounds.width_px * scale_x).is_finite()
2162 && (bounds.y_px * scale_y + bounds.height_px * scale_y).is_finite()
2163}
2164
2165fn valid_image_paint_bounds(image: &ImageRunNode) -> bool {
2166 valid_image_paint_geometry(image) && (image.paint_bounds.is_none() || image.floating.is_none())
2167}
2168
2169fn vml_paint_matches_source(
2170 image: &ImageRunNode,
2171 svg: roxmltree::Node<'_, '_>,
2172 source: roxmltree::Node<'_, '_>,
2173 source_width: f64,
2174 source_height: f64,
2175 frame_width: f64,
2176 frame_height: f64,
2177) -> bool {
2178 let Some(bounds) = image.paint_bounds.as_deref() else {
2179 return source_width == frame_width
2180 && source_height == frame_height
2181 && image.width_px == Some(frame_width)
2182 && image.height_px == Some(frame_height);
2183 };
2184 if !valid_image_paint_bounds(image)
2185 || image.width_px != Some(source_width)
2186 || image.height_px != Some(source_height)
2187 || bounds.layout_width_px != source_width
2188 || bounds.layout_height_px != source_height
2189 || bounds.width_px != frame_width
2190 || bounds.height_px != frame_height
2191 || svg.attribute("preserveAspectRatio") != Some("none")
2192 {
2193 return false;
2194 }
2195 let Some([origin_x, origin_y]) =
2196 finite_coordinates::<2>(source.attribute("coordorigin").unwrap_or("0,0"))
2197 else {
2198 return false;
2199 };
2200 let Some([extent_x, extent_y]) =
2201 finite_coordinates::<2>(source.attribute("coordsize").unwrap_or("1000,1000"))
2202 else {
2203 return false;
2204 };
2205 let Some([canvas_x, canvas_y, canvas_width, canvas_height]) =
2206 svg.attribute("viewBox").and_then(finite_coordinates::<4>)
2207 else {
2208 return false;
2209 };
2210 extent_x > 0.0
2211 && extent_y > 0.0
2212 && canvas_width > 0.0
2213 && canvas_height > 0.0
2214 && bounds.x_px
2215 == if source_width == extent_x {
2216 canvas_x - origin_x
2217 } else {
2218 (canvas_x - origin_x) * source_width / extent_x
2219 }
2220 && bounds.y_px
2221 == if source_height == extent_y {
2222 canvas_y - origin_y
2223 } else {
2224 (canvas_y - origin_y) * source_height / extent_y
2225 }
2226 && bounds.width_px
2227 == if source_width == extent_x {
2228 canvas_width
2229 } else if canvas_width == extent_x {
2230 source_width
2231 } else {
2232 canvas_width * source_width / extent_x
2233 }
2234 && bounds.height_px
2235 == if source_height == extent_y {
2236 canvas_height
2237 } else if canvas_height == extent_y {
2238 source_height
2239 } else {
2240 canvas_height * source_height / extent_y
2241 }
2242}
2243
2244fn vml_floating_paint_matches_source(
2245 image: &ImageRunNode,
2246 svg: roxmltree::Node<'_, '_>,
2247 source: &str,
2248 owner_part_name: &str,
2249 relationships: &[Relationship],
2250 package: &OoxmlPackage,
2251) -> bool {
2252 if image.floating.is_none() || image.paint_bounds.is_some() {
2253 return false;
2254 }
2255 let mut targets = HashMap::new();
2256 for relationship in relationships {
2257 if relationship.r#type == REL_TYPE_IMAGE && relationship.target_mode.is_none() {
2258 targets.entry(relationship.id.clone()).or_insert_with(|| {
2259 crate::parse::resolve_part_path(owner_part_name, &relationship.target)
2260 });
2261 }
2262 }
2263 let context = crate::parse::ParseContext {
2264 relationships: targets,
2265 content_types: crate::parse::parse_content_types(package),
2266 parts: &package.parts,
2267 binary_assets: &package.binary_assets,
2268 style_sheet: crate::parse::parse_style_sheet(package),
2269 warnings: std::cell::RefCell::new(Vec::new()),
2270 };
2271 let Some(parsed) = crate::parse::parse_vml_group_image_from_part(source, &context, owner_part_name) else {
2272 return false;
2273 };
2274 if parsed.synthetic_text_box != Some(true)
2275 || parsed.paint_bounds.is_some()
2276 || parsed.width_px != image.width_px
2277 || parsed.height_px != image.height_px
2278 || parsed.floating != image.floating
2279 {
2280 return false;
2281 }
2282 let Some((mime, bytes)) = parsed.src.as_deref().and_then(decode_data_uri) else {
2283 return false;
2284 };
2285 if mime != "image/svg+xml" {
2286 return false;
2287 }
2288 let Some(expected) = std::str::from_utf8(&bytes)
2289 .ok()
2290 .and_then(|text| roxmltree::Document::parse(text).ok())
2291 else {
2292 return false;
2293 };
2294 ["width", "height", "viewBox", "preserveAspectRatio"]
2295 .into_iter()
2296 .all(|name| svg.attribute(name) == expected.root_element().attribute(name))
2297}
2298
2299fn edited_vml_textbox_source_plan(
2300 image: &ImageRunNode,
2301 owner_part_name: &str,
2302 relationships: &[Relationship],
2303 package: &OoxmlPackage,
2304) -> Option<VmlTextboxSourcePlan> {
2305 if image.synthetic_text_box != Some(true)
2306 || image.part_name.is_some()
2307 || image.data.is_some()
2308 || image.crop.is_some()
2309 || image.css_filter.is_some()
2310 || image.css_opacity.is_some()
2311 {
2312 return None;
2313 }
2314 let source = image.source_xml.as_deref()?;
2315 let (mime, svg_bytes) = decode_data_uri(image.src.as_deref()?)?;
2316 if mime != "image/svg+xml" {
2317 return None;
2318 }
2319 let svg_text = std::str::from_utf8(&svg_bytes).ok()?;
2320 let svg = roxmltree::Document::parse(svg_text).ok()?;
2321 if !svg
2322 .root_element()
2323 .has_tag_name(("http://www.w3.org/2000/svg", "svg"))
2324 {
2325 return None;
2326 }
2327 let frame_width: f64 = svg.root_element().attribute("width")?.parse().ok()?;
2328 let frame_height: f64 = svg.root_element().attribute("height")?.parse().ok()?;
2329 if !frame_width.is_finite()
2330 || !frame_height.is_finite()
2331 || frame_width <= 0.0
2332 || frame_height <= 0.0
2333 {
2334 return None;
2335 }
2336 let open = format!("<root {VML_SOURCE_NAMESPACES}>");
2337 let wrapped = format!("{open}{source}</root>");
2338 let document = roxmltree::Document::parse(&wrapped).ok()?;
2339 let top: Vec<_> = document
2340 .root_element()
2341 .children()
2342 .filter(|node| node.is_element())
2343 .collect();
2344 match top.as_slice() {
2345 [node] if node.has_tag_name((WORD_MAIN_NS, "pict")) => {}
2346 [node] if node.has_tag_name((WORD_MAIN_NS, "r")) => {
2347 let children: Vec<_> = node.children().filter(|child| child.is_element()).collect();
2348 if children
2349 .iter()
2350 .filter(|child| child.has_tag_name((WORD_MAIN_NS, "pict")))
2351 .count()
2352 != 1
2353 || children.iter().any(|child| {
2354 !child.has_tag_name((WORD_MAIN_NS, "pict"))
2355 && !child.has_tag_name((WORD_MAIN_NS, "rPr"))
2356 })
2357 {
2358 return None;
2359 }
2360 }
2361 _ => {
2362 if top.is_empty()
2363 || top.iter().any(|node| {
2364 !node.has_tag_name((VML_NS, "group"))
2365 && !node.has_tag_name((VML_NS, "shapetype"))
2366 })
2367 {
2368 return None;
2369 }
2370 }
2371 };
2372 let groups: Vec<_> = document
2373 .descendants()
2374 .filter(|node| {
2375 node.has_tag_name((VML_NS, "group"))
2376 && !node
2377 .ancestors()
2378 .skip(1)
2379 .any(|parent| parent.has_tag_name((VML_NS, "group")))
2380 })
2381 .collect();
2382 if groups.len() != 1 {
2383 return None;
2384 }
2385 let root = groups[0];
2386 let (source_width, source_height, source_anchor) = vml_source_frame(root)?;
2387 let direct_frame_matches = vml_paint_matches_source(
2388 image,
2389 svg.root_element(),
2390 root,
2391 source_width,
2392 source_height,
2393 frame_width,
2394 frame_height,
2395 ) && source_anchor == image.floating;
2396 if !direct_frame_matches
2397 && !vml_floating_paint_matches_source(
2398 image,
2399 svg.root_element(),
2400 source,
2401 owner_part_name,
2402 relationships,
2403 package,
2404 )
2405 {
2406 return None;
2407 }
2408 for attribute in document.descendants().flat_map(|node| node.attributes()) {
2409 if attribute.namespace() == Some(OFFICE_REL_NS) {
2410 if !matches!(attribute.name(), "id" | "embed") {
2411 return None;
2412 }
2413 let relationship = relationships
2414 .iter()
2415 .find(|relationship| relationship.id == attribute.value())?;
2416 if relationship.r#type != format!("{OFFICE_REL_NS}/image")
2417 || relationship.target_mode.is_some()
2418 || !package
2419 .binary_assets
2420 .contains_key(&crate::parse::resolve_part_path(
2421 owner_part_name,
2422 &relationship.target,
2423 ))
2424 {
2425 return None;
2426 }
2427 }
2428 }
2429 if image.paint_bounds.is_some() || !direct_frame_matches {
2430 let source_bitmaps: Vec<_> = root
2431 .descendants()
2432 .filter(|node| node.has_tag_name((VML_NS, "imagedata")))
2433 .collect();
2434 let rendered_bitmaps: Vec<_> = svg
2435 .descendants()
2436 .filter(|node| node.has_tag_name(("http://www.w3.org/2000/svg", "image")))
2437 .collect();
2438 if source_bitmaps.len() != rendered_bitmaps.len() {
2439 return None;
2440 }
2441 let mut remaining: Vec<_> = source_bitmaps
2442 .iter()
2443 .map(|bitmap| {
2444 let id = bitmap.attribute((OFFICE_REL_NS, "id"))?;
2445 let relationship = relationships
2446 .iter()
2447 .find(|relationship| relationship.id == id)?;
2448 package.binary_assets.get(&crate::parse::resolve_part_path(
2449 owner_part_name,
2450 &relationship.target,
2451 ))
2452 })
2453 .collect::<Option<Vec<_>>>()?;
2454 for bitmap in rendered_bitmaps {
2455 let (_, bytes) = decode_data_uri(bitmap.attribute("href")?)?;
2456 let index = remaining
2457 .iter()
2458 .position(|source| source.as_slice() == bytes.as_slice())?;
2459 remaining.swap_remove(index);
2460 }
2461 }
2462 let stories: Vec<_> = root
2463 .descendants()
2464 .filter(|node| node.has_tag_name((WORD_MAIN_NS, "txbxContent")))
2465 .collect();
2466 let content = image.content_nodes.as_ref()?;
2467 if stories.is_empty() {
2468 return None;
2469 }
2470 let mut cursor = 0;
2471 let mut story_texts = Vec::with_capacity(stories.len());
2472 let mut plans = Vec::with_capacity(stories.len());
2473 let mut bookmark_ids = std::collections::HashSet::new();
2474 for story in stories {
2475 if story
2476 .ancestors()
2477 .skip(1)
2478 .any(|node| node.has_tag_name((WORD_MAIN_NS, "txbxContent")))
2479 {
2480 return None;
2481 }
2482 let paragraphs: Vec<_> = story.children().filter(|node| node.is_element()).collect();
2483 if paragraphs.is_empty()
2484 || paragraphs
2485 .iter()
2486 .any(|node| !node.has_tag_name((WORD_MAIN_NS, "p")))
2487 {
2488 return None;
2489 }
2490 let mut paragraph_markers = Vec::with_capacity(paragraphs.len());
2491 for paragraph in ¶graphs {
2492 if paragraph
2493 .descendants()
2494 .filter(|node| node.is_element())
2495 .any(|node| {
2496 node.tag_name().namespace() != Some(WORD_MAIN_NS)
2497 || (matches!(node.tag_name().name(), "bookmarkStart" | "bookmarkEnd" | "proofErr")
2498 && node.parent() != Some(*paragraph))
2499 || matches!(
2500 node.tag_name().name(),
2501 "drawing"
2502 | "pict"
2503 | "object"
2504 | "txbxContent"
2505 | "fldChar"
2506 | "instrText"
2507 | "hyperlink"
2508 | "sdt"
2509 )
2510 })
2511 {
2512 return None;
2513 }
2514 for node in paragraph.children().filter(|node| node.is_element()) {
2515 if node.has_tag_name((WORD_MAIN_NS, "pPr")) {
2516 if node
2517 .descendants()
2518 .any(|child| child.has_tag_name((WORD_MAIN_NS, "sectPr")))
2519 {
2520 return None;
2521 }
2522 } else if node.has_tag_name((WORD_MAIN_NS, "r")) {
2523 if node
2524 .children()
2525 .filter(|child| child.is_element())
2526 .any(|child| {
2527 child.tag_name().namespace() != Some(WORD_MAIN_NS)
2528 || !matches!(
2529 child.tag_name().name(),
2530 "rPr" | "t" | "tab" | "br" | "cr"
2531 )
2532 })
2533 {
2534 return None;
2535 }
2536 } else if node.has_tag_name((WORD_MAIN_NS, "proofErr")) {
2537 if !matches!(node.attribute((WORD_MAIN_NS, "type")), Some("spellStart" | "spellEnd" | "gramStart" | "gramEnd"))
2538 || node.attributes().any(|attribute| attribute.namespace() != Some(WORD_MAIN_NS) || attribute.name() != "type")
2539 || node.children().any(|child| child.is_element() || (child.is_text() && child.text().is_some_and(|text| !text.trim().is_empty())))
2540 {
2541 return None;
2542 }
2543 } else if !node.has_tag_name((WORD_MAIN_NS, "bookmarkStart"))
2544 && !node.has_tag_name((WORD_MAIN_NS, "bookmarkEnd"))
2545 {
2546 return None;
2547 }
2548 }
2549 paragraph_markers.push(vml_paragraph_bookmarks(
2550 *paragraph,
2551 &wrapped,
2552 &mut bookmark_ids,
2553 )?);
2554 }
2555 let end = cursor + paragraphs.len();
2556 let typed = content.get(cursor..end)?;
2557 let mut texts = Vec::with_capacity(typed.len());
2558 for node in typed {
2559 let DocNode::Paragraph(paragraph) = node else {
2560 return None;
2561 };
2562 let mut text = String::new();
2563 for child in ¶graph.children {
2564 let ParagraphChildNode::Text(run) = child else {
2565 return None;
2566 };
2567 if run.note_reference.is_some() {
2568 return None;
2569 }
2570 text.push_str(&run.text);
2571 }
2572 texts.push(text);
2573 }
2574 story_texts.push(texts.join("\n"));
2575 plans.push((story.range(), cursor..end, paragraph_markers));
2576 cursor = end;
2577 }
2578 if cursor != content.len()
2579 || image.text_box_text.as_ref().is_some_and(|current| {
2580 story_texts
2581 .iter()
2582 .filter(|text| !text.is_empty())
2583 .cloned()
2584 .collect::<Vec<_>>()
2585 .join("\n")
2586 != *current
2587 })
2588 {
2589 return None;
2590 }
2591 let run_start = (top.len() == 1 && top[0].has_tag_name((WORD_MAIN_NS, "r")))
2592 .then(|| top[0].range().start - open.len());
2593 let is_pict = top.len() == 1 && top[0].has_tag_name((WORD_MAIN_NS, "pict"));
2594 let namespaces = document
2595 .root_element()
2596 .namespaces()
2597 .filter_map(|namespace| {
2598 namespace
2599 .name()
2600 .map(|prefix| (prefix.to_owned(), namespace.uri().to_owned()))
2601 })
2602 .collect();
2603 Some(VmlTextboxSourcePlan {
2604 wrapped,
2605 open_len: open.len(),
2606 paragraphs: plans,
2607 run_start,
2608 is_pict,
2609 namespaces,
2610 })
2611}
2612
2613fn edited_vml_textbox_run_xml(
2614 image: &ImageRunNode,
2615 state: &mut ImageSerializationState<'_>,
2616 run_id: i64,
2617) -> Option<String> {
2618 let plan = edited_vml_textbox_source_plan(
2619 image,
2620 &state.owner_part_name,
2621 &state.relationships,
2622 state.pkg,
2623 )?;
2624 let content = image.content_nodes.as_ref()?;
2625 let mut output = plan.wrapped;
2626 let mut next_run_id = run_id;
2627 for (range, nodes, paragraph_markers) in plan.paragraphs.into_iter().rev() {
2628 let mut replacement = format!("<w:txbxContent {VML_SOURCE_NAMESPACES}>");
2629 for (node, (before, after)) in content[nodes].iter().zip(paragraph_markers) {
2630 let DocNode::Paragraph(paragraph) = node else {
2631 unreachable!()
2632 };
2633 let mut current = paragraph.clone();
2634 current.source_xml = None;
2635 current.source_text_patch = None;
2636 current.source_run_provenance = None;
2637 current.source_paragraph_mark_style = None;
2638 current.source_paragraph_mark_formatting = None;
2639 current.source_paragraph_mark_properties_xml = None;
2640 let mut generated = paragraph_xml(¤t, state, &mut next_run_id);
2641 let before_offset = direct_property_children(&generated)
2642 .into_iter()
2643 .find(|(name, _)| name != "w:pPr")
2644 .map(|(_, range)| range.start)
2645 .unwrap_or("<w:p>".len());
2646 generated.insert_str(generated.len() - "</w:p>".len(), &after);
2647 generated.insert_str(before_offset, &before);
2648 replacement.push_str(&generated);
2649 }
2650 replacement.push_str("</w:txbxContent>");
2651 output.replace_range(range, &replacement);
2652 }
2653 let output = &output[plan.open_len..output.len() - "</root>".len()];
2654 if let Some(start) = plan.run_start {
2655 let end = opening_tag_end(output, start)?;
2656 let mut tag = output[start..end].to_string();
2657 for (prefix, uri) in plan.namespaces {
2658 tag = ensure_namespace(&tag, &prefix, &uri);
2659 }
2660 Some(format!("{}{}{}", &output[..start], tag, &output[end..]))
2661 } else if plan.is_pict {
2662 Some(format!("<w:r {VML_SOURCE_NAMESPACES}>{output}</w:r>"))
2663 } else {
2664 Some(format!(
2665 "<w:r {VML_SOURCE_NAMESPACES}><w:pict>{output}</w:pict></w:r>"
2666 ))
2667 }
2668}
2669
2670fn overflowing_paint_viewport(
2671 x: f64,
2672 y: f64,
2673 width: f64,
2674 height: f64,
2675 layout_width: f64,
2676 layout_height: f64,
2677) -> bool {
2678 let right = x + width;
2679 let bottom = y + height;
2680 x <= 0.0
2681 && y <= 0.0
2682 && right.is_finite()
2683 && bottom.is_finite()
2684 && right >= layout_width
2685 && bottom >= layout_height
2686 && (x < 0.0 || y < 0.0 || right > layout_width || bottom > layout_height)
2687}
2688
2689fn paint_png_bytes(image: &ImageRunNode) -> Option<Vec<u8>> {
2690 if !valid_image_paint_bounds(image)
2691 || image.paint_bounds.is_none()
2692 || image.part_name.is_some()
2693 || image.data.is_some()
2694 || image.crop.is_some()
2695 || image.css_filter.is_some()
2696 || image.css_opacity.is_some()
2697 || image
2698 .content_type
2699 .as_deref()
2700 .is_some_and(|mime| mime != "image/png")
2701 {
2702 return None;
2703 }
2704 let bounds = image.paint_bounds.as_deref()?;
2705 let width = image.width_px?;
2706 let height = image.height_px?;
2707 let scale_x = width / bounds.layout_width_px;
2708 let scale_y = height / bounds.layout_height_px;
2709 if !overflowing_paint_viewport(
2710 bounds.x_px,
2711 bounds.y_px,
2712 bounds.width_px,
2713 bounds.height_px,
2714 bounds.layout_width_px,
2715 bounds.layout_height_px,
2716 ) || !overflowing_paint_viewport(
2717 bounds.x_px * scale_x,
2718 bounds.y_px * scale_y,
2719 bounds.width_px * scale_x,
2720 bounds.height_px * scale_y,
2721 width,
2722 height,
2723 ) {
2724 return None;
2725 }
2726 let (mime, bytes) = decode_data_uri(image.src.as_deref()?)?;
2727 if mime != "image/png"
2728 || bytes.get(..8) != Some(b"\x89PNG\r\n\x1a\n")
2729 || bytes.get(8..16) != Some(b"\x00\x00\x00\x0dIHDR")
2730 || !bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82")
2731 || u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?) == 0
2732 || u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?) == 0
2733 {
2734 return None;
2735 }
2736 Some(bytes)
2737}
2738
2739const VML_BITMAP_SHAPE_TYPE: &str = r#"<v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><v:stroke joinstyle="miter"/><v:formulas><v:f eqn="if lineDrawn pixelLineWidth 0"/><v:f eqn="sum @0 1 0"/><v:f eqn="sum 0 0 @1"/><v:f eqn="prod @2 1 2"/><v:f eqn="prod @3 21600 pixelWidth"/><v:f eqn="prod @3 21600 pixelHeight"/><v:f eqn="sum @0 0 1"/><v:f eqn="prod @6 1 2"/><v:f eqn="prod @7 21600 pixelWidth"/><v:f eqn="sum @8 21600 0"/><v:f eqn="prod @7 21600 pixelHeight"/><v:f eqn="sum @10 21600 0"/></v:formulas><v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype>"#;
2740
2741fn inline_paint_png_run_xml(
2742 image: &ImageRunNode,
2743 state: &mut ImageSerializationState<'_>,
2744 run_id: i64,
2745) -> Option<String> {
2746 paint_png_bytes(image)?;
2747 let bounds = image.paint_bounds.as_deref()?;
2748 let (relationship, width, height, alt) = ensure_image_part_and_relationship(image, state)?;
2749 let scale_x = width / bounds.layout_width_px;
2750 let scale_y = height / bounds.layout_height_px;
2751 let x = bounds.x_px * scale_x;
2752 let y = bounds.y_px * scale_y;
2753 let paint_width = bounds.width_px * scale_x;
2754 let paint_height = bounds.height_px * scale_y;
2755 Some(format!(
2756 r##"<w:r {VML_SOURCE_NAMESPACES}><w:pict>{VML_BITMAP_SHAPE_TYPE}<v:group style="position:static;width:{width}px;height:{height}px" coordorigin="0,0" coordsize="{},{}"><v:shape id="_x0000_i{run_id}" type="#_x0000_t75" style="position:absolute;left:{};top:{};width:{};height:{}" filled="f" stroked="f"><v:imagedata r:id="{}" o:title="{}"/></v:shape></v:group></w:pict></w:r>"##,
2757 width,
2758 height,
2759 x,
2760 y,
2761 paint_width,
2762 paint_height,
2763 escape_xml(&relationship),
2764 escape_xml(&alt),
2765 ))
2766}
2767
2768fn drawing_run_xml(
2769 image: &ImageRunNode,
2770 state: &mut ImageSerializationState<'_>,
2771 run_id: i64,
2772) -> String {
2773 if let Some((source, next_relationship_index)) = preserved_picture_run_xml(image, &state.owner_part_name, &state.relationships, state.pkg) {
2774 state.next_relationship_index = state.next_relationship_index.max(next_relationship_index);
2775 return source;
2776 }
2777 if let Some(source) = edited_vml_textbox_run_xml(image, state, run_id) {
2778 return source;
2779 }
2780 if image.paint_bounds.is_some() {
2781 return inline_paint_png_run_xml(image, state, run_id)
2782 .expect("Inline paint requires preserved source or a current PNG viewport; validate before serialization");
2783 }
2784 let Some((relationship_id, width_px, height_px, alt)) =
2785 ensure_image_part_and_relationship(image, state)
2786 else {
2787 return String::new();
2788 };
2789
2790 let cx = emu_from_px(Some(width_px), 240.0);
2791 let cy = emu_from_px(Some(height_px), 160.0);
2792
2793 if let Some(floating) = &image.floating {
2794 let dist_l = emu_from_px(floating.dist_l_px, 0.0);
2795 let dist_r = emu_from_px(floating.dist_r_px, 0.0);
2796 let dist_t = emu_from_px(floating.dist_t_px, 0.0);
2797 let dist_b = emu_from_px(floating.dist_b_px, 0.0);
2798 let relative_height = floating
2799 .z_index
2800 .filter(|value| *value >= 0)
2801 .unwrap_or(251_658_240);
2802 let horizontal_relative_to = floating
2803 .horizontal_relative_to
2804 .as_deref()
2805 .map(str::trim)
2806 .filter(|value| !value.is_empty())
2807 .unwrap_or("margin");
2808 let vertical_relative_to = floating
2809 .vertical_relative_to
2810 .as_deref()
2811 .map(str::trim)
2812 .filter(|value| !value.is_empty())
2813 .unwrap_or("paragraph");
2814 let position_h_xml = if let Some(horizontal_align) = floating.horizontal_align {
2815 format!(
2816 r#"<wp:positionH relativeFrom="{}"><wp:align>{}</wp:align></wp:positionH>"#,
2817 escape_xml(horizontal_relative_to),
2818 escape_xml(image_horizontal_align_str(horizontal_align))
2819 )
2820 } else {
2821 format!(
2822 r#"<wp:positionH relativeFrom="{}"><wp:posOffset>{}</wp:posOffset></wp:positionH>"#,
2823 escape_xml(horizontal_relative_to),
2824 emu_from_px(floating.x_px, 0.0)
2825 )
2826 };
2827 let position_v_xml = if let Some(vertical_align) = floating.vertical_align {
2828 format!(
2829 r#"<wp:positionV relativeFrom="{}"><wp:align>{}</wp:align></wp:positionV>"#,
2830 escape_xml(vertical_relative_to),
2831 escape_xml(image_vertical_align_str(vertical_align))
2832 )
2833 } else {
2834 format!(
2835 r#"<wp:positionV relativeFrom="{}"><wp:posOffset>{}</wp:posOffset></wp:positionV>"#,
2836 escape_xml(vertical_relative_to),
2837 emu_from_px(floating.y_px, 0.0)
2838 )
2839 };
2840 let wrap_xml = match floating.wrap_type {
2841 Some(ImageWrapType::WrapNone) => "<wp:wrapNone/>".to_string(),
2842 Some(ImageWrapType::TopAndBottom) => "<wp:wrapTopAndBottom/>".to_string(),
2843 Some(ImageWrapType::Tight) => format!(
2844 "<wp:wrapTight{}/>",
2845 image_wrap_text_attr(floating.wrap_text)
2846 ),
2847 Some(ImageWrapType::Through) => format!(
2848 "<wp:wrapThrough{}/>",
2849 image_wrap_text_attr(floating.wrap_text)
2850 ),
2851 Some(ImageWrapType::Square) => format!(
2852 "<wp:wrapSquare{}/>",
2853 image_wrap_text_attr(floating.wrap_text)
2854 ),
2855 None => "<wp:wrapNone/>".to_string(),
2856 };
2857
2858 return format!(
2859 r#"<w:r><w:drawing><wp:anchor distT="{dist_t}" distB="{dist_b}" distL="{dist_l}" distR="{dist_r}" simplePos="0" relativeHeight="{relative_height}" behindDoc="{}" locked="0" layoutInCell="1" allowOverlap="1" xmlns:wp="{DRAWING_WORD_NS}"><wp:simplePos x="0" y="0"/>{position_h_xml}{position_v_xml}<wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/>{wrap_xml}<wp:docPr id="{run_id}" name="Picture {run_id}" descr="{}"/><wp:cNvGraphicFramePr><a:graphicFrameLocks xmlns:a="{DRAWING_MAIN_NS}" noChangeAspect="1"/></wp:cNvGraphicFramePr><a:graphic xmlns:a="{DRAWING_MAIN_NS}"><a:graphicData uri="{DRAWING_PICTURE_NS}"><pic:pic xmlns:pic="{DRAWING_PICTURE_NS}"><pic:nvPicPr><pic:cNvPr id="0" name="Picture {run_id}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{relationship_id}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#,
2860 if floating.behind_document == Some(true) {
2861 1
2862 } else {
2863 0
2864 },
2865 escape_xml(&alt)
2866 );
2867 }
2868
2869 format!(
2870 r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0" xmlns:wp="{DRAWING_WORD_NS}"><wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{run_id}" name="Picture {run_id}" descr="{}"/><a:graphic xmlns:a="{DRAWING_MAIN_NS}"><a:graphicData uri="{DRAWING_PICTURE_NS}"><pic:pic xmlns:pic="{DRAWING_PICTURE_NS}"><pic:nvPicPr><pic:cNvPr id="0" name="Picture {run_id}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{relationship_id}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#,
2871 escape_xml(&alt)
2872 )
2873}
2874
2875fn code_point_hex_from_symbol(symbol: Option<&str>, fallback_hex: &str) -> String {
2876 let Some(symbol) = symbol else {
2877 return fallback_hex.to_string();
2878 };
2879 let Some(first) = symbol.chars().next() else {
2880 return fallback_hex.to_string();
2881 };
2882 let code_point = first as u32;
2883 if code_point == 0 {
2884 return fallback_hex.to_string();
2885 }
2886 format!("{:04X}", code_point)
2887}
2888
2889fn wrap_with_hyperlink_xml(
2890 xml: &str,
2891 link: Option<&str>,
2892 state: &mut ImageSerializationState<'_>,
2893) -> String {
2894 let normalized_link = link.map(str::trim).filter(|value| !value.is_empty());
2895 let Some(normalized_link) = normalized_link else {
2896 return xml.to_string();
2897 };
2898
2899 if let Some(anchor) = normalized_link.strip_prefix('#') {
2900 return format!(
2901 r#"<w:hyperlink w:anchor="{}">{}</w:hyperlink>"#,
2902 escape_xml(anchor),
2903 xml
2904 );
2905 }
2906
2907 let relationship_id = ensure_hyperlink_relationship(normalized_link, state);
2908 format!(r#"<w:hyperlink r:id="{relationship_id}">{xml}</w:hyperlink>"#)
2909}
2910
2911fn resolve_dropdown_display_value(field: &FormFieldRunNode) -> String {
2912 let selected = field.value.as_deref().map(str::trim).unwrap_or("");
2913 if selected.is_empty() {
2914 return field
2915 .options
2916 .as_ref()
2917 .and_then(|options| options.first())
2918 .map(|option| option.display_text.clone())
2919 .unwrap_or_default();
2920 }
2921
2922 if let Some(options) = &field.options {
2923 if let Some(matching_option) = options.iter().find(|option| {
2924 option.display_text.trim() == selected
2925 || option
2926 .value
2927 .as_deref()
2928 .map(str::trim)
2929 .filter(|value| !value.is_empty())
2930 .is_some_and(|value| value == selected)
2931 }) {
2932 return matching_option.display_text.clone();
2933 }
2934 }
2935
2936 selected.to_string()
2937}
2938
2939#[derive(Clone, Copy, PartialEq, Eq)]
2940enum LegacyTextInputType {
2941 Regular,
2942 Number,
2943 Date,
2944 CurrentDate,
2945 CurrentTime,
2946 Calculated,
2947}
2948
2949fn normalize_legacy_text_input_type(raw_value: Option<&str>) -> Option<LegacyTextInputType> {
2950 let raw_value = raw_value?;
2951 let normalized = raw_value.trim().to_ascii_lowercase();
2952 if normalized.is_empty() {
2953 return None;
2954 }
2955
2956 match normalized.as_str() {
2957 "regular" => Some(LegacyTextInputType::Regular),
2958 "number" => Some(LegacyTextInputType::Number),
2959 "date" => Some(LegacyTextInputType::Date),
2960 "currentdate" | "current_date" | "current-date" => Some(LegacyTextInputType::CurrentDate),
2961 "currenttime" | "current_time" | "current-time" => Some(LegacyTextInputType::CurrentTime),
2962 "calculated" | "calculation" => Some(LegacyTextInputType::Calculated),
2963 _ => None,
2964 }
2965}
2966
2967fn legacy_text_input_type_str(value: LegacyTextInputType) -> &'static str {
2968 match value {
2969 LegacyTextInputType::Regular => "regular",
2970 LegacyTextInputType::Number => "number",
2971 LegacyTextInputType::Date => "date",
2972 LegacyTextInputType::CurrentDate => "currentDate",
2973 LegacyTextInputType::CurrentTime => "currentTime",
2974 LegacyTextInputType::Calculated => "calculated",
2975 }
2976}
2977
2978fn legacy_on_off_tag_xml(tag_name: &str, value: Option<bool>) -> String {
2979 let Some(value) = value else {
2980 return String::new();
2981 };
2982 format!(
2983 r#"<w:{tag_name} w:val="{}"/>"#,
2984 if value { "1" } else { "0" }
2985 )
2986}
2987
2988fn legacy_form_field_run_xml(
2989 field: &FormFieldRunNode,
2990 state: &mut ImageSerializationState<'_>,
2991) -> String {
2992 let mut ff_data_fragments: Vec<String> = Vec::new();
2993 let widget = field.widget.as_ref();
2994
2995 if let Some(name) = widget.and_then(|w| w.name.as_deref()).map(str::trim) {
2996 if !name.is_empty() {
2997 ff_data_fragments.push(format!(r#"<w:name w:val="{}"/>"#, escape_xml(name)));
2998 }
2999 }
3000 ff_data_fragments.push(legacy_on_off_tag_xml(
3001 "enabled",
3002 widget.and_then(|w| w.enabled),
3003 ));
3004 ff_data_fragments.push(legacy_on_off_tag_xml(
3005 "calcOnExit",
3006 widget.and_then(|w| w.calc_on_exit),
3007 ));
3008
3009 let instruction;
3010 let display_value;
3011
3012 match field.field_type {
3013 FormFieldType::Checkbox => {
3014 instruction = "FORMCHECKBOX";
3015 let checked_symbol = field.checked_symbol.as_deref().unwrap_or("☒");
3016 let unchecked_symbol = field.unchecked_symbol.as_deref().unwrap_or("☐");
3017 display_value = if field.checked == Some(true) {
3018 checked_symbol.to_string()
3019 } else {
3020 unchecked_symbol.to_string()
3021 };
3022
3023 let mut checkbox_fragments: Vec<String> = Vec::new();
3024 checkbox_fragments.push(legacy_on_off_tag_xml(
3025 "default",
3026 widget
3027 .and_then(|w| w.checkbox.as_ref())
3028 .and_then(|c| c.default_checked),
3029 ));
3030 checkbox_fragments.push(legacy_on_off_tag_xml("checked", field.checked));
3031 if widget
3032 .and_then(|w| w.checkbox.as_ref())
3033 .and_then(|c| c.size_mode)
3034 == Some(FormFieldCheckboxSizeMode::Auto)
3035 {
3036 checkbox_fragments.push("<w:sizeAuto/>".to_string());
3037 } else if let Some(size_pt) = widget
3038 .and_then(|w| w.checkbox.as_ref())
3039 .and_then(|c| c.size_pt)
3040 {
3041 if size_pt.is_finite() && size_pt > 0.0 {
3042 checkbox_fragments.push(format!(
3043 r#"<w:size w:val="{}"/>"#,
3044 (size_pt * 2.0).round() as i64
3045 ));
3046 }
3047 }
3048 if checkbox_fragments.is_empty() {
3049 ff_data_fragments.push("<w:checkBox/>".to_string());
3050 } else {
3051 ff_data_fragments.push(format!(
3052 "<w:checkBox>{}</w:checkBox>",
3053 checkbox_fragments.join("")
3054 ));
3055 }
3056 }
3057 FormFieldType::Dropdown => {
3058 instruction = "FORMDROPDOWN";
3059 display_value = resolve_dropdown_display_value(field);
3060 let mut dropdown_fragments: Vec<String> = Vec::new();
3061 if let Some(options) = &field.options {
3062 for option in options {
3063 let display_text = option.display_text.trim();
3064 if display_text.is_empty() {
3065 continue;
3066 }
3067 dropdown_fragments.push(format!(
3068 r#"<w:listEntry w:val="{}"/>"#,
3069 escape_xml(display_text)
3070 ));
3071 }
3072 }
3073 if let Some(default_value) = widget
3074 .and_then(|w| w.dropdown.as_ref())
3075 .and_then(|d| d.default_value.as_deref())
3076 .map(str::trim)
3077 {
3078 if !default_value.is_empty() {
3079 dropdown_fragments.push(format!(
3080 r#"<w:default w:val="{}"/>"#,
3081 escape_xml(default_value)
3082 ));
3083 }
3084 }
3085 if dropdown_fragments.is_empty() {
3086 ff_data_fragments.push("<w:ddList/>".to_string());
3087 } else {
3088 ff_data_fragments.push(format!(
3089 "<w:ddList>{}</w:ddList>",
3090 dropdown_fragments.join("")
3091 ));
3092 }
3093 }
3094 _ => {
3095 instruction = "FORMTEXT";
3096 let text_widget = widget.and_then(|w| w.text.as_ref());
3097 let normalized_input_type =
3098 normalize_legacy_text_input_type(text_widget.and_then(|t| t.input_type.as_deref()));
3099 let current_value = field.value.as_deref();
3100 let has_current_value = current_value
3101 .map(str::trim)
3102 .is_some_and(|value| !value.is_empty());
3103 let configured_default_text = text_widget.and_then(|t| t.default_text.as_deref());
3104 let has_configured_default_text = configured_default_text
3105 .map(str::trim)
3106 .is_some_and(|value| !value.is_empty());
3107 let default_text = if normalized_input_type == Some(LegacyTextInputType::Number)
3108 && !has_current_value
3109 && !has_configured_default_text
3110 {
3111 Some("0".to_string())
3112 } else {
3113 configured_default_text.map(str::to_string)
3114 };
3115 display_value = current_value
3116 .map(str::to_string)
3117 .or(default_text.clone())
3118 .unwrap_or_else(|| {
3119 if normalized_input_type == Some(LegacyTextInputType::Number) {
3120 "0".to_string()
3121 } else {
3122 String::new()
3123 }
3124 });
3125
3126 let mut text_fragments: Vec<String> = Vec::new();
3127 if let Some(input_type) = normalized_input_type {
3128 text_fragments.push(format!(
3129 r#"<w:type w:val="{}"/>"#,
3130 escape_xml(legacy_text_input_type_str(input_type))
3131 ));
3132 }
3133 if let Some(default_text) = &default_text {
3134 text_fragments.push(format!(
3135 r#"<w:default w:val="{}"/>"#,
3136 escape_xml(default_text)
3137 ));
3138 }
3139 if let Some(max_length) = text_widget.and_then(|t| t.max_length) {
3140 if max_length >= 0 {
3141 text_fragments.push(format!(r#"<w:maxLength w:val="{max_length}"/>"#));
3142 }
3143 }
3144 let normalized_text_format = text_widget
3145 .and_then(|t| t.text_format.as_deref())
3146 .map(str::trim)
3147 .filter(|value| !value.is_empty());
3148 if let Some(format_value) = normalized_text_format {
3149 text_fragments.push(format!(
3150 r#"<w:format w:val="{}"/>"#,
3151 escape_xml(format_value)
3152 ));
3153 } else if normalized_input_type == Some(LegacyTextInputType::Number) {
3154 text_fragments.push(r#"<w:format w:val="0"/>"#.to_string());
3155 }
3156 if text_fragments.is_empty() {
3157 ff_data_fragments.push("<w:textInput/>".to_string());
3158 } else {
3159 ff_data_fragments.push(format!(
3160 "<w:textInput>{}</w:textInput>",
3161 text_fragments.join("")
3162 ));
3163 }
3164 }
3165 }
3166
3167 let ff_data_xml = format!("<w:ffData>{}</w:ffData>", ff_data_fragments.join(""));
3168 let result_run_xml = format!(
3169 "<w:r>{}</w:r>",
3170 format!(
3171 "{}{}",
3172 run_properties_xml(field.style.as_deref(), state),
3173 render_text_tokens(&display_value)
3174 )
3175 );
3176 let legacy_field_xml = format!(
3177 "{}{}{}{}{}",
3178 format!(r#"<w:r><w:fldChar w:fldCharType="begin">{ff_data_xml}</w:fldChar></w:r>"#),
3179 format!(r#"<w:r><w:instrText xml:space="preserve"> {instruction} </w:instrText></w:r>"#),
3180 r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
3181 result_run_xml,
3182 r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#
3183 );
3184
3185 wrap_with_hyperlink_xml(&legacy_field_xml, field.link.as_deref(), state)
3186}
3187
3188fn form_field_source_preview_is_stale(field: &FormFieldRunNode) -> bool {
3189 field.source_preview.as_ref().is_some_and(|preview| {
3190 let checked = field
3191 .checked
3192 .or_else(|| {
3193 field
3194 .widget
3195 .as_ref()
3196 .and_then(|widget| widget.checkbox.as_ref())
3197 .and_then(|checkbox| checkbox.default_checked)
3198 })
3199 .unwrap_or(false);
3200 field.field_type != FormFieldType::Checkbox
3201 || checked != preview.checked
3202 || field.source_xml.as_deref().map_or(true, str::is_empty)
3203 })
3204}
3205
3206fn paragraph_has_stale_form_field_preview(paragraph: &ParagraphNode) -> bool {
3207 paragraph.children.iter().any(|child| match child {
3208 ParagraphChildNode::FormField(field) => form_field_source_preview_is_stale(field),
3209 _ => false,
3210 })
3211}
3212
3213fn table_has_stale_form_field_preview(table: &TableNode) -> bool {
3214 table.rows.iter().any(|row| {
3215 row.cells.iter().any(|cell| {
3216 cell.nodes.iter().any(|node| match node {
3217 TableCellContentNode::Paragraph(paragraph) => {
3218 paragraph_has_stale_form_field_preview(paragraph)
3219 }
3220 TableCellContentNode::Table(table) => table_has_stale_form_field_preview(table),
3221 })
3222 })
3223 })
3224}
3225
3226fn form_field_run_xml(field: &FormFieldRunNode, state: &mut ImageSerializationState<'_>) -> String {
3227 if !form_field_source_preview_is_stale(field) {
3228 if let Some(source_xml) = &field.source_xml {
3229 return wrap_with_hyperlink_xml(source_xml, field.link.as_deref(), state);
3230 }
3231 }
3232
3233 if field.source_kind == Some(FormFieldSourceKind::Legacy) {
3234 return legacy_form_field_run_xml(field, state);
3235 }
3236
3237 let mut properties: Vec<String> = Vec::new();
3238 if let Some(id) = field.id {
3239 properties.push(format!(r#"<w:id w:val="{id}"/>"#));
3240 }
3241 if let Some(title) = field.title.as_deref().map(str::trim) {
3242 if !title.is_empty() {
3243 properties.push(format!(r#"<w:alias w:val="{}"/>"#, escape_xml(title)));
3244 }
3245 }
3246 if let Some(tag) = field.tag.as_deref().map(str::trim) {
3247 if !tag.is_empty() {
3248 properties.push(format!(r#"<w:tag w:val="{}"/>"#, escape_xml(tag)));
3249 }
3250 }
3251 if let Some(placeholder) = field.placeholder.as_deref().map(str::trim) {
3252 if !placeholder.is_empty() {
3253 properties.push(format!(
3254 r#"<w:placeholder><w:docPart w:val="{}"/></w:placeholder>"#,
3255 escape_xml(placeholder)
3256 ));
3257 }
3258 }
3259
3260 let display_value;
3261 match field.field_type {
3262 FormFieldType::Checkbox => {
3263 let checked_symbol = field.checked_symbol.as_deref().unwrap_or("☒");
3264 let unchecked_symbol = field.unchecked_symbol.as_deref().unwrap_or("☐");
3265 let checked_hex = code_point_hex_from_symbol(field.checked_symbol.as_deref(), "2612");
3266 let unchecked_hex =
3267 code_point_hex_from_symbol(field.unchecked_symbol.as_deref(), "2610");
3268 properties.push(format!(
3269 r#"<w14:checkbox><w14:checked w14:val="{}"/><w14:checkedState w14:val="{checked_hex}" w14:font="MS Gothic"/><w14:uncheckedState w14:val="{unchecked_hex}" w14:font="MS Gothic"/></w14:checkbox>"#,
3270 if field.checked == Some(true) { "1" } else { "0" }
3271 ));
3272 display_value = if field.checked == Some(true) {
3273 checked_symbol.to_string()
3274 } else {
3275 unchecked_symbol.to_string()
3276 };
3277 }
3278 FormFieldType::Dropdown => {
3279 let options_xml = field
3280 .options
3281 .as_ref()
3282 .map(|options| {
3283 options
3284 .iter()
3285 .filter_map(|option| {
3286 let display_text = option.display_text.trim();
3287 if display_text.is_empty() {
3288 return None;
3289 }
3290 let value = option
3291 .value
3292 .as_deref()
3293 .map(str::trim)
3294 .filter(|value| !value.is_empty())
3295 .unwrap_or(display_text);
3296 Some(format!(
3297 r#"<w:listItem w:displayText="{}" w:value="{}"/>"#,
3298 escape_xml(display_text),
3299 escape_xml(value)
3300 ))
3301 })
3302 .collect::<String>()
3303 })
3304 .unwrap_or_default();
3305 let last_value = field
3306 .value
3307 .as_deref()
3308 .map(str::trim)
3309 .filter(|v| !v.is_empty());
3310 properties.push(format!(
3311 "<w:dropDownList>{options_xml}{}</w:dropDownList>",
3312 last_value
3313 .map(|value| format!(r#"<w:lastValue w:val="{}"/>"#, escape_xml(value)))
3314 .unwrap_or_default()
3315 ));
3316 display_value = resolve_dropdown_display_value(field);
3317 }
3318 FormFieldType::Date => {
3319 let normalized_date = field
3320 .value
3321 .as_deref()
3322 .map(str::trim)
3323 .filter(|v| !v.is_empty());
3324 properties.push(format!(
3325 "<w:date>{}</w:date>",
3326 normalized_date
3327 .map(|value| format!(r#"<w:fullDate w:val="{}"/>"#, escape_xml(value)))
3328 .unwrap_or_default()
3329 ));
3330 display_value = normalized_date.unwrap_or("").to_string();
3331 }
3332 FormFieldType::Text => {
3333 properties.push("<w:text/>".to_string());
3334 display_value = field.value.clone().unwrap_or_default();
3335 }
3336 }
3337
3338 let properties_xml = if properties.is_empty() {
3339 "<w:sdtPr/>".to_string()
3340 } else {
3341 format!("<w:sdtPr>{}</w:sdtPr>", properties.join(""))
3342 };
3343 let content_xml = format!(
3344 "<w:sdtContent><w:r>{}{}</w:r></w:sdtContent>",
3345 run_properties_xml(field.style.as_deref(), state),
3346 render_text_tokens(&display_value)
3347 );
3348 let sdt_xml = format!("<w:sdt>{properties_xml}{content_xml}</w:sdt>");
3349 wrap_with_hyperlink_xml(&sdt_xml, field.link.as_deref(), state)
3350}
3351
3352fn source_run_text(run_xml: &str) -> Option<String> {
3353 let tokens = crate::parse::parse_run_text_tokens(run_xml);
3354 if tokens.len() != 1 || tokens[0].note_reference.is_some() {
3355 return None;
3356 }
3357
3358 let text_ranges = extract_balanced_tag_ranges(run_xml, "w:t");
3359 if text_ranges.len() != 1 {
3360 return None;
3361 }
3362
3363 Some(tokens[0].text.clone())
3364}
3365
3366fn ensure_xml_space_preserve(opening_tag: &str) -> String {
3367 let lower = opening_tag.to_ascii_lowercase();
3368 let needle = "xml:space";
3369 let mut search_from = 0usize;
3370
3371 while let Some(relative_start) = lower[search_from..].find(needle) {
3372 let attribute_start = search_from + relative_start;
3373 let before_is_boundary = attribute_start == 0
3374 || opening_tag.as_bytes()[attribute_start - 1].is_ascii_whitespace();
3375 let mut cursor = attribute_start + needle.len();
3376 while opening_tag
3377 .as_bytes()
3378 .get(cursor)
3379 .is_some_and(|byte| byte.is_ascii_whitespace())
3380 {
3381 cursor += 1;
3382 }
3383 if before_is_boundary && opening_tag.as_bytes().get(cursor) == Some(&b'=') {
3384 cursor += 1;
3385 while opening_tag
3386 .as_bytes()
3387 .get(cursor)
3388 .is_some_and(|byte| byte.is_ascii_whitespace())
3389 {
3390 cursor += 1;
3391 }
3392 let quote = opening_tag.as_bytes().get(cursor).copied();
3393 if quote == Some(b'\'') || quote == Some(b'"') {
3394 let quote = quote.unwrap_or(b'"');
3395 let value_start = cursor + 1;
3396 let value_end = opening_tag.as_bytes()[value_start..]
3397 .iter()
3398 .position(|byte| *byte == quote)
3399 .map(|offset| value_start + offset);
3400 if let Some(value_end) = value_end {
3401 let mut updated = opening_tag.to_string();
3402 updated.replace_range(value_start..value_end, "preserve");
3403 return updated;
3404 }
3405 }
3406 }
3407 search_from = attribute_start + needle.len();
3408 }
3409
3410 let mut updated = opening_tag.to_string();
3411 updated.insert_str(updated.len() - 1, r#" xml:space="preserve""#);
3412 updated
3413}
3414
3415fn replace_source_run_text(run_xml: &str, text: &str) -> Option<String> {
3416 let text_range = extract_balanced_tag_ranges(run_xml, "w:t")
3417 .into_iter()
3418 .next()?;
3419 let source_text_tag = &run_xml[text_range.start..text_range.end];
3420
3421 let replacement = if text.contains(['\n', '\t']) || source_text_tag.trim_end().ends_with("/>") {
3422 render_text_tokens(text)
3423 } else {
3424 let opening_end = source_text_tag.find('>')?;
3425 let closing_start = source_text_tag.rfind("</w:t")?;
3426 if closing_start < opening_end {
3427 return None;
3428 }
3429
3430 let mut opening_tag = source_text_tag[..=opening_end].to_string();
3431 if should_preserve_whitespace(text) {
3432 opening_tag = ensure_xml_space_preserve(&opening_tag);
3433 }
3434 format!(
3435 "{opening_tag}{}{closing_tag}",
3436 escape_xml(text),
3437 closing_tag = &source_text_tag[closing_start..]
3438 )
3439 };
3440
3441 let mut patched = String::with_capacity(run_xml.len() + replacement.len());
3442 patched.push_str(&run_xml[..text_range.start]);
3443 patched.push_str(&replacement);
3444 patched.push_str(&run_xml[text_range.end..]);
3445 Some(patched)
3446}
3447
3448fn source_text_patch_xml_is_safe(source_xml: &str) -> bool {
3454 let lower = source_xml.to_ascii_lowercase();
3455 [
3456 "<w:fldchar",
3457 "<w:fldsimple",
3458 "<w:instrtext",
3459 "<w:tab",
3460 "<w:br",
3461 "<w:cr",
3462 "<w:footnotereference",
3463 "<w:endnotereference",
3464 "<w:drawing",
3465 "<w:pict",
3466 "<w:object",
3467 "<w:sdt",
3468 "<w:ins",
3469 "<w:del",
3470 "<w:conflictins",
3471 "<w:conflictdel",
3472 "<w:movefrom",
3473 "<w:moveto",
3474 "<w:commentrangestart",
3475 "<w:commentrangeend",
3476 "<w:commentreference",
3477 "<w:bookmarkstart",
3478 "<w:bookmarkend",
3479 "<w:customxml",
3480 "<w:smarttag",
3481 "<w:permstart",
3482 "<w:permend",
3483 "<w:prooferr",
3484 "<w:subdoc",
3485 "<w:altchunk",
3486 "<mc:alternatecontent",
3487 ]
3488 .iter()
3489 .all(|marker| !lower.contains(marker))
3490}
3491
3492fn patch_source_paragraph_text(
3493 paragraph: &ParagraphNode,
3494 source_xml: &str,
3495 patch_plan: &ParagraphSourceTextPatch,
3496) -> Option<String> {
3497 if !source_text_patch_xml_is_safe(source_xml) {
3498 return None;
3499 }
3500 let current_runs = paragraph
3501 .children
3502 .iter()
3503 .map(|child| match child {
3504 ParagraphChildNode::Text(run) => Some(run),
3505 _ => None,
3506 })
3507 .collect::<Option<Vec<_>>>()?;
3508 let source_ranges = extract_balanced_tag_ranges(source_xml, "w:r");
3509 if source_ranges.len() != current_runs.len() || patch_plan.runs.len() != current_runs.len() {
3510 return None;
3511 }
3512 if current_runs
3513 .iter()
3514 .zip(&patch_plan.runs)
3515 .any(|(current, expected)| {
3516 current.style != expected.style
3517 || current.link != expected.link
3518 || current.note_reference != expected.note_reference
3519 })
3520 {
3521 return None;
3522 }
3523
3524 let source_text = source_ranges
3525 .iter()
3526 .flat_map(|range| crate::parse::parse_run_text_tokens(&source_xml[range.start..range.end]))
3527 .map(|token| token.text)
3528 .collect::<String>();
3529 let current_text = current_runs
3530 .iter()
3531 .map(|run| run.text.as_str())
3532 .collect::<String>();
3533
3534 if source_text == current_text {
3535 return Some(source_xml.to_string());
3536 }
3537 let mut replacements = Vec::with_capacity(source_ranges.len());
3538 for (range, current_run) in source_ranges.iter().zip(current_runs) {
3539 let source_run_xml = &source_xml[range.start..range.end];
3540 let original_text = source_run_text(source_run_xml)?;
3541 if original_text == current_run.text {
3542 continue;
3543 }
3544 replacements.push((
3545 range.start,
3546 range.end,
3547 replace_source_run_text(source_run_xml, ¤t_run.text)?,
3548 ));
3549 }
3550
3551 let mut patched = source_xml.to_string();
3552 for (start, end, replacement) in replacements.into_iter().rev() {
3553 patched.replace_range(start..end, &replacement);
3554 }
3555 Some(patched)
3556}
3557
3558const PARAGRAPH_MARK_TAIL_ORDER: &[&str] = &["w:rPr", "w:sectPr", "w:pPrChange"];
3559const MARK_PROPERTY_ORDER: &[&str] = &[
3560 "w:ins",
3561 "w:del",
3562 "w:moveFrom",
3563 "w:moveTo",
3564 "w14:conflictIns",
3565 "w14:conflictDel",
3566 "w:rStyle",
3567 "w:rFonts",
3568 "w:b",
3569 "w:bCs",
3570 "w:i",
3571 "w:iCs",
3572 "w:caps",
3573 "w:smallCaps",
3574 "w:strike",
3575 "w:dStrike",
3576 "w:outline",
3577 "w:shadow",
3578 "w:emboss",
3579 "w:imprint",
3580 "w:noProof",
3581 "w:snapToGrid",
3582 "w:vanish",
3583 "w:webHidden",
3584 "w:color",
3585 "w:spacing",
3586 "w:w",
3587 "w:kern",
3588 "w:position",
3589 "w:sz",
3590 "w:szCs",
3591 "w:highlight",
3592 "w:u",
3593 "w:effect",
3594 "w:bdr",
3595 "w:shd",
3596 "w:fitText",
3597 "w:vertAlign",
3598 "w:rtl",
3599 "w:cs",
3600 "w:em",
3601 "w:lang",
3602 "w:eastAsianLayout",
3603 "w:specVanish",
3604 "w:oMath",
3605 "w14:glow",
3606 "w14:shadow",
3607 "w14:reflection",
3608 "w14:textOutline",
3609 "w14:textFill",
3610 "w14:scene3d",
3611 "w14:props3d",
3612 "w14:ligatures",
3613 "w14:numForm",
3614 "w14:numSpacing",
3615 "w14:stylisticSets",
3616 "w14:cntxtAlts",
3617 "w:rPrChange",
3618];
3619
3620fn direct_property_children(xml: &str) -> Vec<(String, crate::xml::TagRange)> {
3621 crate::xml::extract_direct_child_tag_ranges(xml)
3622 .into_iter()
3623 .map(|range| {
3624 (
3625 range.tag_name,
3626 crate::xml::TagRange {
3627 start: range.start,
3628 end: range.end,
3629 },
3630 )
3631 })
3632 .collect()
3633}
3634
3635const MARK_MANAGED_PROPERTIES: &[&str] = &[
3636 "w:rFonts",
3637 "w:b",
3638 "w:i",
3639 "w:u",
3640 "w:strike",
3641 "w:caps",
3642 "w:smallCaps",
3643 "w:color",
3644 "w:highlight",
3645 "w:shd",
3646 "w:sz",
3647 "w:szCs",
3648 "w:lang",
3649 "w:rtl",
3650 "w:cs",
3651 "w:spacing",
3652 "w:w",
3653 "w:kern",
3654 "w:vertAlign",
3655 "w:bdr",
3656 "w14:shadow",
3657 "w14:textOutline",
3658 "w14:ligatures",
3659 "w14:cntxtAlts",
3660];
3661
3662fn property_attributes(tag_name: &str) -> &'static [&'static str] {
3663 match tag_name {
3664 "w:rFonts" => &[
3665 "w:ascii",
3666 "w:hAnsi",
3667 "w:eastAsia",
3668 "w:cs",
3669 "w:asciiTheme",
3670 "w:hAnsiTheme",
3671 "w:eastAsiaTheme",
3672 "w:csTheme",
3673 "w:hint",
3674 ],
3675 "w:lang" => &["w:val", "w:eastAsia", "w:bidi"],
3676 "w:shd" => &["w:val", "w:color", "w:fill"],
3677 "w:bdr" => &["w:val", "w:color", "w:sz", "w:space", "w:frame", "w:shadow"],
3678 "w14:ligatures" | "w14:cntxtAlts" => &["w14:val"],
3679 _ => &["w:val"],
3680 }
3681}
3682
3683fn remove_xml_attribute(tag: &str, name: &str) -> String {
3684 let Some((value_start, value_end)) = attribute_value_range(tag, name) else {
3685 return tag.to_string();
3686 };
3687 let mut start = value_start.saturating_sub(1);
3688 while start > 0
3689 && (tag.as_bytes()[start - 1].is_ascii_whitespace() || tag.as_bytes()[start - 1] == b'=')
3690 {
3691 start -= 1;
3692 }
3693 start = start.saturating_sub(name.len());
3694 while start > 0 && tag.as_bytes()[start - 1].is_ascii_whitespace() {
3695 start -= 1;
3696 }
3697 let mut updated = tag.to_string();
3698 updated.replace_range(start..value_end + 1, "");
3699 updated
3700}
3701
3702fn insert_property_child(
3703 xml: &str,
3704 container: &str,
3705 name: &str,
3706 child: &str,
3707 order: &[&str],
3708) -> String {
3709 let open_end = opening_tag_end(xml, 0).unwrap_or(xml.len());
3710 let opening = &xml[..open_end];
3711 if opening.trim_end().ends_with("/>") {
3712 return format!(
3713 "{}>{child}</{container}>",
3714 opening
3715 .trim_end()
3716 .trim_end_matches('>')
3717 .trim_end()
3718 .trim_end_matches('/')
3719 .trim_end()
3720 );
3721 }
3722 let mut insert_at = xml.rfind(&format!("</{container}>")).unwrap_or(xml.len());
3723 if let Some(rank) = order.iter().position(|value| *value == name) {
3724 for (other_name, range) in direct_property_children(xml) {
3725 if order
3726 .iter()
3727 .position(|value| *value == other_name)
3728 .is_some_and(|other_rank| other_rank > rank)
3729 {
3730 insert_at = insert_at.min(range.start);
3731 }
3732 }
3733 }
3734 let mut updated = xml.to_string();
3735 updated.insert_str(insert_at, child);
3736 updated
3737}
3738
3739fn merge_mark_properties_xml(source: &str, generated: &str) -> String {
3740 let source_children = direct_property_children(source);
3741 let generated = generated.to_string();
3742 let generated_children = direct_property_children(&generated);
3743 let mut consumed = std::collections::HashSet::new();
3744 let mut replacements = Vec::new();
3745 for (name, range) in source_children {
3746 if !MARK_MANAGED_PROPERTIES.contains(&name.as_str()) {
3747 continue;
3748 }
3749 let block = &source[range.start..range.end];
3750 if matches!(name.as_str(), "w14:shadow" | "w14:textOutline")
3751 && !crate::parse::text_effects::supports(block, &name)
3752 {
3753 continue;
3754 }
3755 let replacement = generated_children
3756 .iter()
3757 .enumerate()
3758 .find(|(index, (other_name, _))| !consumed.contains(index) && *other_name == name);
3759 if let Some((index, (_, generated_range))) = replacement {
3760 consumed.insert(index);
3761 let generated_block = &generated[generated_range.start..generated_range.end];
3762 let merged = if matches!(name.as_str(), "w14:shadow" | "w14:textOutline") {
3763 generated_block.to_string()
3764 } else {
3765 let end = opening_tag_end(block, 0).unwrap_or(block.len());
3766 let generated_end =
3767 opening_tag_end(generated_block, 0).unwrap_or(generated_block.len());
3768 let mut opening = block[..end].to_string();
3769 for attribute in property_attributes(&name) {
3770 opening = if let Some(value) =
3771 get_attribute(&generated_block[..generated_end], attribute)
3772 {
3773 set_xml_attribute(&opening, attribute, &value)
3774 } else {
3775 remove_xml_attribute(&opening, attribute)
3776 };
3777 }
3778 format!("{opening}{}", &block[end..])
3779 };
3780 replacements.push((range.start, range.end, merged));
3781 } else {
3782 replacements.push((range.start, range.end, String::new()));
3783 }
3784 }
3785 let mut updated = replace_xml_ranges(source, replacements);
3786 for (index, (name, range)) in generated_children.iter().enumerate() {
3787 if !consumed.contains(&index) {
3788 updated = insert_property_child(
3789 &updated,
3790 "w:rPr",
3791 name,
3792 &generated[range.start..range.end],
3793 MARK_PROPERTY_ORDER,
3794 );
3795 }
3796 }
3797 updated
3798}
3799
3800fn paragraph_mark_properties_xml(style: Option<&TextStyle>, state: &ImageSerializationState<'_>) -> String {
3801 let order = MARK_PROPERTY_ORDER;
3802 let mut generated = run_properties_xml(style, state);
3803 if generated.is_empty() {
3804 generated = "<w:rPr/>".to_string();
3805 }
3806 if let Some(style) = style {
3807 for (name, value) in [
3808 ("b", style.bold),
3809 ("i", style.italic),
3810 ("strike", style.strike),
3811 ] {
3812 if value == Some(false) {
3813 generated = insert_property_child(
3814 &generated,
3815 "w:rPr",
3816 &format!("w:{name}"),
3817 &on_off_element_xml(name, value),
3818 order,
3819 );
3820 }
3821 }
3822 if style.underline == Some(false) {
3823 generated =
3824 insert_property_child(&generated, "w:rPr", "w:u", r#"<w:u w:val="none"/>"#, order);
3825 }
3826 }
3827 merge_mark_properties_xml("<w:rPr/>", &generated)
3828}
3829
3830fn paragraph_mark_xml(paragraph: &ParagraphNode, source: Option<&str>, state: &ImageSerializationState<'_>) -> String {
3831 if paragraph.paragraph_mark_style == paragraph.source_paragraph_mark_style
3832 && (source.is_some() || paragraph.source_paragraph_mark_style.is_some())
3833 {
3834 return source.unwrap_or_default().to_string();
3835 }
3836 if paragraph.paragraph_mark_style.is_none() && source.is_none() {
3837 return String::new();
3838 }
3839 let generated = paragraph_mark_properties_xml(paragraph.paragraph_mark_style.as_deref(), state);
3840 source
3841 .map(|source| merge_mark_properties_xml(source, &generated))
3842 .unwrap_or(generated)
3843}
3844
3845fn serialized_paragraph_snap_to_grid(
3846 style: &ParagraphStyle,
3847 state: &ImageSerializationState<'_>,
3848) -> Option<bool> {
3849 let effective = style.snap_to_grid.or(style.source_inherited_snap_to_grid);
3850 let style_id = style.style_id.clone().or_else(|| {
3851 style
3852 .heading_level
3853 .map(|level| format!("Heading{}", heading_level_number(level)))
3854 });
3855 let inherited = style_id
3856 .as_ref()
3857 .and_then(|id| state.paragraph_style_grid_owners.get(id).copied())
3858 .or(state.current_table_snap_to_grid)
3859 .or_else(|| {
3860 style_id
3861 .as_ref()
3862 .and_then(|id| state.paragraph_snap_to_grid_by_style_id.get(id).copied())
3863 })
3864 .or(state.default_paragraph_snap_to_grid);
3865 let inherited_only = style.snap_to_grid.is_none()
3866 || (style.source_has_snap_to_grid == Some(false)
3867 && style.snap_to_grid == style.source_snap_to_grid);
3868 if inherited_only && inherited == effective {
3869 None
3870 } else {
3871 effective
3872 }
3873}
3874
3875fn serialized_paragraph_text_alignment(
3876 style: &ParagraphStyle,
3877 state: &ImageSerializationState<'_>,
3878) -> Option<ParagraphTextAlignment> {
3879 let effective = style
3880 .text_alignment
3881 .or(style.source_inherited_text_alignment);
3882 let style_id = style.style_id.clone().or_else(|| {
3883 style
3884 .heading_level
3885 .map(|level| format!("Heading{}", heading_level_number(level)))
3886 });
3887 let inherited = style_id
3888 .as_ref()
3889 .and_then(|id| state.paragraph_style_alignment_owners.get(id).copied())
3890 .or(state.current_table_text_alignment)
3891 .or_else(|| {
3892 style_id
3893 .as_ref()
3894 .and_then(|id| state.paragraph_text_alignment_by_style_id.get(id).copied())
3895 })
3896 .or(state.default_paragraph_text_alignment);
3897 let inherited_only = style.text_alignment.is_none()
3898 || (style.source_has_text_alignment == Some(false)
3899 && style.text_alignment == style.source_text_alignment);
3900 if inherited_only && inherited == effective {
3901 None
3902 } else {
3903 effective
3904 }
3905}
3906
3907struct TableAlignmentContext {
3908 style_id: Option<String>,
3909 look: crate::parse::ParsedTableLook,
3910 grid_bound: i64,
3911 columns: i64,
3912 rows: i64,
3913}
3914
3915fn resolve_table_alignment_context(
3916 table: &TableNode,
3917 state: &ImageSerializationState<'_>,
3918 source_xml: Option<&str>,
3919) -> TableAlignmentContext {
3920 let style_id = table
3921 .style
3922 .as_ref()
3923 .and_then(|style| style.style_id.clone())
3924 .or_else(|| {
3925 if table
3926 .style
3927 .as_ref()
3928 .is_some_and(|style| style.source_style_id.is_some())
3929 {
3930 return None;
3931 }
3932 source_xml
3933 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblPr"))
3934 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblStyle"))
3935 .and_then(|tag| get_attribute(tag, "w:val"))
3936 });
3937 let inherited_look = style_id
3938 .as_ref()
3939 .and_then(|id| state.table_styles.get(id))
3940 .and_then(|style| {
3941 style
3942 .conditions
3943 .get(&crate::parse::TableConditionalStyleType::WholeTable)
3944 })
3945 .and_then(|condition| condition.table_look.clone());
3946 let direct_look = source_xml
3947 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblPr"))
3948 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblLook"))
3949 .and_then(|xml| crate::parse::parse_table_look(Some(xml)));
3950 let look = direct_look
3951 .or(inherited_look)
3952 .unwrap_or_else(crate::parse::default_table_look);
3953 let grid_bound = crate::parse::table_grid_column_bound(table);
3954 let columns = table
3955 .rows
3956 .iter()
3957 .map(|row| {
3958 row.cells
3959 .iter()
3960 .fold(0i64, |total, cell| {
3961 total.saturating_add(
3962 cell.style
3963 .as_ref()
3964 .and_then(|style| style.grid_span)
3965 .unwrap_or(1)
3966 .max(1),
3967 )
3968 })
3969 .saturating_add(crate::parse::resolve_table_grid_skip_count(
3970 row.style.as_ref().and_then(|style| style.grid_before),
3971 grid_bound,
3972 ))
3973 .saturating_add(crate::parse::resolve_table_grid_skip_count(
3974 row.style.as_ref().and_then(|style| style.grid_after),
3975 grid_bound,
3976 ))
3977 })
3978 .max()
3979 .unwrap_or(0)
3980 .max(grid_bound)
3981 .max(1);
3982 TableAlignmentContext {
3983 style_id,
3984 look,
3985 grid_bound,
3986 columns,
3987 rows: table.rows.len() as i64,
3988 }
3989}
3990
3991fn table_cell_text_alignment(
3992 context: &TableAlignmentContext,
3993 row: usize,
3994 column: i64,
3995 span: i64,
3996 state: &ImageSerializationState<'_>,
3997) -> Option<ParagraphTextAlignment> {
3998 let style = context
3999 .style_id
4000 .as_ref()
4001 .and_then(|id| state.table_styles.get(id))?;
4002 crate::parse::resolve_table_condition_for_cell(
4003 style,
4004 &context.look,
4005 row as i64,
4006 context.rows,
4007 column,
4008 column + span - 1,
4009 context.columns,
4010 )
4011 .and_then(|condition| condition.paragraph_text_alignment)
4012}
4013
4014fn table_cell_snap_to_grid(
4015 context: &TableAlignmentContext,
4016 row: usize,
4017 column: i64,
4018 span: i64,
4019 state: &ImageSerializationState<'_>,
4020) -> Option<bool> {
4021 let style = context
4022 .style_id
4023 .as_ref()
4024 .and_then(|id| state.table_styles.get(id))?;
4025 crate::parse::resolve_table_condition_for_cell(
4026 style,
4027 &context.look,
4028 row as i64,
4029 context.rows,
4030 column,
4031 column + span - 1,
4032 context.columns,
4033 )
4034 .and_then(|condition| condition.paragraph_snap_to_grid)
4035}
4036
4037fn cell_alignment_context_changed(
4038 cell: &TableCellNode,
4039 state: &ImageSerializationState<'_>,
4040) -> bool {
4041 cell.nodes.iter().any(|node| match node {
4042 TableCellContentNode::Paragraph(paragraph) => {
4043 paragraph.style.as_ref().is_some_and(|style| {
4044 (style.source_has_snap_to_grid == Some(false)
4045 && style.snap_to_grid == style.source_snap_to_grid
4046 && serialized_paragraph_snap_to_grid(style, state).is_some())
4047 || style.source_has_text_alignment == Some(false)
4048 && style.text_alignment == style.source_text_alignment
4049 && serialized_paragraph_text_alignment(style, state).is_some()
4050 })
4051 }
4052 TableCellContentNode::Table(table) => {
4053 table_alignment_context_changed(table, state, table.source_xml.as_deref())
4054 }
4055 })
4056}
4057
4058fn table_alignment_context_changed(
4059 table: &TableNode,
4060 state: &ImageSerializationState<'_>,
4061 source: Option<&str>,
4062) -> bool {
4063 let context = resolve_table_alignment_context(table, state, source);
4064 table.rows.iter().enumerate().any(|(row_index, row)| {
4065 let bound = context.grid_bound;
4066 let mut column = crate::parse::resolve_table_grid_skip_count(
4067 row.style.as_ref().and_then(|style| style.grid_before),
4068 bound,
4069 );
4070 row.cells.iter().any(|cell| {
4071 let span = cell
4072 .style
4073 .as_ref()
4074 .and_then(|style| style.grid_span)
4075 .unwrap_or(1)
4076 .max(1);
4077 let current = table_cell_text_alignment(&context, row_index, column, span, state);
4078 let current_snap = table_cell_snap_to_grid(&context, row_index, column, span, state);
4079 column += span;
4080 cell.nodes.iter().any(|node| match node {
4081 TableCellContentNode::Paragraph(paragraph) => {
4082 paragraph.style.as_ref().is_some_and(|style| {
4083 if style.source_has_snap_to_grid == Some(false)
4084 && style.snap_to_grid == style.source_snap_to_grid
4085 {
4086 let inherited = style
4087 .style_id
4088 .as_ref()
4089 .and_then(|id| state.paragraph_style_grid_owners.get(id).copied())
4090 .or(current_snap)
4091 .or_else(|| {
4092 style.style_id.as_ref().and_then(|id| {
4093 state.paragraph_snap_to_grid_by_style_id.get(id).copied()
4094 })
4095 })
4096 .or(state.default_paragraph_snap_to_grid);
4097 if inherited
4098 != style.snap_to_grid.or(style.source_inherited_snap_to_grid)
4099 {
4100 return true;
4101 }
4102 }
4103 if style.source_has_text_alignment != Some(false)
4104 || style.text_alignment != style.source_text_alignment
4105 {
4106 return false;
4107 }
4108 let style_id = style.style_id.as_ref();
4109 let inherited = style_id
4110 .and_then(|id| state.paragraph_style_alignment_owners.get(id).copied())
4111 .or(current)
4112 .or_else(|| {
4113 style_id.and_then(|id| {
4114 state.paragraph_text_alignment_by_style_id.get(id).copied()
4115 })
4116 })
4117 .or(state.default_paragraph_text_alignment);
4118 inherited
4119 != style
4120 .text_alignment
4121 .or(style.source_inherited_text_alignment)
4122 })
4123 }
4124 TableCellContentNode::Table(nested) => {
4125 table_alignment_context_changed(nested, state, nested.source_xml.as_deref())
4126 }
4127 })
4128 })
4129 })
4130}
4131
4132fn paragraph_properties_with_mark_xml(
4133 paragraph: &ParagraphNode,
4134 state: &ImageSerializationState<'_>,
4135) -> String {
4136 let text_alignment = paragraph
4137 .style
4138 .as_ref()
4139 .and_then(|style| serialized_paragraph_text_alignment(style, state));
4140 let snap_to_grid = paragraph
4141 .style
4142 .as_ref()
4143 .and_then(|style| serialized_paragraph_snap_to_grid(style, state));
4144 let properties =
4145 paragraph_properties_xml(paragraph.style.as_deref(), text_alignment, snap_to_grid);
4146 let mark = paragraph_mark_xml(
4147 paragraph,
4148 paragraph.source_paragraph_mark_properties_xml.as_deref(),
4149 state,
4150 );
4151 if mark.is_empty() {
4152 return properties;
4153 }
4154 insert_property_child(
4155 if properties.is_empty() {
4156 "<w:pPr/>"
4157 } else {
4158 &properties
4159 },
4160 "w:pPr",
4161 "w:rPr",
4162 &mark,
4163 PARAGRAPH_MARK_TAIL_ORDER,
4164 )
4165}
4166
4167fn patch_source_paragraph_mark(paragraph: &ParagraphNode, source: &str, state: &ImageSerializationState<'_>) -> String {
4168 if paragraph.paragraph_mark_style == paragraph.source_paragraph_mark_style {
4169 return source.to_string();
4170 }
4171 let ppr_range = direct_property_children(source)
4172 .into_iter()
4173 .find(|(name, _)| name == "w:pPr")
4174 .map(|(_, range)| range);
4175 let original_properties = ppr_range
4176 .as_ref()
4177 .map(|range| &source[range.start..range.end])
4178 .unwrap_or("<w:pPr/>");
4179 let mark_range = direct_property_children(original_properties)
4180 .into_iter()
4181 .find(|(name, _)| name == "w:rPr")
4182 .map(|(_, range)| range);
4183 let mark = paragraph_mark_xml(
4184 paragraph,
4185 mark_range
4186 .as_ref()
4187 .map(|range| &original_properties[range.start..range.end]),
4188 state,
4189 );
4190 let mut properties = original_properties.to_string();
4191 if let Some(range) = mark_range {
4192 properties.replace_range(range.start..range.end, &mark);
4193 } else if !mark.is_empty() {
4194 properties = insert_property_child(
4195 &properties,
4196 "w:pPr",
4197 "w:rPr",
4198 &mark,
4199 PARAGRAPH_MARK_TAIL_ORDER,
4200 );
4201 }
4202 let mut updated = source.to_string();
4203 if let Some(range) = ppr_range {
4204 updated.replace_range(range.start..range.end, &properties);
4205 } else if paragraph.paragraph_mark_style.is_some() {
4206 let insert_at = opening_tag_end(source, 0).unwrap_or(0);
4207 updated.insert_str(insert_at, &properties);
4208 }
4209 updated
4210}
4211fn paragraph_text_alignment_str(value: ParagraphTextAlignment) -> &'static str {
4212 match value {
4213 ParagraphTextAlignment::Auto => "auto",
4214 ParagraphTextAlignment::Top => "top",
4215 ParagraphTextAlignment::Center => "center",
4216 ParagraphTextAlignment::Baseline => "baseline",
4217 ParagraphTextAlignment::Bottom => "bottom",
4218 }
4219}
4220
4221fn patch_source_paragraph_text_alignment(
4222 paragraph: &ParagraphNode,
4223 source: &str,
4224 state: &ImageSerializationState<'_>,
4225) -> String {
4226 let Some(style) = paragraph.style.as_ref() else {
4227 return source.to_string();
4228 };
4229 if style.text_alignment == style.source_text_alignment
4230 && (style.source_has_text_alignment.is_none() || style.source_text_alignment.is_none())
4231 {
4232 return source.to_string();
4233 }
4234 let range = direct_property_children(source)
4235 .into_iter()
4236 .find(|(name, _)| name == "w:pPr")
4237 .map(|(_, range)| range);
4238 let properties = range
4239 .as_ref()
4240 .map(|range| &source[range.start..range.end])
4241 .unwrap_or("<w:pPr/>");
4242 let desired = serialized_paragraph_text_alignment(style, state);
4243 let current = crate::parse::direct_table_property(properties, "w:textAlignment")
4244 .and_then(|tag| get_attribute(tag, "w:val"));
4245 if current.as_deref() == desired.map(paragraph_text_alignment_str) {
4246 return source.to_string();
4247 }
4248 let attributes =
4249 desired.map(|value| [("w:val", paragraph_text_alignment_str(value).to_string())]);
4250 let updated = patch_direct_table_property(
4251 properties,
4252 "w:pPr",
4253 "w:textAlignment",
4254 attributes.as_ref().map(|values| values.as_slice()),
4255 &[
4256 "w:textDirection",
4257 "w:textAlignment",
4258 "w:textboxTightWrap",
4259 "w:outlineLvl",
4260 "w:divId",
4261 "w:cnfStyle",
4262 "w:rPr",
4263 "w:sectPr",
4264 "w:pPrChange",
4265 ],
4266 );
4267 let mut source = source.to_string();
4268 if let Some(range) = range {
4269 source.replace_range(range.start..range.end, &updated);
4270 } else if desired.is_some() {
4271 let end = opening_tag_end(&source, 0).unwrap_or(0);
4272 source.insert_str(end, &updated);
4273 }
4274 source
4275}
4276
4277fn patch_source_paragraph_snap_to_grid(
4278 paragraph: &ParagraphNode,
4279 source: &str,
4280 state: &ImageSerializationState<'_>,
4281) -> String {
4282 let Some(style) = paragraph.style.as_ref() else {
4283 return source.to_string();
4284 };
4285 if style.snap_to_grid == style.source_snap_to_grid
4286 && (style.source_has_snap_to_grid.is_none() || style.source_snap_to_grid.is_none())
4287 {
4288 return source.to_string();
4289 }
4290 let range = direct_property_children(source)
4291 .into_iter()
4292 .find(|(name, _)| name == "w:pPr")
4293 .map(|(_, range)| range);
4294 let properties = range
4295 .as_ref()
4296 .map(|range| &source[range.start..range.end])
4297 .unwrap_or("<w:pPr/>");
4298 let desired = serialized_paragraph_snap_to_grid(style, state);
4299 let current = crate::parse::direct_table_property(properties, "w:snapToGrid")
4300 .and_then(|tag| crate::xml::parse_on_off_attribute(tag, "snapToGrid"));
4301 if current == desired {
4302 return source.to_string();
4303 }
4304 let attributes = desired.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
4305 let updated = patch_direct_table_property(
4306 properties,
4307 "w:pPr",
4308 "w:snapToGrid",
4309 attributes.as_ref().map(|values| values.as_slice()),
4310 &[
4311 "w:snapToGrid",
4312 "w:spacing",
4313 "w:ind",
4314 "w:contextualSpacing",
4315 "w:mirrorIndents",
4316 "w:suppressOverlap",
4317 "w:jc",
4318 "w:textDirection",
4319 "w:textAlignment",
4320 "w:textboxTightWrap",
4321 "w:outlineLvl",
4322 "w:divId",
4323 "w:cnfStyle",
4324 "w:rPr",
4325 "w:sectPr",
4326 "w:pPrChange",
4327 ],
4328 );
4329 let mut source = source.to_string();
4330 if let Some(range) = range {
4331 source.replace_range(range.start..range.end, &updated);
4332 } else if desired.is_some() {
4333 let insert_at = opening_tag_end(&source, 0).unwrap_or(0);
4334 source.insert_str(insert_at, &updated);
4335 }
4336 source
4337}
4338
4339fn patch_source_paragraph_auto_spacing(paragraph: &ParagraphNode, source: &str) -> String {
4340 let Some(style) = paragraph.style.as_ref() else {
4341 return source.to_string();
4342 };
4343 let edits = [
4344 (
4345 "w:beforeAutospacing",
4346 style.spacing.as_ref().and_then(|s| s.before_auto_spacing),
4347 style.source_before_auto_spacing,
4348 ),
4349 (
4350 "w:afterAutospacing",
4351 style.spacing.as_ref().and_then(|s| s.after_auto_spacing),
4352 style.source_after_auto_spacing,
4353 ),
4354 ];
4355 if edits
4356 .iter()
4357 .all(|(_, desired, original)| desired == original)
4358 {
4359 return source.to_string();
4360 }
4361 let range = direct_property_children(source)
4362 .into_iter()
4363 .find(|(name, _)| name == "w:pPr")
4364 .map(|(_, range)| range);
4365 let properties = range
4366 .as_ref()
4367 .map(|range| &source[range.start..range.end])
4368 .unwrap_or("<w:pPr/>");
4369 let spacing_range = direct_property_children(properties)
4370 .into_iter()
4371 .find(|(name, _)| name == "w:spacing")
4372 .map(|(_, range)| range);
4373 let mut spacing = spacing_range
4374 .as_ref()
4375 .map(|range| properties[range.start..range.end].to_string())
4376 .unwrap_or_else(|| "<w:spacing/>".to_string());
4377 for (name, desired, original) in edits {
4378 if desired == original {
4379 continue;
4380 }
4381 let opening_end = opening_tag_end(&spacing, 0).unwrap_or(spacing.len());
4382 let opening = &spacing[..opening_end];
4383 let updated = match desired {
4384 Some(value) => set_xml_attribute(opening, name, if value { "1" } else { "0" }),
4385 None => remove_xml_attribute(opening, name),
4386 };
4387 spacing.replace_range(..opening_end, &updated);
4388 }
4389 let mut updated = properties.to_string();
4390 if let Some(spacing_range) = spacing_range {
4391 updated.replace_range(spacing_range.start..spacing_range.end, &spacing);
4392 } else {
4393 updated = insert_property_child(
4394 properties,
4395 "w:pPr",
4396 "w:spacing",
4397 &spacing,
4398 &[
4399 "w:spacing",
4400 "w:ind",
4401 "w:contextualSpacing",
4402 "w:mirrorIndents",
4403 "w:suppressOverlap",
4404 "w:jc",
4405 "w:textDirection",
4406 "w:textAlignment",
4407 "w:textboxTightWrap",
4408 "w:outlineLvl",
4409 "w:divId",
4410 "w:cnfStyle",
4411 "w:rPr",
4412 "w:sectPr",
4413 "w:pPrChange",
4414 ],
4415 );
4416 }
4417 let mut source = source.to_string();
4418 if let Some(range) = range {
4419 source.replace_range(range.start..range.end, &updated);
4420 } else {
4421 let insert_at = opening_tag_end(&source, 0).unwrap_or(0);
4422 source.insert_str(insert_at, &updated);
4423 }
4424 source
4425}
4426
4427fn patch_source_paragraph_properties(
4428 paragraph: &ParagraphNode,
4429 source: &str,
4430 state: &ImageSerializationState<'_>,
4431) -> String {
4432 let marked = patch_source_paragraph_mark(paragraph, source, state);
4433 let aligned = patch_source_paragraph_text_alignment(paragraph, &marked, state);
4434 let grid = patch_source_paragraph_snap_to_grid(paragraph, &aligned, state);
4435 patch_source_paragraph_auto_spacing(paragraph, &grid)
4436}
4437
4438fn paragraph_xml(
4439 paragraph: &ParagraphNode,
4440 state: &mut ImageSerializationState<'_>,
4441 run_id_ref: &mut i64,
4442) -> String {
4443 if !paragraph_has_stale_form_field_preview(paragraph) {
4444 if let Some(source_xml) = ¶graph.source_xml {
4445 if let Some(patch_plan) = ¶graph.source_text_patch {
4446 if let Some(patched) = patch_source_paragraph_text(paragraph, source_xml, patch_plan) {
4447 return patch_source_paragraph_properties(paragraph, &patched, state);
4448 }
4449 } else {
4450 return patch_source_paragraph_properties(paragraph, source_xml, state);
4451 }
4452 }
4453 }
4454
4455 let runs = paragraph
4456 .children
4457 .iter()
4458 .filter_map(|child| {
4459 let xml = match child {
4460 ParagraphChildNode::Text(text_run) => {
4461 let has_note_reference = text_run.note_reference.is_some();
4462 let note_reference_xml = text_run
4463 .note_reference
4464 .as_ref()
4465 .map(|reference| match reference.kind {
4466 NoteReferenceKind::Footnote => {
4467 format!(r#"<w:footnoteReference w:id="{}"/>"#, reference.id)
4468 }
4469 NoteReferenceKind::Endnote => {
4470 format!(r#"<w:endnoteReference w:id="{}"/>"#, reference.id)
4471 }
4472 })
4473 .unwrap_or_default();
4474 let text_xml = if has_note_reference && text_run.text.is_empty() {
4475 String::new()
4476 } else {
4477 render_text_tokens(&text_run.text)
4478 };
4479 let run_xml = format!(
4480 "<w:r>{}{}{}</w:r>",
4481 run_properties_xml(text_run.style.as_deref(), state),
4482 note_reference_xml,
4483 text_xml
4484 );
4485 wrap_with_hyperlink_xml(&run_xml, text_run.link.as_deref(), state)
4486 }
4487 ParagraphChildNode::FormField(form_field) => form_field_run_xml(form_field, state),
4488 ParagraphChildNode::Image(image) => {
4489 *run_id_ref += 1;
4490 drawing_run_xml(image, state, *run_id_ref)
4491 }
4492 };
4493 if xml.is_empty() {
4494 None
4495 } else {
4496 Some(xml)
4497 }
4498 })
4499 .collect::<String>();
4500
4501 let paragraph_runs = if runs.is_empty() {
4502 "<w:r><w:t/></w:r>".to_string()
4503 } else {
4504 runs
4505 };
4506 format!(
4507 "<w:p>{}{}</w:p>",
4508 paragraph_properties_with_mark_xml(paragraph, state),
4509 paragraph_runs
4510 )
4511}
4512
4513fn table_cell_xml_content(
4514 nodes: &[TableCellContentNode],
4515 state: &mut ImageSerializationState<'_>,
4516 run_id_ref: &mut i64,
4517) -> String {
4518 nodes
4519 .iter()
4520 .map(|node| match node {
4521 TableCellContentNode::Paragraph(paragraph) => {
4522 paragraph_xml(paragraph, state, run_id_ref)
4523 }
4524 TableCellContentNode::Table(table) => table_xml(table, state, run_id_ref),
4525 })
4526 .collect()
4527}
4528
4529fn table_layout_str(layout: TableLayout) -> &'static str {
4530 match layout {
4531 TableLayout::Fixed => "fixed",
4532 TableLayout::Autofit => "autofit",
4533 }
4534}
4535
4536fn table_row_height_rule_str(rule: TableRowHeightRule) -> &'static str {
4537 match rule {
4538 TableRowHeightRule::Auto => "auto",
4539 TableRowHeightRule::AtLeast => "atLeast",
4540 TableRowHeightRule::Exact => "exact",
4541 }
4542}
4543
4544fn table_cell_vertical_align_str(align: TableCellVerticalAlign) -> &'static str {
4545 match align {
4546 TableCellVerticalAlign::Top => "top",
4547 TableCellVerticalAlign::Center => "center",
4548 TableCellVerticalAlign::Bottom => "bottom",
4549 }
4550}
4551
4552fn patch_table_source_text(
4553 table: &TableNode,
4554 source_xml: &str,
4555 state: &mut ImageSerializationState<'_>,
4556 run_id_ref: &mut i64,
4557) -> Option<String> {
4558 let patches = table.source_text_patches.as_deref()?;
4559 if patches.is_empty() {
4560 return Some(source_xml.to_string());
4561 }
4562
4563 let mut patched = source_xml.to_string();
4564 for patch in patches {
4565 if patch.source_paragraph_xml.is_empty() {
4566 return None;
4567 }
4568 let mut matches = patched.match_indices(&patch.source_paragraph_xml);
4569 let (start, _) = matches.next()?;
4570 if matches.next().is_some() {
4571 return None;
4572 }
4573 let end = start + patch.source_paragraph_xml.len();
4574 let replacement = paragraph_xml(&patch.paragraph, state, run_id_ref);
4575 patched.replace_range(start..end, &replacement);
4576 }
4577 Some(patched)
4578}
4579
4580const TABLE_PROPERTIES_ORDER: &[&str] = &[
4581 "w:tblStyle",
4582 "w:tblpPr",
4583 "w:tblOverlap",
4584 "w:bidiVisual",
4585 "w:tblStyleRowBandSize",
4586 "w:tblStyleColBandSize",
4587 "w:tblW",
4588 "w:jc",
4589 "w:tblCellSpacing",
4590 "w:tblInd",
4591 "w:tblBorders",
4592 "w:shd",
4593 "w:tblLayout",
4594 "w:tblCellMar",
4595 "w:tblLook",
4596 "w:tblCaption",
4597 "w:tblDescription",
4598 "w:tblPrChange",
4599];
4600const CELL_PROPERTIES_ORDER: &[&str] = &[
4601 "w:cnfStyle",
4602 "w:tcW",
4603 "w:gridSpan",
4604 "w:hMerge",
4605 "w:vMerge",
4606 "w:tcBorders",
4607 "w:shd",
4608 "w:noWrap",
4609 "w:tcMar",
4610 "w:textDirection",
4611 "w:tcFitText",
4612 "w:vAlign",
4613 "w:hideMark",
4614 "w:headers",
4615 "w:cellIns",
4616 "w:cellDel",
4617 "w:cellMerge",
4618 "w:tcPrChange",
4619];
4620
4621fn effective_table_width(
4622 preferred: Option<&TablePreferredWidth>,
4623 legacy: Option<i64>,
4624 source: Option<&TableWidthSource>,
4625) -> Option<TablePreferredWidth> {
4626 if let Some(source) = source {
4627 if preferred != source.preferred_width.as_ref() {
4628 return preferred
4629 .cloned()
4630 .or_else(|| source.inherited_preferred_width.clone());
4631 }
4632 if legacy != source.width_twips {
4633 return legacy
4634 .map(|value| TablePreferredWidth::Dxa {
4635 value: value as f64,
4636 })
4637 .or_else(|| source.inherited_preferred_width.clone());
4638 }
4639 }
4640 preferred.cloned().or_else(|| {
4641 legacy.map(|value| TablePreferredWidth::Dxa {
4642 value: value as f64,
4643 })
4644 })
4645}
4646
4647fn table_width_edit(
4648 preferred: Option<&TablePreferredWidth>,
4649 legacy: Option<i64>,
4650 source: Option<&TableWidthSource>,
4651) -> Option<Option<TablePreferredWidth>> {
4652 if let Some(source) = source {
4653 if preferred != source.preferred_width.as_ref() {
4654 return Some(preferred.cloned());
4655 }
4656 if legacy != source.width_twips {
4657 return Some(legacy.map(|value| TablePreferredWidth::Dxa {
4658 value: value as f64,
4659 }));
4660 }
4661 None
4662 } else {
4663 effective_table_width(preferred, legacy, None).map(Some)
4664 }
4665}
4666
4667fn table_width_attributes(width: &TablePreferredWidth) -> Option<[(&'static str, String); 2]> {
4668 let (kind, value) = match width {
4669 TablePreferredWidth::Dxa { value } if value.is_finite() && *value >= 0.0 => (
4670 "dxa",
4671 if value.fract() == 0.0 {
4672 format!("{value:.0}")
4673 } else {
4674 format!("{}pt", value / 20.0)
4675 },
4676 ),
4677 TablePreferredWidth::Pct { value } if value.is_finite() && *value >= 0.0 => {
4678 let fiftieths = value * 50.0;
4679 (
4680 "pct",
4681 if fiftieths.fract() == 0.0 {
4682 format!("{fiftieths:.0}")
4683 } else {
4684 format!("{value}%")
4685 },
4686 )
4687 }
4688 TablePreferredWidth::Auto => ("auto", "0".to_string()),
4689 TablePreferredWidth::Nil => ("nil", "0".to_string()),
4690 _ => return None,
4691 };
4692 Some([("w:w", value), ("w:type", kind.to_string())])
4693}
4694
4695fn table_width_xml(name: &str, width: Option<&TablePreferredWidth>) -> Option<String> {
4696 let attributes = table_width_attributes(width?)?;
4697 Some(format!(
4698 r#"<{name} w:w="{}" w:type="{}"/>"#,
4699 attributes[0].1, attributes[1].1
4700 ))
4701}
4702
4703fn table_alignment_str(value: TableAlignment) -> &'static str {
4704 match value {
4705 TableAlignment::Left => "left",
4706 TableAlignment::Center => "center",
4707 TableAlignment::Right => "right",
4708 }
4709}
4710
4711fn patch_direct_table_property(
4712 xml: &str,
4713 container: &str,
4714 name: &str,
4715 attributes: Option<&[(&str, String)]>,
4716 order: &[&str],
4717) -> String {
4718 let ranges: Vec<_> = direct_property_children(xml)
4719 .into_iter()
4720 .filter(|(tag, _)| tag == name)
4721 .map(|(_, range)| range)
4722 .collect();
4723 let Some(attributes) = attributes else {
4724 return replace_xml_ranges(
4725 xml,
4726 ranges
4727 .into_iter()
4728 .map(|range| (range.start, range.end, String::new()))
4729 .collect(),
4730 );
4731 };
4732 if let Some(first) = ranges.first() {
4733 let existing = &xml[first.start..first.end];
4734 let end = opening_tag_end(existing, 0).unwrap_or(existing.len());
4735 let mut tag = existing[..end].to_string();
4736 for (attribute, value) in attributes {
4737 tag = set_xml_attribute(&tag, attribute, value);
4738 }
4739 tag.push_str(&existing[end..]);
4740 let mut replacements = vec![(first.start, first.end, tag)];
4741 replacements.extend(
4742 ranges
4743 .into_iter()
4744 .skip(1)
4745 .map(|range| (range.start, range.end, String::new())),
4746 );
4747 return replace_xml_ranges(xml, replacements);
4748 }
4749 let mut child = format!("<{name}/>");
4750 for (attribute, value) in attributes {
4751 child = set_xml_attribute(&child, attribute, value);
4752 }
4753 insert_property_child(xml, container, name, &child, order)
4754}
4755
4756fn patch_table_properties(
4757 xml: &str,
4758 container: &str,
4759 width_name: &str,
4760 width: Option<Option<TablePreferredWidth>>,
4761 alignment: Option<Option<TableAlignment>>,
4762 bidi: Option<Option<bool>>,
4763 order: &[&str],
4764) -> String {
4765 if width.is_none() && alignment.is_none() && bidi.is_none() {
4766 return xml.to_string();
4767 }
4768 let range = direct_property_children(xml)
4769 .into_iter()
4770 .find(|(tag, _)| tag == container)
4771 .map(|(_, range)| range);
4772 let original = range.as_ref().map(|range| &xml[range.start..range.end]);
4773 let mut properties = original
4774 .map(str::to_string)
4775 .unwrap_or_else(|| format!("<{container}/>"));
4776 if let Some(width) = width {
4777 let current = crate::parse::direct_table_property(&properties, width_name)
4778 .and_then(crate::parse::parse_table_preferred_width);
4779 if current != width {
4780 let attributes = width.as_ref().and_then(table_width_attributes);
4781 properties = patch_direct_table_property(
4782 &properties,
4783 container,
4784 width_name,
4785 attributes.as_ref().map(|values| values.as_slice()),
4786 order,
4787 );
4788 }
4789 }
4790 if let Some(alignment) = alignment {
4791 let current = crate::parse::direct_table_property(&properties, "w:jc")
4792 .and_then(|tag| get_attribute(tag, "w:val"));
4793 let desired = alignment.map(|value| table_alignment_str(value).to_string());
4794 if current != desired {
4795 let attributes = desired.map(|value| [("w:val", value)]);
4796 properties = patch_direct_table_property(
4797 &properties,
4798 container,
4799 "w:jc",
4800 attributes.as_ref().map(|values| values.as_slice()),
4801 order,
4802 );
4803 }
4804 }
4805 if let Some(bidi) = bidi {
4806 let current = crate::parse::direct_table_property(&properties, "w:bidiVisual")
4807 .and_then(|tag| crate::xml::parse_on_off_attribute(tag, "bidiVisual"));
4808 if current != bidi {
4809 let attributes =
4810 bidi.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
4811 properties = patch_direct_table_property(
4812 &properties,
4813 container,
4814 "w:bidiVisual",
4815 attributes.as_ref().map(|values| values.as_slice()),
4816 order,
4817 );
4818 }
4819 }
4820 if let Some(range) = range {
4821 let mut patched = xml.to_string();
4822 patched.replace_range(range.start..range.end, &properties);
4823 patched
4824 } else if !direct_property_children(&properties).is_empty() {
4825 let mut patched = xml.to_string();
4826 let end = opening_tag_end(xml, 0).unwrap_or(xml.len());
4827 patched.insert_str(end, &properties);
4828 patched
4829 } else {
4830 xml.to_string()
4831 }
4832}
4833
4834const ROW_PROPERTIES_ORDER: &[&str] = &[
4835 "w:cnfStyle",
4836 "w:divId",
4837 "w:gridBefore",
4838 "w:gridAfter",
4839 "w:wBefore",
4840 "w:wAfter",
4841 "w:cantSplit",
4842 "w:trHeight",
4843 "w:tblHeader",
4844 "w:tblCellSpacing",
4845 "w:jc",
4846 "w:hidden",
4847 "w:ins",
4848 "w:del",
4849 "w:trPrChange",
4850];
4851
4852fn row_has_source_geometry_edits(row: &TableRowNode) -> bool {
4853 let Some(style) = row.style.as_ref() else {
4854 return false;
4855 };
4856 if let Some(source) = style.source_row_geometry.as_ref() {
4857 style.grid_before != source.grid_before
4858 || style.grid_after != source.grid_after
4859 || style.width_before != source.width_before
4860 || style.width_after != source.width_after
4861 } else {
4862 style.grid_before.is_some()
4863 || style.grid_after.is_some()
4864 || style.width_before.is_some()
4865 || style.width_after.is_some()
4866 }
4867}
4868
4869fn patch_row_source_geometry(row: &TableRowNode, xml: &str) -> String {
4870 if !row_has_source_geometry_edits(row) {
4871 return xml.to_string();
4872 }
4873 let style = row.style.as_ref().unwrap();
4874 let source = style.source_row_geometry.as_ref();
4875 let range = direct_property_children(xml)
4876 .into_iter()
4877 .find(|(name, _)| name == "w:trPr")
4878 .map(|(_, range)| range);
4879 let mut properties = range
4880 .as_ref()
4881 .map(|range| xml[range.start..range.end].to_string())
4882 .unwrap_or_else(|| "<w:trPr/>".to_string());
4883 for (name, current, baseline) in [
4884 (
4885 "w:gridBefore",
4886 style.grid_before,
4887 source.and_then(|source| source.grid_before),
4888 ),
4889 (
4890 "w:gridAfter",
4891 style.grid_after,
4892 source.and_then(|source| source.grid_after),
4893 ),
4894 ] {
4895 if (source.is_none() && current.is_none())
4896 || (source.is_some() && current == baseline)
4897 || current.is_some_and(|value| value < 0)
4898 {
4899 continue;
4900 }
4901 let attributes = current.map(|value| [("w:val", value.to_string())]);
4902 properties = patch_direct_table_property(
4903 &properties,
4904 "w:trPr",
4905 name,
4906 attributes.as_ref().map(|values| values.as_slice()),
4907 ROW_PROPERTIES_ORDER,
4908 );
4909 }
4910 for (name, current, baseline) in [
4911 (
4912 "w:wBefore",
4913 style.width_before.as_ref(),
4914 source.and_then(|source| source.width_before.as_ref()),
4915 ),
4916 (
4917 "w:wAfter",
4918 style.width_after.as_ref(),
4919 source.and_then(|source| source.width_after.as_ref()),
4920 ),
4921 ] {
4922 if (source.is_none() && current.is_none()) || (source.is_some() && current == baseline) {
4923 continue;
4924 }
4925 let actual = crate::parse::direct_table_property(&properties, name)
4926 .and_then(crate::parse::parse_table_preferred_width);
4927 if actual.as_ref() == current {
4928 continue;
4929 }
4930 let attributes = current.and_then(table_width_attributes);
4931 properties = patch_direct_table_property(
4932 &properties,
4933 "w:trPr",
4934 name,
4935 attributes.as_ref().map(|values| values.as_slice()),
4936 ROW_PROPERTIES_ORDER,
4937 );
4938 }
4939 let mut updated = xml.to_string();
4940 if let Some(range) = range {
4941 updated.replace_range(range.start..range.end, &properties);
4942 } else if !direct_property_children(&properties).is_empty() {
4943 let end = direct_property_children(xml)
4944 .into_iter()
4945 .find(|(name, _)| name == "w:tblPrEx")
4946 .map(|(_, range)| range.end)
4947 .unwrap_or_else(|| opening_tag_end(xml, 0).unwrap_or(0));
4948 updated.insert_str(end, &properties);
4949 }
4950 updated
4951}
4952
4953fn row_has_source_property_edits(row: &TableRowNode) -> bool {
4954 row_has_source_geometry_edits(row) || row.cells.iter().any(cell_has_source_property_edits)
4955}
4956
4957fn paragraph_has_source_property_edits(paragraph: &ParagraphNode) -> bool {
4958 paragraph.paragraph_mark_style != paragraph.source_paragraph_mark_style
4959 || paragraph.style.as_ref().is_some_and(|style| {
4960 style.text_alignment != style.source_text_alignment
4961 || style.snap_to_grid != style.source_snap_to_grid
4962 || style.spacing.as_ref().and_then(|s| s.before_auto_spacing)
4963 != style.source_before_auto_spacing
4964 || style.spacing.as_ref().and_then(|s| s.after_auto_spacing)
4965 != style.source_after_auto_spacing
4966 })
4967}
4968
4969fn cell_has_source_property_edits(cell: &TableCellNode) -> bool {
4970 cell.style.as_ref().is_some_and(|style| {
4971 style.text_direction != style.source_text_direction
4972 || style.no_wrap != style.source_no_wrap
4973 || table_width_edit(
4974 style.preferred_width.as_ref(),
4975 style.width_twips,
4976 style.source_width.as_ref(),
4977 )
4978 .is_some()
4979 }) || cell.nodes.iter().any(|node| match node {
4980 TableCellContentNode::Paragraph(paragraph) => {
4981 paragraph_has_source_property_edits(paragraph)
4982 }
4983 TableCellContentNode::Table(table) => table_has_source_property_edits(table),
4984 })
4985}
4986
4987fn table_has_source_property_edits(table: &TableNode) -> bool {
4988 table.style.as_ref().is_some_and(|style| {
4989 table_width_edit(
4990 style.preferred_width.as_ref(),
4991 style.width_twips,
4992 style.source_width.as_ref(),
4993 )
4994 .is_some()
4995 || style.style_id != style.source_style_id
4996 || style.alignment != style.source_alignment
4997 || style.bidi_visual != style.source_bidi_visual
4998 }) || table.rows.iter().any(row_has_source_property_edits)
4999}
5000
5001fn patch_table_source_properties(
5002 table: &TableNode,
5003 source_xml: &str,
5004 state: &mut ImageSerializationState<'_>,
5005) -> Option<String> {
5006 let previous = state.current_table_text_alignment;
5007 let previous_snap = state.current_table_snap_to_grid;
5008 let result = patch_table_source_properties_inner(table, source_xml, state);
5009 state.current_table_text_alignment = previous;
5010 state.current_table_snap_to_grid = previous_snap;
5011 result
5012}
5013
5014fn patch_table_source_properties_inner(
5015 table: &TableNode,
5016 source_xml: &str,
5017 state: &mut ImageSerializationState<'_>,
5018) -> Option<String> {
5019 let identity_changed = table
5020 .style
5021 .as_ref()
5022 .is_some_and(|style| style.style_id != style.source_style_id);
5023 if !table_has_source_property_edits(table)
5024 && !table_alignment_context_changed(table, state, Some(source_xml))
5025 {
5026 return Some(source_xml.to_string());
5027 }
5028 let width = table.style.as_ref().and_then(|style| {
5029 table_width_edit(
5030 style.preferred_width.as_ref(),
5031 style.width_twips,
5032 style.source_width.as_ref(),
5033 )
5034 });
5035 let alignment = table
5036 .style
5037 .as_ref()
5038 .filter(|style| style.alignment != style.source_alignment)
5039 .map(|style| style.alignment);
5040 let bidi = table
5041 .style
5042 .as_ref()
5043 .filter(|style| style.bidi_visual != style.source_bidi_visual)
5044 .map(|style| style.bidi_visual);
5045 let mut source = patch_table_properties(
5046 source_xml,
5047 "w:tblPr",
5048 "w:tblW",
5049 width,
5050 alignment,
5051 bidi,
5052 TABLE_PROPERTIES_ORDER,
5053 );
5054 if let Some(style) = table
5055 .style
5056 .as_ref()
5057 .filter(|style| style.style_id != style.source_style_id)
5058 {
5059 let range = direct_property_children(&source)
5060 .into_iter()
5061 .find(|(name, _)| name == "w:tblPr")
5062 .map(|(_, range)| range);
5063 let properties = range
5064 .as_ref()
5065 .map(|range| &source[range.start..range.end])
5066 .unwrap_or("<w:tblPr/>");
5067 let attributes = style
5068 .style_id
5069 .as_ref()
5070 .map(|value| [("w:val", value.clone())]);
5071 let properties = patch_direct_table_property(
5072 properties,
5073 "w:tblPr",
5074 "w:tblStyle",
5075 attributes.as_ref().map(|values| values.as_slice()),
5076 TABLE_PROPERTIES_ORDER,
5077 );
5078 if let Some(range) = range {
5079 source.replace_range(range.start..range.end, &properties);
5080 } else if style.style_id.is_some() {
5081 let end = opening_tag_end(&source, 0).unwrap_or(0);
5082 source.insert_str(end, &properties);
5083 }
5084 }
5085 if !identity_changed
5086 && !table.rows.iter().any(row_has_source_property_edits)
5087 && !table_alignment_context_changed(table, state, Some(&source))
5088 {
5089 return Some(source);
5090 }
5091 let context = resolve_table_alignment_context(table, state, Some(&source));
5092 let rows = extract_balanced_tag_ranges(&source, "w:tr");
5093 if rows.len() != table.rows.len() {
5094 return None;
5095 }
5096 let mut row_replacements = Vec::new();
5097 for (row_index, (range, row)) in rows.into_iter().zip(&table.rows).enumerate() {
5098 let patched_row = patch_row_source_geometry(row, &source[range.start..range.end]);
5099 let row_xml = patched_row.as_str();
5100 let cells = extract_balanced_tag_ranges(row_xml, "w:tc");
5101 if cells.len() != row.cells.len() {
5102 return None;
5103 }
5104 let mut cell_replacements = Vec::new();
5105 let bound = context.grid_bound;
5106 let mut column = crate::parse::resolve_table_grid_skip_count(
5107 row.style.as_ref().and_then(|style| style.grid_before),
5108 bound,
5109 );
5110 for (cell_range, cell) in cells.into_iter().zip(&row.cells) {
5111 let span = cell
5112 .style
5113 .as_ref()
5114 .and_then(|style| style.grid_span)
5115 .unwrap_or(1)
5116 .max(1);
5117 state.current_table_text_alignment =
5118 table_cell_text_alignment(&context, row_index, column, span, state);
5119 state.current_table_snap_to_grid =
5120 table_cell_snap_to_grid(&context, row_index, column, span, state);
5121 column += span;
5122 let cell_xml = &row_xml[cell_range.start..cell_range.end];
5123 let width = cell.style.as_ref().and_then(|style| {
5124 table_width_edit(
5125 style.preferred_width.as_ref(),
5126 style.width_twips,
5127 style.source_width.as_ref(),
5128 )
5129 });
5130 let mut patched_cell = patch_table_properties(
5131 cell_xml,
5132 "w:tcPr",
5133 "w:tcW",
5134 width,
5135 None,
5136 None,
5137 CELL_PROPERTIES_ORDER,
5138 );
5139 if let Some(style) = cell
5140 .style
5141 .as_ref()
5142 .filter(|style| style.text_direction != style.source_text_direction)
5143 {
5144 let range = direct_property_children(&patched_cell)
5145 .into_iter()
5146 .find(|(name, _)| name == "w:tcPr")
5147 .map(|(_, range)| range);
5148 let properties = range
5149 .as_ref()
5150 .map(|range| &patched_cell[range.start..range.end])
5151 .unwrap_or("<w:tcPr/>");
5152 let attributes = style
5153 .text_direction
5154 .as_ref()
5155 .map(|value| [("w:val", value.clone())]);
5156 let properties = patch_direct_table_property(
5157 properties,
5158 "w:tcPr",
5159 "w:textDirection",
5160 attributes.as_ref().map(|values| values.as_slice()),
5161 CELL_PROPERTIES_ORDER,
5162 );
5163 if let Some(range) = range {
5164 patched_cell.replace_range(range.start..range.end, &properties);
5165 } else if style.text_direction.is_some() {
5166 let end = opening_tag_end(&patched_cell, 0).unwrap_or(0);
5167 patched_cell.insert_str(end, &properties);
5168 }
5169 }
5170 if let Some(style) = cell
5171 .style
5172 .as_ref()
5173 .filter(|style| style.no_wrap != style.source_no_wrap)
5174 {
5175 let range = direct_property_children(&patched_cell)
5176 .into_iter()
5177 .find(|(name, _)| name == "w:tcPr")
5178 .map(|(_, range)| range);
5179 let properties = range
5180 .as_ref()
5181 .map(|range| &patched_cell[range.start..range.end])
5182 .unwrap_or("<w:tcPr/>");
5183 let attributes = style
5184 .no_wrap
5185 .map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
5186 let properties = patch_direct_table_property(
5187 properties,
5188 "w:tcPr",
5189 "w:noWrap",
5190 attributes.as_ref().map(|values| values.as_slice()),
5191 CELL_PROPERTIES_ORDER,
5192 );
5193 if let Some(range) = range {
5194 patched_cell.replace_range(range.start..range.end, &properties);
5195 } else if style.no_wrap.is_some() {
5196 let end = opening_tag_end(&patched_cell, 0).unwrap_or(0);
5197 patched_cell.insert_str(end, &properties);
5198 }
5199 }
5200 if identity_changed
5201 || cell_alignment_context_changed(cell, state)
5202 || cell.nodes.iter().any(|node| match node {
5203 TableCellContentNode::Paragraph(paragraph) => {
5204 paragraph_has_source_property_edits(paragraph)
5205 }
5206 TableCellContentNode::Table(table) => table_has_source_property_edits(table),
5207 })
5208 {
5209 let blocks = crate::xml::extract_balanced_tag_blocks_in_order(
5210 &patched_cell,
5211 &["w:p", "w:tbl"],
5212 );
5213 if blocks.len() != cell.nodes.len() {
5214 return None;
5215 }
5216 let mut replacements = Vec::new();
5217 for (range, node) in blocks.into_iter().zip(&cell.nodes) {
5218 let source = &patched_cell[range.start..range.end];
5219 let replacement = match node {
5220 TableCellContentNode::Paragraph(paragraph)
5221 if range.tag_name.eq_ignore_ascii_case("w:p") =>
5222 {
5223 patch_source_paragraph_properties(paragraph, source, state)
5224 }
5225 TableCellContentNode::Table(table)
5226 if range.tag_name.eq_ignore_ascii_case("w:tbl") =>
5227 {
5228 patch_table_source_properties(table, source, state)?
5229 }
5230 _ => return None,
5231 };
5232 replacements.push((range.start, range.end, replacement));
5233 }
5234 patched_cell = replace_xml_ranges(&patched_cell, replacements);
5235 }
5236 cell_replacements.push((cell_range.start, cell_range.end, patched_cell));
5237 }
5238 row_replacements.push((
5239 range.start,
5240 range.end,
5241 replace_xml_ranges(row_xml, cell_replacements),
5242 ));
5243 }
5244 Some(replace_xml_ranges(&source, row_replacements))
5245}
5246
5247fn table_xml(
5248 table: &TableNode,
5249 state: &mut ImageSerializationState<'_>,
5250 run_id_ref: &mut i64,
5251) -> String {
5252 if !table_has_stale_form_field_preview(table) {
5253 if let Some(source_xml) = &table.source_xml {
5254 let source = if table
5255 .source_text_patches
5256 .as_ref()
5257 .is_some_and(|patches| !patches.is_empty())
5258 {
5259 patch_table_source_text(table, source_xml, state, run_id_ref)
5260 } else {
5261 Some(source_xml.clone())
5262 };
5263 if let Some(patched) =
5264 source.and_then(|source| patch_table_source_properties(table, &source, state))
5265 {
5266 return patched;
5267 }
5268 }
5269 }
5270
5271 let physical_table = match crate::table_grid::materialize_collapsed_table_grid(table) {
5272 Ok(table) => table,
5273 Err(error) => {
5274 state
5275 .pkg
5276 .warnings
5277 .push(format!("Invalid collapsed table grid: {error}"));
5278 None
5279 }
5280 };
5281 let table = physical_table.as_ref().unwrap_or(table);
5282
5283 let mut table_props: Vec<String> = Vec::new();
5284 if let Some(style_id) = table
5285 .style
5286 .as_ref()
5287 .and_then(|style| style.style_id.as_ref())
5288 {
5289 table_props.push(format!(r#"<w:tblStyle w:val="{}"/>"#, escape_xml(style_id)));
5290 }
5291
5292 if let Some(floating) = table.style.as_ref().and_then(|s| s.floating.as_ref()) {
5294 let mut attrs = String::new();
5295 if let Some(value) = floating.left_from_text_twips {
5296 attrs.push_str(&format!(r#" w:leftFromText="{value}""#));
5297 }
5298 if let Some(value) = floating.right_from_text_twips {
5299 attrs.push_str(&format!(r#" w:rightFromText="{value}""#));
5300 }
5301 if let Some(value) = floating.top_from_text_twips {
5302 attrs.push_str(&format!(r#" w:topFromText="{value}""#));
5303 }
5304 if let Some(value) = floating.bottom_from_text_twips {
5305 attrs.push_str(&format!(r#" w:bottomFromText="{value}""#));
5306 }
5307 if let Some(value) = floating.vertical_anchor.as_ref() {
5308 attrs.push_str(&format!(r#" w:vertAnchor="{}""#, escape_xml(value)));
5309 }
5310 if let Some(value) = floating.horizontal_anchor.as_ref() {
5311 attrs.push_str(&format!(r#" w:horzAnchor="{}""#, escape_xml(value)));
5312 }
5313 if let Some(align) = floating.horizontal_align {
5314 attrs.push_str(&format!(
5315 r#" w:tblpXSpec="{}""#,
5316 image_horizontal_align_str(align)
5317 ));
5318 }
5319 if let Some(value) = floating.x_twips {
5320 attrs.push_str(&format!(r#" w:tblpX="{value}""#));
5321 }
5322 if let Some(align) = floating.vertical_align {
5323 attrs.push_str(&format!(
5324 r#" w:tblpYSpec="{}""#,
5325 image_vertical_align_str(align)
5326 ));
5327 }
5328 if let Some(value) = floating.y_twips {
5329 attrs.push_str(&format!(r#" w:tblpY="{value}""#));
5330 }
5331 if !attrs.is_empty() {
5332 table_props.push(format!("<w:tblpPr{attrs}/>"));
5333 }
5334 }
5335 let preferred_width = table.style.as_ref().and_then(|style| {
5336 effective_table_width(
5337 style.preferred_width.as_ref(),
5338 style.width_twips,
5339 style.source_width.as_ref(),
5340 )
5341 });
5342 if let Some(xml) = table_width_xml("w:tblW", preferred_width.as_ref()) {
5343 table_props.push(xml);
5344 }
5345 if let Some(alignment) = table
5346 .style
5347 .as_ref()
5348 .and_then(|style| style.alignment.or(style.source_inherited_alignment))
5349 {
5350 table_props.push(format!(
5351 r#"<w:jc w:val="{}"/>"#,
5352 table_alignment_str(alignment)
5353 ));
5354 }
5355 if let Some(bidi_visual) = table
5356 .style
5357 .as_ref()
5358 .and_then(|style| style.bidi_visual.or(style.source_inherited_bidi_visual))
5359 {
5360 table_props.push(format!(
5361 r#"<w:bidiVisual w:val="{}"/>"#,
5362 if bidi_visual { "1" } else { "0" }
5363 ));
5364 }
5365
5366 if let Some(table_indent_twips) =
5367 twips_to_xml(table.style.as_ref().and_then(|s| s.indent_twips))
5368 {
5369 table_props.push(format!(
5370 r#"<w:tblInd w:w="{table_indent_twips}" w:type="dxa"/>"#
5371 ));
5372 }
5373
5374 if let Some(layout) = table.style.as_ref().and_then(|s| s.layout) {
5375 table_props.push(format!(
5376 r#"<w:tblLayout w:type="{}"/>"#,
5377 table_layout_str(layout)
5378 ));
5379 }
5380
5381 if let Some(table_cell_spacing_twips) =
5382 twips_to_xml_non_negative(table.style.as_ref().and_then(|s| s.cell_spacing_twips))
5383 {
5384 table_props.push(format!(
5385 r#"<w:tblCellSpacing w:w="{table_cell_spacing_twips}" w:type="dxa"/>"#
5386 ));
5387 }
5388
5389 let table_cell_margin_xml = table_box_spacing_xml(
5390 table
5391 .style
5392 .as_ref()
5393 .and_then(|s| s.cell_margin_twips.as_ref()),
5394 "w:tblCellMar",
5395 );
5396 if !table_cell_margin_xml.is_empty() {
5397 table_props.push(table_cell_margin_xml);
5398 }
5399 let table_border_xml = table_borders_xml(
5400 table.style.as_ref().and_then(|s| s.borders.as_ref()),
5401 "w:tblBorders",
5402 );
5403 if !table_border_xml.is_empty() {
5404 table_props.push(table_border_xml);
5405 }
5406
5407 table_props.sort_by_key(|xml| {
5408 let name = xml[1..]
5409 .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
5410 .next()
5411 .unwrap_or_default();
5412 TABLE_PROPERTIES_ORDER
5413 .iter()
5414 .position(|value| *value == name)
5415 .unwrap_or(usize::MAX)
5416 });
5417
5418 let table_grid_xml = table
5419 .style
5420 .as_ref()
5421 .and_then(|s| s.column_widths_twips.as_ref())
5422 .map(|widths| {
5423 let cols = widths
5424 .iter()
5425 .filter_map(|width| twips_to_xml_non_negative(Some(*width)))
5426 .map(|width| format!(r#"<w:gridCol w:w="{width}"/>"#))
5427 .collect::<String>();
5428 format!("<w:tblGrid>{cols}</w:tblGrid>")
5429 })
5430 .unwrap_or_default();
5431
5432 let context = resolve_table_alignment_context(table, state, None);
5433 let previous_table_alignment = state.current_table_text_alignment;
5434 let previous_table_snap = state.current_table_snap_to_grid;
5435 let rows = table
5436 .rows
5437 .iter()
5438 .enumerate()
5439 .map(|(row_index, row)| {
5440 let mut row_props: Vec<String> = Vec::new();
5441 for (name, count) in [
5442 (
5443 "w:gridBefore",
5444 row.style.as_ref().and_then(|style| style.grid_before),
5445 ),
5446 (
5447 "w:gridAfter",
5448 row.style.as_ref().and_then(|style| style.grid_after),
5449 ),
5450 ] {
5451 if let Some(count) = count.filter(|count| *count >= 0) {
5452 row_props.push(format!(r#"<{name} w:val="{count}"/>"#));
5453 }
5454 }
5455 for (name, width) in [
5456 (
5457 "w:wBefore",
5458 row.style
5459 .as_ref()
5460 .and_then(|style| style.width_before.as_ref()),
5461 ),
5462 (
5463 "w:wAfter",
5464 row.style
5465 .as_ref()
5466 .and_then(|style| style.width_after.as_ref()),
5467 ),
5468 ] {
5469 if let Some(xml) = table_width_xml(name, width) {
5470 row_props.push(xml);
5471 }
5472 }
5473
5474 if let Some(background_color) =
5475 row.style.as_ref().and_then(|s| s.background_color.as_ref())
5476 {
5477 let fill = background_color.replace('#', "");
5478 row_props.push(format!(
5479 r#"<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>"#
5480 ));
5481 }
5482 if let Some(row_height_twips) =
5483 twips_to_xml(row.style.as_ref().and_then(|s| s.height_twips))
5484 {
5485 let h_rule = row
5486 .style
5487 .as_ref()
5488 .and_then(|s| s.height_rule)
5489 .filter(|rule| {
5490 matches!(
5491 rule,
5492 TableRowHeightRule::Exact
5493 | TableRowHeightRule::AtLeast
5494 | TableRowHeightRule::Auto
5495 )
5496 })
5497 .map(|rule| format!(r#" w:hRule="{}""#, table_row_height_rule_str(rule)))
5498 .unwrap_or_default();
5499 row_props.push(format!(
5500 r#"<w:trHeight w:val="{row_height_twips}"{h_rule}/>"#
5501 ));
5502 }
5503 if row.style.as_ref().and_then(|s| s.cant_split) == Some(true) {
5504 row_props.push("<w:cantSplit/>".to_string());
5505 }
5506 if let Some(is_header) = row.style.as_ref().and_then(|s| s.is_header) {
5507 row_props.push(if is_header {
5508 "<w:tblHeader/>".to_string()
5509 } else {
5510 r#"<w:tblHeader w:val="0"/>"#.to_string()
5511 });
5512 }
5513
5514 row_props.sort_by_key(|xml| {
5515 let name = xml[1..]
5516 .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
5517 .next()
5518 .unwrap_or_default();
5519 ROW_PROPERTIES_ORDER
5520 .iter()
5521 .position(|value| *value == name)
5522 .unwrap_or(usize::MAX)
5523 });
5524
5525 let bound = context.grid_bound;
5526 let mut column = crate::parse::resolve_table_grid_skip_count(
5527 row.style.as_ref().and_then(|style| style.grid_before),
5528 bound,
5529 );
5530 let cells = row
5531 .cells
5532 .iter()
5533 .map(|cell| {
5534 let span = cell
5535 .style
5536 .as_ref()
5537 .and_then(|style| style.grid_span)
5538 .unwrap_or(1)
5539 .max(1);
5540 state.current_table_text_alignment =
5541 table_cell_text_alignment(&context, row_index, column, span, state);
5542 state.current_table_snap_to_grid =
5543 table_cell_snap_to_grid(&context, row_index, column, span, state);
5544 column += span;
5545 let serialized_cells = table_cell_xml_content(&cell.nodes, state, run_id_ref);
5546
5547 let mut cell_props: Vec<String> = Vec::new();
5548 let preferred_width = cell.style.as_ref().and_then(|style| {
5549 effective_table_width(
5550 style.preferred_width.as_ref(),
5551 style.width_twips,
5552 style.source_width.as_ref(),
5553 )
5554 });
5555 if let Some(xml) = table_width_xml("w:tcW", preferred_width.as_ref()) {
5556 cell_props.push(xml);
5557 }
5558 if let Some(background_color) = cell
5559 .style
5560 .as_ref()
5561 .and_then(|s| s.background_color.as_ref())
5562 {
5563 let fill = background_color.replace('#', "");
5564 cell_props.push(format!(
5565 r#"<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>"#
5566 ));
5567 }
5568 if let Some(grid_span) = cell.style.as_ref().and_then(|s| s.grid_span) {
5569 if grid_span > 1 {
5570 cell_props.push(format!(r#"<w:gridSpan w:val="{grid_span}"/>"#));
5571 }
5572 }
5573 if cell.style.as_ref().and_then(|s| s.v_merge_continuation) == Some(true) {
5574 cell_props.push("<w:vMerge/>".to_string());
5575 } else if cell.style.as_ref().and_then(|s| s.row_span).is_some() {
5576 cell_props.push(r#"<w:vMerge w:val="restart"/>"#.to_string());
5577 }
5578 let cell_margin_xml = table_box_spacing_xml(
5579 cell.style.as_ref().and_then(|s| s.margin_twips.as_ref()),
5580 "w:tcMar",
5581 );
5582 if !cell_margin_xml.is_empty() {
5583 cell_props.push(cell_margin_xml);
5584 }
5585 let cell_border_xml = table_borders_xml(
5586 cell.style.as_ref().and_then(|s| s.borders.as_ref()),
5587 "w:tcBorders",
5588 );
5589 if !cell_border_xml.is_empty() {
5590 cell_props.push(cell_border_xml);
5591 }
5592 if let Some(direction) = cell
5593 .style
5594 .as_ref()
5595 .and_then(|s| s.text_direction.as_deref())
5596 {
5597 cell_props.push(format!(
5598 r#"<w:textDirection w:val="{}"/>"#,
5599 escape_xml(direction)
5600 ));
5601 }
5602 if let Some(no_wrap) = cell.style.as_ref().and_then(|s| s.no_wrap) {
5603 cell_props.push(format!(
5604 r#"<w:noWrap w:val="{}"/>"#,
5605 if no_wrap { "1" } else { "0" }
5606 ));
5607 }
5608 if let Some(vertical_align) = cell.style.as_ref().and_then(|s| s.vertical_align)
5609 {
5610 if matches!(
5611 vertical_align,
5612 TableCellVerticalAlign::Top
5613 | TableCellVerticalAlign::Center
5614 | TableCellVerticalAlign::Bottom
5615 ) {
5616 cell_props.push(format!(
5617 r#"<w:vAlign w:val="{}"/>"#,
5618 table_cell_vertical_align_str(vertical_align)
5619 ));
5620 }
5621 }
5622
5623 let tc_pr = if cell_props.is_empty() {
5624 String::new()
5625 } else {
5626 format!("<w:tcPr>{}</w:tcPr>", cell_props.join(""))
5627 };
5628 format!(
5629 "<w:tc>{}{}</w:tc>",
5630 tc_pr,
5631 if serialized_cells.is_empty() {
5632 "<w:p><w:r><w:t/></w:r></w:p>"
5633 } else {
5634 &serialized_cells
5635 }
5636 )
5637 })
5638 .collect::<String>();
5639
5640 let tr_pr = if row_props.is_empty() {
5641 String::new()
5642 } else {
5643 format!("<w:trPr>{}</w:trPr>", row_props.join(""))
5644 };
5645 format!("<w:tr>{tr_pr}{cells}</w:tr>")
5646 })
5647 .collect::<String>();
5648
5649 state.current_table_text_alignment = previous_table_alignment;
5650 state.current_table_snap_to_grid = previous_table_snap;
5651 format!(
5652 "<w:tbl><w:tblPr>{}</w:tblPr>{table_grid_xml}{rows}</w:tbl>",
5653 table_props.join("")
5654 )
5655}
5656
5657const EMPTY_RELATIONSHIPS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
5658
5659fn create_image_serialization_state<'a>(
5660 base_package: &'a mut OoxmlPackage,
5661 owner_part_name: &str,
5662) -> ImageSerializationState<'a> {
5663 let relationship_part_name = relationship_part_name_for_word_part(owner_part_name);
5664 let relationships_xml = base_package
5665 .parts
5666 .get(&relationship_part_name)
5667 .map(|part| part.content.as_str())
5668 .unwrap_or(EMPTY_RELATIONSHIPS_XML);
5669
5670 let relationships = parse_relationships_xml(relationships_xml);
5671 let relationship_by_target = relationships
5672 .iter()
5673 .map(|relationship| (relationship.target.clone(), relationship.clone()))
5674 .collect::<HashMap<_, _>>();
5675
5676 let used_image_numbers = base_package
5677 .binary_assets
5678 .keys()
5679 .filter_map(|part_name| {
5680 let lower = part_name.to_ascii_lowercase();
5681 let prefix = "word/media/image";
5682 if !lower.starts_with(prefix) {
5683 return None;
5684 }
5685 let rest = &lower[prefix.len()..];
5686 let dot = rest.find('.')?;
5687 rest[..dot].parse::<i64>().ok()
5688 })
5689 .collect::<Vec<_>>();
5690
5691 let next_image_index = used_image_numbers.iter().copied().max().unwrap_or(0).max(0) + 1;
5692
5693 let styles = crate::parse::parse_style_sheet(base_package);
5694 let paragraph_style_grid_owners = styles
5695 .paragraph_style_by_id
5696 .iter()
5697 .filter(|(_, style)| style.source_has_snap_to_grid == Some(true))
5698 .filter_map(|(id, style)| style.snap_to_grid.map(|value| (id.clone(), value)))
5699 .collect();
5700 let paragraph_snap_to_grid_by_style_id = styles
5701 .paragraph_style_by_id
5702 .iter()
5703 .filter_map(|(id, style)| style.snap_to_grid.map(|value| (id.clone(), value)))
5704 .collect();
5705 let default_paragraph_snap_to_grid = styles
5706 .default_paragraph_style
5707 .as_ref()
5708 .and_then(|style| style.snap_to_grid);
5709 let paragraph_style_alignment_owners = styles
5710 .paragraph_style_by_id
5711 .iter()
5712 .filter(|(_, style)| style.source_has_text_alignment == Some(true))
5713 .filter_map(|(id, style)| style.text_alignment.map(|value| (id.clone(), value)))
5714 .collect();
5715 let table_styles = styles.table_style_by_id;
5716 let paragraph_text_alignment_by_style_id = styles
5717 .paragraph_style_by_id
5718 .into_iter()
5719 .filter_map(|(id, style)| style.text_alignment.map(|value| (id, value)))
5720 .collect();
5721 let default_paragraph_text_alignment = styles
5722 .default_paragraph_style
5723 .as_ref()
5724 .and_then(|style| style.text_alignment)
5725 .or(styles.default_paragraph_text_alignment);
5726 ImageSerializationState {
5727 owner_part_name: owner_part_name.to_string(),
5728 paragraph_style_grid_owners,
5729 paragraph_snap_to_grid_by_style_id,
5730 default_paragraph_snap_to_grid,
5731 current_table_snap_to_grid: None,
5732 paragraph_style_alignment_owners,
5733 table_styles,
5734 current_table_text_alignment: None,
5735 paragraph_text_alignment_by_style_id,
5736 default_paragraph_text_alignment,
5737 next_image_index: next_image_index.max(1),
5738 next_relationship_index: next_relationship_index(&relationships),
5739 relationships,
5740 relationship_by_target,
5741 pkg: base_package,
5742 theme_fonts: OnceCell::new(),
5743 }
5744}
5745
5746fn has_xmlns_prefix(open_tag: &str, prefix: &str) -> bool {
5747 let patterns = [format!(" xmlns:{prefix}=\""), format!(" xmlns:{prefix}='")];
5748 patterns.iter().any(|pattern| {
5749 open_tag
5750 .as_bytes()
5751 .windows(pattern.len())
5752 .any(|window| window.eq_ignore_ascii_case(pattern.as_bytes()))
5753 })
5754}
5755
5756fn ensure_namespace(open_tag: &str, prefix: &str, namespace: &str) -> String {
5757 if has_xmlns_prefix(open_tag, prefix) {
5758 return open_tag.to_string();
5759 }
5760
5761 if open_tag.ends_with('>') {
5762 format!(
5763 "{} xmlns:{prefix}=\"{namespace}\">",
5764 &open_tag[..open_tag.len() - 1]
5765 )
5766 } else {
5767 open_tag.to_string()
5768 }
5769}
5770
5771fn ensure_ignorable_prefix(open_tag: &str, prefix: &str) -> String {
5772 let lower = open_tag.to_ascii_lowercase();
5773 let marker = " mc:ignorable=";
5774 if let Some(start) = lower.find(marker) {
5775 let quote_byte = open_tag.as_bytes()[start + marker.len()];
5776 let quote = quote_byte as char;
5777 let value_start = start + marker.len() + 1;
5778 let value_end = open_tag[value_start..]
5779 .find(quote)
5780 .map(|index| value_start + index)
5781 .unwrap_or(open_tag.len());
5782 let current_value = &open_tag[value_start..value_end];
5783 let tokens: Vec<&str> = current_value
5784 .split_whitespace()
5785 .map(str::trim)
5786 .filter(|token| !token.is_empty())
5787 .collect();
5788 if tokens.iter().any(|token| *token == prefix) {
5789 return open_tag.to_string();
5790 }
5791 let updated_value = if tokens.is_empty() {
5792 prefix.to_string()
5793 } else {
5794 format!("{} {}", tokens.join(" "), prefix)
5795 };
5796 return format!(
5797 "{} mc:Ignorable={}{}{}{}",
5798 &open_tag[..start],
5799 quote,
5800 updated_value,
5801 quote,
5802 &open_tag[value_end + 1..]
5803 );
5804 }
5805
5806 if open_tag.ends_with('>') {
5807 format!(
5808 "{} mc:Ignorable=\"{prefix}\">",
5809 &open_tag[..open_tag.len() - 1]
5810 )
5811 } else {
5812 open_tag.to_string()
5813 }
5814}
5815
5816fn ensure_word_section_part_open_tag(
5817 existing_part_content: Option<&str>,
5818 root_tag_name: &str,
5819) -> String {
5820 let existing_open_tag = existing_part_content.and_then(|content| {
5821 let pattern = format!("<{root_tag_name}");
5822 let start = content.find(&pattern)?;
5823 let end = content[start..].find('>')?;
5824 Some(content[start..start + end + 1].to_string())
5825 });
5826
5827 let mut open_tag = existing_open_tag.unwrap_or_else(|| {
5828 format!(r#"<{root_tag_name} xmlns:w="{WORD_MAIN_NS}" xmlns:r="{OFFICE_REL_NS}">"#)
5829 });
5830
5831 open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
5832 open_tag = ensure_namespace(&open_tag, "r", OFFICE_REL_NS);
5833 open_tag = ensure_namespace(&open_tag, "a", DRAWING_MAIN_NS);
5834 open_tag = ensure_namespace(&open_tag, "wp", DRAWING_WORD_NS);
5835 open_tag = ensure_namespace(&open_tag, "pic", DRAWING_PICTURE_NS);
5836 open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
5837 open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
5838 open_tag = ensure_ignorable_prefix(&open_tag, "w14");
5839 open_tag
5840}
5841
5842fn serialize_section_part_xml(
5843 section_nodes: &[DocNode],
5844 root_tag_name: &str,
5845 part_name: &str,
5846 pkg: &mut OoxmlPackage,
5847) -> (String, Vec<Relationship>) {
5848 let existing_content = pkg.parts.get(part_name).map(|part| part.content.clone());
5849 let (body_xml, relationships) = {
5850 let mut state = create_image_serialization_state(pkg, part_name);
5851 let mut run_id_ref = 1i64;
5852 let body_xml = section_nodes
5853 .iter()
5854 .map(|node| match node {
5855 DocNode::Paragraph(paragraph) => {
5856 paragraph_xml(paragraph, &mut state, &mut run_id_ref)
5857 }
5858 DocNode::Table(table) => table_xml(table, &mut state, &mut run_id_ref),
5859 })
5860 .collect::<String>();
5861 (body_xml, state.relationships)
5862 };
5863 let open_tag = ensure_word_section_part_open_tag(existing_content.as_deref(), root_tag_name);
5864 let xml = format!(
5865 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>{open_tag}{body_xml}</{root_tag_name}>"#
5866 );
5867 (xml, relationships)
5868}
5869
5870fn section_root_tag_name_for_part_name(part_name: &str) -> &'static str {
5871 if part_name.to_ascii_lowercase().contains("header") {
5872 "w:hdr"
5873 } else {
5874 "w:ftr"
5875 }
5876}
5877
5878fn serialize_header_footer_parts(model: &DocModel, pkg: &mut OoxmlPackage) {
5879 let mut sections_by_part_name: HashMap<String, Vec<DocNode>> = HashMap::new();
5880
5881 let mut add_part_sections = |sections: &[(String, Vec<DocNode>)]| {
5882 for (part_name, nodes) in sections {
5883 let normalized_part_name = normalize_word_part_name(part_name);
5884 if normalized_part_name.is_empty()
5885 || sections_by_part_name.contains_key(&normalized_part_name)
5886 {
5887 continue;
5888 }
5889 sections_by_part_name.insert(normalized_part_name, nodes.clone());
5890 }
5891 };
5892
5893 add_part_sections(
5894 &model
5895 .metadata
5896 .header_sections
5897 .iter()
5898 .map(|section| (section.part_name.clone(), section.nodes.clone()))
5899 .collect::<Vec<_>>(),
5900 );
5901 add_part_sections(
5902 &model
5903 .metadata
5904 .footer_sections
5905 .iter()
5906 .map(|section| (section.part_name.clone(), section.nodes.clone()))
5907 .collect::<Vec<_>>(),
5908 );
5909 if let Some(sections) = &model.metadata.sections {
5910 for section in sections {
5911 add_part_sections(
5912 §ion
5913 .header_sections
5914 .iter()
5915 .map(|header| (header.part_name.clone(), header.nodes.clone()))
5916 .collect::<Vec<_>>(),
5917 );
5918 add_part_sections(
5919 §ion
5920 .footer_sections
5921 .iter()
5922 .map(|footer| (footer.part_name.clone(), footer.nodes.clone()))
5923 .collect::<Vec<_>>(),
5924 );
5925 }
5926 }
5927
5928 let part_names: Vec<String> = sections_by_part_name.keys().cloned().collect();
5929 for part_name in part_names {
5930 let nodes = sections_by_part_name.remove(&part_name).unwrap_or_default();
5931 let root_tag_name = section_root_tag_name_for_part_name(&part_name);
5932 let (xml, relationships) =
5933 serialize_section_part_xml(&nodes, root_tag_name, &part_name, pkg);
5934 pkg.parts.insert(
5935 part_name.clone(),
5936 OoxmlPart {
5937 name: part_name.clone(),
5938 content: xml,
5939 },
5940 );
5941 let relationship_part_name = relationship_part_name_for_word_part(&part_name);
5942 pkg.parts.insert(
5943 relationship_part_name.clone(),
5944 OoxmlPart {
5945 name: relationship_part_name,
5946 content: render_relationships_xml(&relationships),
5947 },
5948 );
5949 }
5950}
5951
5952fn opening_tag_end(xml: &str, start: usize) -> Option<usize> {
5953 let mut quote: Option<u8> = None;
5954 for (offset, byte) in xml.as_bytes().iter().copied().enumerate().skip(start) {
5955 if let Some(active_quote) = quote {
5956 if byte == active_quote {
5957 quote = None;
5958 }
5959 continue;
5960 }
5961 if byte == b'\'' || byte == b'"' {
5962 quote = Some(byte);
5963 continue;
5964 }
5965 if byte == b'>' {
5966 return Some(offset + 1);
5967 }
5968 }
5969 None
5970}
5971
5972fn attribute_value_range(open_tag: &str, attribute_name: &str) -> Option<(usize, usize)> {
5973 let bytes = open_tag.as_bytes();
5974 let name = attribute_name.as_bytes();
5975 let mut index = 1usize;
5976 while index + name.len() <= bytes.len() {
5977 let matches_name = bytes[index..index + name.len()]
5978 .iter()
5979 .zip(name.iter())
5980 .all(|(left, right)| left.eq_ignore_ascii_case(right));
5981 let before_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
5982 if !matches_name || !before_boundary {
5983 index += 1;
5984 continue;
5985 }
5986 let mut cursor = index + name.len();
5987 while bytes
5988 .get(cursor)
5989 .is_some_and(|byte| byte.is_ascii_whitespace())
5990 {
5991 cursor += 1;
5992 }
5993 if bytes.get(cursor) != Some(&b'=') {
5994 index += 1;
5995 continue;
5996 }
5997 cursor += 1;
5998 while bytes
5999 .get(cursor)
6000 .is_some_and(|byte| byte.is_ascii_whitespace())
6001 {
6002 cursor += 1;
6003 }
6004 let quote = *bytes.get(cursor)?;
6005 if quote != b'\'' && quote != b'"' {
6006 index += 1;
6007 continue;
6008 }
6009 let value_start = cursor + 1;
6010 let value_end = bytes[value_start..]
6011 .iter()
6012 .position(|byte| *byte == quote)
6013 .map(|offset| value_start + offset)?;
6014 return Some((value_start, value_end));
6015 }
6016 None
6017}
6018
6019fn set_xml_attribute(open_tag: &str, name: &str, value: &str) -> String {
6020 let escaped = escape_xml(value);
6021 if let Some((value_start, value_end)) = attribute_value_range(open_tag, name) {
6022 let mut updated = open_tag.to_string();
6023 updated.replace_range(value_start..value_end, &escaped);
6024 return updated;
6025 }
6026
6027 let mut insert_at = open_tag.len().saturating_sub(1);
6028 while insert_at > 0 && open_tag.as_bytes()[insert_at - 1].is_ascii_whitespace() {
6029 insert_at -= 1;
6030 }
6031 if insert_at > 0 && open_tag.as_bytes()[insert_at - 1] == b'/' {
6032 insert_at -= 1;
6033 }
6034 let mut updated = open_tag.to_string();
6035 updated.insert_str(insert_at, &format!(r#" {name}="{escaped}""#));
6036 updated
6037}
6038
6039fn deterministic_comment_paragraph_id(comment_id: i64) -> String {
6040 let normalized = comment_id.max(0) as u64;
6041 format!("{:08X}", (0xC000_0000u64 + normalized) & 0xFFFF_FFFF)
6042}
6043
6044fn source_comment_paragraph_id(source_xml: &str) -> Option<String> {
6045 let range = extract_balanced_tag_ranges(source_xml, "w:p")
6046 .into_iter()
6047 .last()?;
6048 let open_end = opening_tag_end(source_xml, range.start)?;
6049 get_attribute(&source_xml[range.start..open_end], "w14:paraId")
6050}
6051
6052fn ensure_comment_paragraph_id(source_xml: &str, paragraph_id: &str) -> String {
6053 let Some(range) = extract_balanced_tag_ranges(source_xml, "w:p")
6054 .into_iter()
6055 .last()
6056 else {
6057 return source_xml.to_string();
6058 };
6059 let Some(open_end) = opening_tag_end(source_xml, range.start) else {
6060 return source_xml.to_string();
6061 };
6062 let open_tag = &source_xml[range.start..open_end];
6063 let updated_open_tag = set_xml_attribute(open_tag, "w14:paraId", paragraph_id);
6064 format!(
6065 "{}{}{}",
6066 &source_xml[..range.start],
6067 updated_open_tag,
6068 &source_xml[open_end..]
6069 )
6070}
6071
6072fn comment_paragraph_id(comment: &DocumentCommentDefinition) -> String {
6073 comment
6074 .extended_paragraph_id
6075 .clone()
6076 .or_else(|| {
6077 comment
6078 .source_xml
6079 .as_deref()
6080 .and_then(source_comment_paragraph_id)
6081 })
6082 .unwrap_or_else(|| deterministic_comment_paragraph_id(comment.id))
6083}
6084
6085fn render_comment_block(
6086 comment: &DocumentCommentDefinition,
6087 ensure_paragraph_id: bool,
6088) -> (String, String) {
6089 let paragraph_id = comment_paragraph_id(comment);
6090 if let Some(source_xml) = &comment.source_xml {
6091 let rendered = if ensure_paragraph_id {
6092 ensure_comment_paragraph_id(source_xml, ¶graph_id)
6093 } else {
6094 source_xml.clone()
6095 };
6096 return (rendered, paragraph_id);
6097 }
6098
6099 let mut attributes = vec![format!(r#"w:id="{}""#, comment.id)];
6100 if let Some(author) = &comment.author {
6101 attributes.push(format!(r#"w:author="{}""#, escape_xml(author)));
6102 }
6103 if let Some(initials) = &comment.initials {
6104 attributes.push(format!(r#"w:initials="{}""#, escape_xml(initials)));
6105 }
6106 if let Some(date) = &comment.date {
6107 attributes.push(format!(r#"w:date="{}""#, escape_xml(date)));
6108 }
6109 let preserve = if should_preserve_whitespace(&comment.text) {
6110 r#" xml:space="preserve""#
6111 } else {
6112 ""
6113 };
6114 (
6115 format!(
6116 r#"<w:comment {}><w:p w14:paraId="{}"><w:r><w:t{}>{}</w:t></w:r></w:p></w:comment>"#,
6117 attributes.join(" "),
6118 escape_xml(¶graph_id),
6119 preserve,
6120 escape_xml(&comment.text)
6121 ),
6122 paragraph_id,
6123 )
6124}
6125
6126fn comment_id_from_block(block: &str) -> Option<i64> {
6127 let open_end = opening_tag_end(block, 0)?;
6128 get_attribute(&block[..open_end], "w:id")?.parse().ok()
6129}
6130
6131fn replace_xml_ranges(source: &str, mut replacements: Vec<(usize, usize, String)>) -> String {
6132 replacements.sort_by(|left, right| right.0.cmp(&left.0));
6133 let mut updated = source.to_string();
6134 for (start, end, value) in replacements {
6135 updated.replace_range(start..end, &value);
6136 }
6137 updated
6138}
6139
6140fn ensure_comments_root_namespaces(xml: &str) -> String {
6141 let Some(root) = extract_balanced_tag_ranges(xml, "w:comments")
6142 .into_iter()
6143 .next()
6144 else {
6145 return xml.to_string();
6146 };
6147 let Some(open_end) = opening_tag_end(xml, root.start) else {
6148 return xml.to_string();
6149 };
6150 let mut open_tag = xml[root.start..open_end].to_string();
6151 open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
6152 open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
6153 open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
6154 open_tag = ensure_ignorable_prefix(&open_tag, "w14");
6155 format!("{}{}{}", &xml[..root.start], open_tag, &xml[open_end..])
6156}
6157
6158fn merge_comments_xml(existing: Option<&str>, rendered_by_id: &HashMap<i64, String>) -> String {
6159 if let Some(existing) = existing {
6160 let ranges = extract_balanced_tag_ranges(existing, "w:comment");
6161 let mut seen = std::collections::HashSet::new();
6162 let replacements = ranges
6163 .into_iter()
6164 .filter_map(|range| {
6165 let block = &existing[range.start..range.end];
6166 let id = comment_id_from_block(block)?;
6167 let rendered = rendered_by_id.get(&id)?;
6168 seen.insert(id);
6169 Some((range.start, range.end, rendered.clone()))
6170 })
6171 .collect::<Vec<_>>();
6172 let mut merged = replace_xml_ranges(existing, replacements);
6173 let mut missing = rendered_by_id
6174 .iter()
6175 .filter(|(id, _)| !seen.contains(id))
6176 .collect::<Vec<_>>();
6177 missing.sort_by_key(|(id, _)| **id);
6178 if !missing.is_empty() {
6179 let appended = missing
6180 .into_iter()
6181 .map(|(_, block)| block.as_str())
6182 .collect::<String>();
6183 if let Some(close_start) = merged.rfind("</w:comments>") {
6184 merged.insert_str(close_start, &appended);
6185 }
6186 }
6187 return ensure_comments_root_namespaces(&merged);
6188 }
6189
6190 let mut rendered = rendered_by_id.iter().collect::<Vec<_>>();
6191 rendered.sort_by_key(|(id, _)| **id);
6192 let blocks = rendered
6193 .into_iter()
6194 .map(|(_, block)| block.as_str())
6195 .collect::<String>();
6196 format!(
6197 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:comments xmlns:w="{WORD_MAIN_NS}" xmlns:w14="{WORD_2010_NS}" xmlns:mc="{MARKUP_COMPATIBILITY_NS}" mc:Ignorable="w14">{blocks}</w:comments>"#
6198 )
6199}
6200
6201#[derive(Clone)]
6202struct CommentExtendedState {
6203 done: Option<bool>,
6204 parent_paragraph_id: Option<String>,
6205 patch_done: bool,
6206 patch_parent: bool,
6207}
6208
6209fn patch_comment_extended_block(block: &str, state: &CommentExtendedState, force: bool) -> String {
6210 let Some(open_end) = opening_tag_end(block, 0) else {
6211 return block.to_string();
6212 };
6213 let mut open_tag = block[..open_end].to_string();
6214 if (force || state.patch_done) && state.done.is_some() {
6215 let done = state.done.unwrap_or(false);
6216 open_tag = set_xml_attribute(&open_tag, "w15:done", if done { "1" } else { "0" });
6217 }
6218 if (force || state.patch_parent) && state.parent_paragraph_id.is_some() {
6219 let parent_id = state.parent_paragraph_id.as_deref().unwrap_or_default();
6220 open_tag = set_xml_attribute(&open_tag, "w15:paraIdParent", parent_id);
6221 }
6222 format!("{}{}", open_tag, &block[open_end..])
6223}
6224
6225fn merge_comments_extended_xml(
6226 existing: Option<&str>,
6227 desired: &HashMap<String, CommentExtendedState>,
6228) -> Option<String> {
6229 if desired.is_empty() {
6230 return existing.map(str::to_string);
6231 }
6232 if let Some(existing) = existing {
6233 let ranges = extract_balanced_tag_ranges(existing, "w15:commentEx");
6234 let mut seen = std::collections::HashSet::new();
6235 let replacements = ranges
6236 .into_iter()
6237 .filter_map(|range| {
6238 let block = &existing[range.start..range.end];
6239 let open_end = opening_tag_end(block, 0)?;
6240 let paragraph_id = get_attribute(&block[..open_end], "w15:paraId")?;
6241 let state = desired.get(¶graph_id)?;
6242 seen.insert(paragraph_id);
6243 Some((
6244 range.start,
6245 range.end,
6246 patch_comment_extended_block(block, state, false),
6247 ))
6248 })
6249 .collect::<Vec<_>>();
6250 let mut merged = replace_xml_ranges(existing, replacements);
6251 let mut missing = desired
6252 .iter()
6253 .filter(|(paragraph_id, _)| !seen.contains(*paragraph_id))
6254 .collect::<Vec<_>>();
6255 missing.sort_by_key(|(paragraph_id, _)| *paragraph_id);
6256 let appended = missing
6257 .into_iter()
6258 .map(|(paragraph_id, state)| {
6259 let mut tag = format!(
6260 r#"<w15:commentEx w15:paraId="{}"/>"#,
6261 escape_xml(paragraph_id)
6262 );
6263 tag = patch_comment_extended_block(&tag, state, true);
6264 tag
6265 })
6266 .collect::<String>();
6267 if let Some(close_start) = merged.rfind("</w15:commentsEx>") {
6268 merged.insert_str(close_start, &appended);
6269 }
6270 return Some(merged);
6271 }
6272
6273 let mut states = desired.iter().collect::<Vec<_>>();
6274 states.sort_by_key(|(paragraph_id, _)| *paragraph_id);
6275 let entries = states
6276 .into_iter()
6277 .map(|(paragraph_id, state)| {
6278 let tag = format!(
6279 r#"<w15:commentEx w15:paraId="{}"/>"#,
6280 escape_xml(paragraph_id)
6281 );
6282 patch_comment_extended_block(&tag, state, true)
6283 })
6284 .collect::<String>();
6285 Some(format!(
6286 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:commentsEx xmlns:w15="{WORD_2012_NS}">{entries}</w15:commentsEx>"#
6287 ))
6288}
6289
6290fn ensure_document_relationship(pkg: &mut OoxmlPackage, relationship_type: &str, target: &str) {
6291 let resolved_part_name =
6292 relationship_part_name_for_word_part(&crate::package::main_document_part_name(pkg));
6293 let part_name = resolved_part_name.as_str();
6294 let existing = pkg
6295 .parts
6296 .get(part_name)
6297 .map(|part| part.content.clone())
6298 .unwrap_or_else(|| EMPTY_RELATIONSHIPS_XML.to_string());
6299 let relationships = parse_relationships_xml(&existing);
6300 if relationships.iter().any(|relationship| {
6301 relationship.r#type == relationship_type
6302 && crate::parse::resolve_part_path(
6303 &crate::package::main_document_part_name(pkg),
6304 &relationship.target,
6305 ) == crate::parse::resolve_part_path(
6306 &crate::package::main_document_part_name(pkg),
6307 target,
6308 )
6309 }) {
6310 return;
6311 }
6312 let relationship = format!(
6313 r#"<Relationship Id="rId{}" Type="{}" Target="{}"/>"#,
6314 next_relationship_index(&relationships),
6315 escape_xml(relationship_type),
6316 escape_xml(target)
6317 );
6318 let updated = if let Some(close_start) = existing.rfind("</Relationships>") {
6319 format!(
6320 "{}{}{}",
6321 &existing[..close_start],
6322 relationship,
6323 &existing[close_start..]
6324 )
6325 } else if let Some(root_start) = existing.find("<Relationships") {
6326 if let Some(root_end) = opening_tag_end(&existing, root_start) {
6327 let root_tag = &existing[root_start..root_end];
6328 if root_tag.trim_end().ends_with("/>") {
6329 let slash = root_tag
6330 .rfind('/')
6331 .unwrap_or(root_tag.len().saturating_sub(1));
6332 format!(
6333 "{}{}>{}</Relationships>{}",
6334 &existing[..root_start],
6335 &root_tag[..slash],
6336 relationship,
6337 &existing[root_end..]
6338 )
6339 } else {
6340 existing
6341 }
6342 } else {
6343 existing
6344 }
6345 } else {
6346 existing
6347 };
6348 pkg.parts.insert(
6349 part_name.to_string(),
6350 OoxmlPart {
6351 name: part_name.to_string(),
6352 content: updated,
6353 },
6354 );
6355}
6356
6357fn serialize_comment_parts(model: &DocModel, pkg: &mut OoxmlPackage) {
6358 let source_part_name = crate::package::related_document_part_name(pkg, "comments");
6359 let Some(comments) = model
6360 .metadata
6361 .comments
6362 .as_ref()
6363 .filter(|comments| !comments.is_empty())
6364 else {
6365 return;
6366 };
6367
6368 let extended_comment_ids = comments
6369 .iter()
6370 .filter(|comment| {
6371 comment.resolved.is_some()
6372 || comment.source_resolved.is_some()
6373 || comment.resolution_dirty == Some(true)
6374 || comment.is_new == Some(true)
6375 || comment.parent_id.is_some()
6376 })
6377 .map(|comment| comment.id)
6378 .chain(comments.iter().filter_map(|comment| comment.parent_id))
6379 .collect::<std::collections::HashSet<_>>();
6380 let paragraph_id_by_comment_id = comments
6381 .iter()
6382 .map(|comment| (comment.id, comment_paragraph_id(comment)))
6383 .collect::<HashMap<_, _>>();
6384 let rendered_by_id = comments
6385 .iter()
6386 .map(|comment| {
6387 let (rendered, _) =
6388 render_comment_block(comment, extended_comment_ids.contains(&comment.id));
6389 (comment.id, rendered)
6390 })
6391 .collect::<HashMap<_, _>>();
6392
6393 let comments_xml = merge_comments_xml(
6394 pkg.parts
6395 .get(&source_part_name)
6396 .map(|part| part.content.as_str()),
6397 &rendered_by_id,
6398 );
6399 pkg.parts.insert(
6400 source_part_name.clone(),
6401 OoxmlPart {
6402 name: source_part_name.clone(),
6403 content: comments_xml,
6404 },
6405 );
6406
6407 let desired_extended = comments
6408 .iter()
6409 .filter(|comment| extended_comment_ids.contains(&comment.id))
6410 .filter_map(|comment| {
6411 let paragraph_id = paragraph_id_by_comment_id.get(&comment.id)?.clone();
6412 let parent_paragraph_id = comment
6413 .parent_id
6414 .and_then(|parent_id| paragraph_id_by_comment_id.get(&parent_id).cloned());
6415 Some((
6416 paragraph_id,
6417 CommentExtendedState {
6418 done: comment.resolved.or(comment.source_resolved),
6419 parent_paragraph_id,
6420 patch_done: comment.resolution_dirty == Some(true)
6421 || comment.is_new == Some(true),
6422 patch_parent: comment.is_new == Some(true),
6423 },
6424 ))
6425 })
6426 .collect::<HashMap<_, _>>();
6427 if let Some(comments_extended_xml) = merge_comments_extended_xml(
6428 pkg.parts
6429 .get("word/commentsExtended.xml")
6430 .map(|part| part.content.as_str()),
6431 &desired_extended,
6432 ) {
6433 pkg.parts.insert(
6434 "word/commentsExtended.xml".to_string(),
6435 OoxmlPart {
6436 name: "word/commentsExtended.xml".to_string(),
6437 content: comments_extended_xml,
6438 },
6439 );
6440 }
6441
6442 ensure_content_type_override(pkg, &source_part_name, COMMENTS_CONTENT_TYPE);
6443 ensure_document_relationship(pkg, REL_TYPE_COMMENTS, &format!("/{source_part_name}"));
6444 if pkg.parts.contains_key("word/commentsExtended.xml") {
6445 ensure_content_type_override(
6446 pkg,
6447 "word/commentsExtended.xml",
6448 COMMENTS_EXTENDED_CONTENT_TYPE,
6449 );
6450 ensure_document_relationship(pkg, REL_TYPE_COMMENTS_EXTENDED, "commentsExtended.xml");
6451 }
6452}
6453
6454fn upsert_settings_element(
6455 xml: &str,
6456 container_tag: &str,
6457 tag_name: &str,
6458 attributes: &[(&str, &str)],
6459 matches: impl Fn(&str) -> bool,
6460) -> String {
6461 for range in extract_balanced_tag_ranges(xml, tag_name) {
6462 let Some(open_end) = opening_tag_end(xml, range.start) else {
6463 continue;
6464 };
6465 let opening_tag = &xml[range.start..open_end];
6466 if !matches(opening_tag) {
6467 continue;
6468 }
6469 let updated_tag = attributes
6470 .iter()
6471 .fold(opening_tag.to_string(), |tag, (name, value)| {
6472 set_xml_attribute(&tag, name, value)
6473 });
6474 let mut updated = xml.to_string();
6475 updated.replace_range(range.start..open_end, &updated_tag);
6476 return updated;
6477 }
6478
6479 let attributes_xml = attributes
6480 .iter()
6481 .map(|(name, value)| format!(r#" {name}="{}""#, escape_xml(value)))
6482 .collect::<String>();
6483 let child_xml = format!("<{tag_name}{attributes_xml}/>");
6484 let Some(container_range) = extract_balanced_tag_ranges(xml, container_tag)
6485 .into_iter()
6486 .next()
6487 else {
6488 return xml.to_string();
6489 };
6490 let Some(open_end) = opening_tag_end(xml, container_range.start) else {
6491 return xml.to_string();
6492 };
6493 let opening_tag = &xml[container_range.start..open_end];
6494 let mut updated = xml.to_string();
6495 if opening_tag.trim_end().ends_with("/>") {
6496 let expanded = format!(
6497 "{}>{child_xml}</{container_tag}>",
6498 opening_tag
6499 .trim_end()
6500 .trim_end_matches('>')
6501 .trim_end()
6502 .trim_end_matches('/')
6503 .trim_end(),
6504 );
6505 updated.replace_range(container_range.start..container_range.end, &expanded);
6506 return updated;
6507 }
6508 let closing_tag = format!("</{container_tag}>");
6509 let Some(close_start) = xml[container_range.start..container_range.end].rfind(&closing_tag)
6510 else {
6511 return xml.to_string();
6512 };
6513 let mut insert_at = container_range.start + close_start;
6514 if container_tag == "w:settings" {
6515 if let Some(index) = SETTINGS_TAIL_ORDER
6516 .iter()
6517 .position(|name| *name == tag_name)
6518 {
6519 for following_tag in &SETTINGS_TAIL_ORDER[index + 1..] {
6520 if let Some(range) = extract_balanced_tag_ranges(xml, following_tag)
6521 .into_iter()
6522 .next()
6523 {
6524 if range.start >= open_end && range.start < insert_at {
6525 insert_at = range.start;
6526 }
6527 }
6528 }
6529 }
6530 }
6531 updated.insert_str(insert_at, &child_xml);
6532 updated
6533}
6534
6535fn remove_settings_element(xml: &str, tag_name: &str, matches: impl Fn(&str) -> bool) -> String {
6536 let mut updated = xml.to_string();
6537 for range in extract_balanced_tag_ranges(xml, tag_name).into_iter().rev() {
6538 let Some(open_end) = opening_tag_end(xml, range.start) else {
6539 continue;
6540 };
6541 if matches(&xml[range.start..open_end]) {
6542 updated.replace_range(range.start..range.end, "");
6543 }
6544 }
6545 updated
6546}
6547
6548const COMPAT_PROPERTIES_ORDER: &[&str] = &[
6549 "w:useSingleBorderforContiguousCells",
6550 "w:wpJustification",
6551 "w:noTabHangInd",
6552 "w:noLeading",
6553 "w:spaceForUL",
6554 "w:noColumnBalance",
6555 "w:balanceSingleByteDoubleByteWidth",
6556 "w:noExtraLineSpacing",
6557 "w:doNotLeaveBackslashAlone",
6558 "w:ulTrailSpace",
6559 "w:doNotExpandShiftReturn",
6560 "w:spacingInWholePoints",
6561 "w:lineWrapLikeWord6",
6562 "w:printBodyTextBeforeHeader",
6563 "w:printColBlack",
6564 "w:wpSpaceWidth",
6565 "w:showBreaksInFrames",
6566 "w:subFontBySize",
6567 "w:suppressBottomSpacing",
6568 "w:suppressTopSpacing",
6569 "w:suppressSpacingAtTopOfPage",
6570 "w:suppressTopSpacingWP",
6571 "w:suppressSpBfAfterPgBrk",
6572 "w:swapBordersFacingPages",
6573 "w:convMailMergeEsc",
6574 "w:truncateFontHeightsLikeWP6",
6575 "w:mwSmallCaps",
6576 "w:usePrinterMetrics",
6577 "w:doNotSuppressParagraphBorders",
6578 "w:wrapTrailSpaces",
6579 "w:footnoteLayoutLikeWW8",
6580 "w:shapeLayoutLikeWW8",
6581 "w:alignTablesRowByRow",
6582 "w:forgetLastTabAlignment",
6583 "w:adjustLineHeightInTable",
6584 "w:autoSpaceLikeWord95",
6585 "w:noSpaceRaiseLower",
6586 "w:doNotUseHTMLParagraphAutoSpacing",
6587 "w:layoutRawTableWidth",
6588 "w:layoutTableRowsApart",
6589 "w:useWord97LineBreakRules",
6590 "w:doNotBreakWrappedTables",
6591 "w:doNotSnapToGridInCell",
6592 "w:selectFldWithFirstOrLastChar",
6593 "w:applyBreakingRules",
6594 "w:doNotWrapTextWithPunct",
6595 "w:doNotUseEastAsianBreakRules",
6596 "w:useWord2002TableStyleRules",
6597 "w:growAutofit",
6598 "w:useFELayout",
6599 "w:useNormalStyleForList",
6600 "w:doNotUseIndentAsNumberingTabStop",
6601 "w:useAltKinsokuLineBreakRules",
6602 "w:allowSpaceOfSameStyleInTable",
6603 "w:doNotSuppressIndentation",
6604 "w:doNotAutofitConstrainedTables",
6605 "w:autofitToFirstFixedWidthCell",
6606 "w:underlineTabInNumList",
6607 "w:displayHangulFixedWidth",
6608 "w:splitPgBreakAndParaMark",
6609 "w:doNotVertAlignCellWithSp",
6610 "w:doNotBreakConstrainedForcedTable",
6611 "w:doNotVertAlignInTxbx",
6612 "w:useAnsiKerningPairs",
6613 "w:cachedColBalance",
6614 "w:compatSetting",
6615];
6616
6617fn serialize_document_settings(model: &DocModel, pkg: &mut OoxmlPackage) {
6618 let source_part_name = crate::package::related_document_part_name(pkg, "settings");
6619 let original_compatibility = crate::parse::parse_document_compatibility_settings(pkg);
6620 let original_tab_stop = crate::parse::parse_document_default_tab_stop_twips(pkg);
6621 let compatibility = model.metadata.compatibility.as_ref();
6622 let raw_compatibility_mode = compatibility.and_then(|settings| settings.compatibility_mode);
6623 let compatibility_mode = raw_compatibility_mode.filter(|mode| u32::try_from(*mode).is_ok());
6624 let enable_open_type_features =
6625 compatibility.and_then(|settings| settings.enable_open_type_features);
6626 let even_and_odd_headers = compatibility.and_then(|settings| settings.even_and_odd_headers);
6627 let default_tab_stop = model
6628 .metadata
6629 .default_tab_stop_twips
6630 .filter(|value| *value >= 0);
6631 let imported_settings = model.metadata.document_settings_imported == Some(true);
6632 let mode_changed = (compatibility_mode.is_some()
6633 || (imported_settings && raw_compatibility_mode.is_none()))
6634 && compatibility_mode
6635 != original_compatibility
6636 .as_ref()
6637 .and_then(|settings| settings.compatibility_mode);
6638 let headers_changed = (even_and_odd_headers.is_some() || imported_settings)
6639 && even_and_odd_headers
6640 != original_compatibility
6641 .as_ref()
6642 .and_then(|settings| settings.even_and_odd_headers);
6643 let open_type_changed = (enable_open_type_features.is_some() || imported_settings)
6644 && enable_open_type_features
6645 != original_compatibility
6646 .as_ref()
6647 .and_then(|settings| settings.enable_open_type_features);
6648 let tabs_changed = (default_tab_stop.is_some()
6649 || (imported_settings && model.metadata.default_tab_stop_twips.is_none()))
6650 && default_tab_stop != original_tab_stop;
6651 let imported_line_settings = model.metadata.line_spacing_compatibility_imported == Some(true);
6652 let line_settings = [
6653 (
6654 "w:adjustLineHeightInTable",
6655 compatibility.and_then(|value| value.adjust_line_height_in_table),
6656 original_compatibility
6657 .as_ref()
6658 .and_then(|value| value.adjust_line_height_in_table),
6659 ),
6660 (
6661 "w:noLeading",
6662 compatibility.and_then(|value| value.no_leading),
6663 original_compatibility
6664 .as_ref()
6665 .and_then(|value| value.no_leading),
6666 ),
6667 (
6668 "w:spaceForUL",
6669 compatibility.and_then(|value| value.space_for_ul),
6670 original_compatibility
6671 .as_ref()
6672 .and_then(|value| value.space_for_ul),
6673 ),
6674 (
6675 "w:noExtraLineSpacing",
6676 compatibility.and_then(|value| value.no_extra_line_spacing),
6677 original_compatibility
6678 .as_ref()
6679 .and_then(|value| value.no_extra_line_spacing),
6680 ),
6681 (
6682 "w:suppressBottomSpacing",
6683 compatibility.and_then(|value| value.suppress_bottom_spacing),
6684 original_compatibility
6685 .as_ref()
6686 .and_then(|value| value.suppress_bottom_spacing),
6687 ),
6688 (
6689 "w:suppressTopSpacingWP",
6690 compatibility.and_then(|value| value.suppress_top_spacing_wp),
6691 original_compatibility
6692 .as_ref()
6693 .and_then(|value| value.suppress_top_spacing_wp),
6694 ),
6695 (
6696 "w:truncateFontHeightsLikeWP6",
6697 compatibility.and_then(|value| value.truncate_font_heights_like_wp6),
6698 original_compatibility
6699 .as_ref()
6700 .and_then(|value| value.truncate_font_heights_like_wp6),
6701 ),
6702 ];
6703 let line_settings_changed = line_settings.iter().any(|(_, current, original)| {
6704 (current.is_some() || imported_line_settings) && current != original
6705 });
6706 if !mode_changed
6707 && !open_type_changed
6708 && !headers_changed
6709 && !tabs_changed
6710 && !line_settings_changed
6711 {
6712 return;
6713 }
6714
6715 let mut settings_xml = pkg.parts.get(&source_part_name)
6716 .map(|part| part.content.clone())
6717 .unwrap_or_else(|| format!(
6718 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="{WORD_MAIN_NS}"/>"#,
6719 ));
6720 if tabs_changed {
6721 settings_xml = if let Some(default_tab_stop) = default_tab_stop {
6722 let value = default_tab_stop.to_string();
6723 upsert_settings_element(
6724 &settings_xml,
6725 "w:settings",
6726 "w:defaultTabStop",
6727 &[("w:val", &value)],
6728 |_| true,
6729 )
6730 } else {
6731 remove_settings_element(&settings_xml, "w:defaultTabStop", |_| true)
6732 };
6733 }
6734 if headers_changed {
6735 settings_xml = if let Some(even_and_odd_headers) = even_and_odd_headers {
6736 let value = if even_and_odd_headers { "1" } else { "0" };
6737 upsert_settings_element(
6738 &settings_xml,
6739 "w:settings",
6740 "w:evenAndOddHeaders",
6741 &[("w:val", value)],
6742 |_| true,
6743 )
6744 } else {
6745 remove_settings_element(&settings_xml, "w:evenAndOddHeaders", |_| true)
6746 };
6747 }
6748 for (changed, name, value) in [
6749 (
6750 mode_changed,
6751 "compatibilityMode",
6752 compatibility_mode.map(|value| value.to_string()),
6753 ),
6754 (
6755 open_type_changed,
6756 "enableOpenTypeFeatures",
6757 enable_open_type_features.map(|value| if value { "1" } else { "0" }.to_string()),
6758 ),
6759 ] {
6760 if !changed {
6761 continue;
6762 }
6763 let compat_range = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6764 .into_iter()
6765 .next();
6766 let compat_xml = compat_range
6767 .as_ref()
6768 .map(|range| &settings_xml[range.start..range.end])
6769 .unwrap_or("<w:compat/>");
6770 let matches_setting = |tag: &str| {
6771 get_attribute(tag, "w:name").as_deref() == Some(name)
6772 && get_attribute(tag, "w:uri").as_deref()
6773 == Some("http://schemas.microsoft.com/office/word")
6774 };
6775 let updated_compat = if let Some(value) = value {
6776 upsert_settings_element(
6777 compat_xml,
6778 "w:compat",
6779 "w:compatSetting",
6780 &[
6781 ("w:name", name),
6782 ("w:uri", "http://schemas.microsoft.com/office/word"),
6783 ("w:val", &value),
6784 ],
6785 matches_setting,
6786 )
6787 } else {
6788 remove_settings_element(compat_xml, "w:compatSetting", matches_setting)
6789 };
6790 if let Some(range) = compat_range {
6791 settings_xml.replace_range(range.start..range.end, &updated_compat);
6792 } else {
6793 settings_xml =
6794 upsert_settings_element(&settings_xml, "w:settings", "w:compat", &[], |_| true);
6795 if let Some(range) = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6796 .into_iter()
6797 .next()
6798 {
6799 settings_xml.replace_range(range.start..range.end, &updated_compat);
6800 }
6801 }
6802 }
6803 if line_settings_changed {
6804 let range = extract_balanced_tag_ranges(&settings_xml, "w:settings")
6805 .into_iter()
6806 .next()
6807 .and_then(|settings| {
6808 direct_property_children(&settings_xml[settings.start..settings.end])
6809 .into_iter()
6810 .find(|(name, _)| name == "w:compat")
6811 .map(|(_, child)| crate::xml::TagRange {
6812 start: settings.start + child.start,
6813 end: settings.start + child.end,
6814 })
6815 });
6816 let mut compat = range
6817 .as_ref()
6818 .map(|range| settings_xml[range.start..range.end].to_string())
6819 .unwrap_or_else(|| "<w:compat/>".to_string());
6820 for (name, current, original) in line_settings {
6821 if (current.is_none() && !imported_line_settings) || current == original {
6822 continue;
6823 }
6824 let attributes =
6825 current.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
6826 compat = patch_direct_table_property(
6827 &compat,
6828 "w:compat",
6829 name,
6830 attributes.as_ref().map(|values| values.as_slice()),
6831 COMPAT_PROPERTIES_ORDER,
6832 );
6833 }
6834 if let Some(range) = range {
6835 settings_xml.replace_range(range.start..range.end, &compat);
6836 } else if !direct_property_children(&compat).is_empty() {
6837 settings_xml =
6838 upsert_settings_element(&settings_xml, "w:settings", "w:compat", &[], |_| true);
6839 if let Some(range) = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6840 .into_iter()
6841 .next()
6842 {
6843 settings_xml.replace_range(range.start..range.end, &compat);
6844 }
6845 }
6846 }
6847 pkg.parts.insert(
6848 source_part_name.clone(),
6849 OoxmlPart {
6850 name: source_part_name.clone(),
6851 content: settings_xml,
6852 },
6853 );
6854 ensure_content_type_override(pkg, &source_part_name, SETTINGS_CONTENT_TYPE);
6855 ensure_document_relationship(pkg, REL_TYPE_SETTINGS, &format!("/{source_part_name}"));
6856}
6857
6858fn ensure_document_open_tag(model: &DocModel) -> String {
6859 let mut open_tag = model.metadata.document_open_tag.clone().unwrap_or_else(|| {
6860 format!(r#"<w:document xmlns:w="{WORD_MAIN_NS}" xmlns:r="{OFFICE_REL_NS}">"#)
6861 });
6862
6863 open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
6864 open_tag = ensure_namespace(&open_tag, "r", OFFICE_REL_NS);
6865 open_tag = ensure_namespace(&open_tag, "a", DRAWING_MAIN_NS);
6866 open_tag = ensure_namespace(&open_tag, "wp", DRAWING_WORD_NS);
6867 open_tag = ensure_namespace(&open_tag, "pic", DRAWING_PICTURE_NS);
6868 open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
6869 open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
6870 open_tag = ensure_ignorable_prefix(&open_tag, "w14");
6871 open_tag
6872}
6873
6874fn model_to_document_xml_with_pkg(model: &DocModel, pkg: &mut OoxmlPackage) -> String {
6875 let resolved_document_part = crate::package::main_document_part_name(pkg);
6876 let document_part_name = resolved_document_part.as_str();
6877 let (body_xml, relationships) = {
6878 let mut state = create_image_serialization_state(pkg, document_part_name);
6879 let mut run_id_ref = 1i64;
6880 let body_xml = model
6881 .nodes
6882 .iter()
6883 .map(|node| match node {
6884 DocNode::Paragraph(paragraph) => {
6885 paragraph_xml(paragraph, &mut state, &mut run_id_ref)
6886 }
6887 DocNode::Table(table) => table_xml(table, &mut state, &mut run_id_ref),
6888 })
6889 .collect::<String>();
6890 (body_xml, state.relationships)
6891 };
6892
6893 let document_relationship_part_name = relationship_part_name_for_word_part(document_part_name);
6894 pkg.parts.insert(
6895 document_relationship_part_name.clone(),
6896 OoxmlPart {
6897 name: document_relationship_part_name,
6898 content: render_relationships_xml(&relationships),
6899 },
6900 );
6901
6902 let document_open_tag = ensure_document_open_tag(model);
6903 let section_properties_xml = model
6904 .metadata
6905 .section_properties_xml
6906 .as_deref()
6907 .unwrap_or(DEFAULT_SECTION_PROPERTIES_XML);
6908
6909 format!(
6910 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>{document_open_tag}<w:body>{body_xml}{section_properties_xml}</w:body></w:document>"#
6911 )
6912}
6913
6914pub fn can_preserve_image_paint_source(
6915 image: &ImageRunNode,
6916 base_package: Option<&OoxmlPackage>,
6917 owner_part_name: &str,
6918) -> bool {
6919 if image.paint_bounds.is_none() || !valid_image_paint_geometry(image) {
6920 return false;
6921 }
6922 let default_package;
6923 let package = match base_package {
6924 Some(package) => package,
6925 None => {
6926 default_package = create_minimal_docx_package(None);
6927 &default_package
6928 }
6929 };
6930 let owner = owner_part_name;
6931 if owner.is_empty() {
6932 return false;
6933 }
6934 let relationships = package
6935 .parts
6936 .get(&relationship_part_name_for_word_part(owner))
6937 .map(|part| parse_relationships_xml(&part.content))
6938 .unwrap_or_default();
6939 preserved_picture_run_xml(image, owner, &relationships, package).is_some()
6940 || (valid_image_paint_bounds(image)
6941 && edited_vml_textbox_source_plan(image, owner, &relationships, package).is_some())
6942}
6943
6944pub fn validate_image_paint_bounds(
6945 model: &DocModel,
6946 base_package: Option<&OoxmlPackage>,
6947) -> Result<(), String> {
6948 fn paragraph<'a>(
6949 value: &'a ParagraphNode,
6950 owner: &str,
6951 images: &mut Vec<(&'a ImageRunNode, String)>,
6952 ) {
6953 for child in &value.children {
6954 if let ParagraphChildNode::Image(image) = child {
6955 if image.paint_bounds.is_some() {
6956 images.push((image, owner.to_owned()));
6957 }
6958 if let Some(content) = &image.content_nodes {
6959 nodes(content, owner, images);
6960 }
6961 }
6962 }
6963 }
6964 fn table<'a>(value: &'a TableNode, owner: &str, images: &mut Vec<(&'a ImageRunNode, String)>) {
6965 for row in &value.rows {
6966 for cell in &row.cells {
6967 for child in &cell.nodes {
6968 match child {
6969 TableCellContentNode::Paragraph(value) => paragraph(value, owner, images),
6970 TableCellContentNode::Table(value) => table(value, owner, images),
6971 }
6972 }
6973 }
6974 }
6975 if let Some(patches) = &value.source_text_patches {
6976 for patch in patches {
6977 paragraph(&patch.paragraph, owner, images);
6978 }
6979 }
6980 }
6981 fn nodes<'a>(values: &'a [DocNode], owner: &str, images: &mut Vec<(&'a ImageRunNode, String)>) {
6982 for value in values {
6983 match value {
6984 DocNode::Paragraph(value) => paragraph(value, owner, images),
6985 DocNode::Table(value) => table(value, owner, images),
6986 }
6987 }
6988 }
6989 let default_package;
6990 let package = match base_package {
6991 Some(package) => package,
6992 None => {
6993 default_package = create_minimal_docx_package(None);
6994 &default_package
6995 }
6996 };
6997 let mut images = Vec::new();
6998 nodes(
6999 &model.nodes,
7000 &crate::package::main_document_part_name(package),
7001 &mut images,
7002 );
7003 let mut seen_parts = std::collections::HashSet::new();
7004 let mut add_part = |part_name: &str, values| {
7005 let owner = normalize_word_part_name(part_name);
7006 if !owner.is_empty() && seen_parts.insert(owner.clone()) {
7007 nodes(values, &owner, &mut images);
7008 }
7009 };
7010 for section in &model.metadata.header_sections {
7011 add_part(§ion.part_name, §ion.nodes);
7012 }
7013 for section in &model.metadata.footer_sections {
7014 add_part(§ion.part_name, §ion.nodes);
7015 }
7016 if let Some(sections) = &model.metadata.sections {
7017 for section in sections {
7018 for header in §ion.header_sections {
7019 add_part(&header.part_name, &header.nodes);
7020 }
7021 for footer in §ion.footer_sections {
7022 add_part(&footer.part_name, &footer.nodes);
7023 }
7024 }
7025 }
7026 for (image, owner) in images {
7027 if can_preserve_image_paint_source(image, Some(package), &owner) {
7028 continue;
7029 }
7030 if !valid_image_paint_bounds(image) {
7031 return Err("Inline image paint bounds require finite positive geometry and an inline layout frame".into());
7032 }
7033 if paint_png_bytes(image).is_none() {
7034 return Err("Inline image paint cannot be preserved from source; a current full-viewport PNG is required before export".into());
7035 }
7036 }
7037 Ok(())
7038}
7039
7040pub fn model_to_document_xml(model: &DocModel, base_package: Option<&OoxmlPackage>) -> String {
7042 validate_image_paint_bounds(model, base_package)
7043 .expect("Unsupported inline image paint; validate before XML serialization");
7044 let mut pkg = base_package.cloned().unwrap_or_else(|| {
7045 create_minimal_docx_package(Some(
7046 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t/></w:r></w:p></w:body></w:document>"#,
7047 ))
7048 });
7049 model_to_document_xml_with_pkg(model, &mut pkg)
7050}
7051
7052pub fn serialize_doc_model(model: &DocModel, base_package: Option<&OoxmlPackage>) -> OoxmlPackage {
7054 try_serialize_doc_model(model, base_package)
7055 .expect("Unsupported inline image paint; use try_serialize_doc_model")
7056}
7057
7058pub fn try_serialize_doc_model(
7059 model: &DocModel,
7060 base_package: Option<&OoxmlPackage>,
7061) -> Result<OoxmlPackage, String> {
7062 validate_image_paint_bounds(model, base_package)?;
7063 let mut seed = base_package
7064 .cloned()
7065 .unwrap_or_else(|| create_minimal_docx_package(None));
7066 let document_part_name = crate::package::main_document_part_name(&seed);
7067 let document_xml = model_to_document_xml_with_pkg(model, &mut seed);
7068 let with_document = with_part(
7069 &seed,
7070 OoxmlPart {
7071 name: document_part_name,
7072 content: document_xml,
7073 },
7074 );
7075 let mut result = with_document;
7076 serialize_header_footer_parts(model, &mut result);
7077 serialize_comment_parts(model, &mut result);
7078 serialize_document_settings(model, &mut result);
7079 Ok(result)
7080}
7081
7082pub fn serialize_docx(
7083 model: &DocModel,
7084 base_package: Option<&OoxmlPackage>,
7085) -> Result<Vec<u8>, String> {
7086 crate::table_grid::validate_collapsed_table_grids(model)?;
7087 package_to_bytes(&try_serialize_doc_model(model, base_package)?)
7088}
7089
7090#[cfg(test)]
7091#[path = "serialize/tests.rs"]
7092mod fidelity_tests;