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.as_deref().map(std::borrow::Cow::Borrowed);
1494 let mut content_type = image.content_type.clone();
1495
1496 if part_name.is_none() || image_data.is_none() {
1497 if let Some(src) = &image.src {
1498 if let Some((decoded_mime, decoded_data)) = decode_data_uri(src) {
1499 image_data = Some(std::borrow::Cow::Owned(decoded_data));
1500 if content_type.is_none() {
1501 content_type = Some(decoded_mime);
1502 }
1503 if part_name.is_none() {
1504 let extension =
1505 extension_from_mime_type(content_type.as_deref()).unwrap_or("png");
1506 part_name = Some(format!(
1507 "word/media/image{}.{}",
1508 state.next_image_index, extension
1509 ));
1510 state.next_image_index += 1;
1511 }
1512 }
1513 }
1514 }
1515
1516 let part_name = part_name?;
1517
1518 if let Some(data) = &image_data {
1519 if !state.pkg.binary_assets.contains_key(&part_name) {
1520 state
1521 .pkg
1522 .binary_assets
1523 .insert(part_name.clone(), data.clone().into_owned());
1524 }
1525 }
1526
1527 let extension = extension_from_part_name(Some(&part_name))
1528 .or_else(|| extension_from_mime_type(content_type.as_deref()).map(str::to_string))
1529 .unwrap_or_else(|| "png".to_string());
1530 let resolved_content_type = content_type.unwrap_or_else(|| {
1531 MIME_BY_EXTENSION
1532 .iter()
1533 .find(|(ext, _)| *ext == extension)
1534 .map(|(_, mime)| mime.to_string())
1535 .unwrap_or_else(|| "application/octet-stream".to_string())
1536 });
1537 ensure_content_type_default(state.pkg, &extension, &resolved_content_type);
1538
1539 let target = target_from_part_name(&part_name);
1540 let relationship = if let Some(existing) = state.relationship_by_target.get(&target) {
1541 existing.clone()
1542 } else {
1543 let relationship = Relationship {
1544 id: format!("rId{}", state.next_relationship_index),
1545 r#type: REL_TYPE_IMAGE.to_string(),
1546 target: target.clone(),
1547 target_mode: None,
1548 };
1549 state.next_relationship_index += 1;
1550 state.relationships.push(relationship.clone());
1551 state
1552 .relationship_by_target
1553 .insert(target.clone(), relationship.clone());
1554 relationship
1555 };
1556
1557 Some((
1558 relationship.id,
1559 image.width_px.unwrap_or(240.0),
1560 image.height_px.unwrap_or(160.0),
1561 image
1562 .alt
1563 .clone()
1564 .unwrap_or_else(|| "DOCX image".to_string()),
1565 ))
1566}
1567
1568fn ensure_hyperlink_relationship(target: &str, state: &mut ImageSerializationState<'_>) -> String {
1569 let target_mode = if is_external_target(target) {
1570 Some("External".to_string())
1571 } else {
1572 None
1573 };
1574
1575 if let Some(existing) = state.relationships.iter().find(|candidate| {
1576 candidate.r#type == REL_TYPE_HYPERLINK
1577 && candidate.target == target
1578 && candidate.target_mode == target_mode
1579 }) {
1580 return existing.id.clone();
1581 }
1582
1583 let relationship = Relationship {
1584 id: format!("rId{}", state.next_relationship_index),
1585 r#type: REL_TYPE_HYPERLINK.to_string(),
1586 target: target.to_string(),
1587 target_mode,
1588 };
1589 state.next_relationship_index += 1;
1590 let id = relationship.id.clone();
1591 state.relationships.push(relationship);
1592 id
1593}
1594
1595fn image_horizontal_align_str(align: ImageHorizontalAlign) -> &'static str {
1596 match align {
1597 ImageHorizontalAlign::Left => "left",
1598 ImageHorizontalAlign::Center => "center",
1599 ImageHorizontalAlign::Right => "right",
1600 ImageHorizontalAlign::Inside => "inside",
1601 ImageHorizontalAlign::Outside => "outside",
1602 }
1603}
1604
1605fn image_vertical_align_str(align: ImageVerticalAlign) -> &'static str {
1606 match align {
1607 ImageVerticalAlign::Top => "top",
1608 ImageVerticalAlign::Center => "center",
1609 ImageVerticalAlign::Bottom => "bottom",
1610 ImageVerticalAlign::Inside => "inside",
1611 ImageVerticalAlign::Outside => "outside",
1612 }
1613}
1614
1615fn image_wrap_text_attr(wrap_text: Option<ImageWrapText>) -> String {
1616 wrap_text
1617 .map(|value| {
1618 let text = match value {
1619 ImageWrapText::BothSides => "bothSides",
1620 ImageWrapText::Left => "left",
1621 ImageWrapText::Right => "right",
1622 ImageWrapText::Largest => "largest",
1623 };
1624 format!(r#" wrapText="{}""#, escape_xml(text))
1625 })
1626 .unwrap_or_default()
1627}
1628
1629const VML_NS: &str = "urn:schemas-microsoft-com:vml";
1630const VML_WORD_NS: &str = "urn:schemas-microsoft-com:office:word";
1631const 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""#;
1632
1633fn picture_source_namespaces(
1634 package: &OoxmlPackage,
1635 owner_part_name: &str,
1636) -> Option<std::collections::BTreeMap<String, String>> {
1637 let mut namespaces = std::collections::BTreeMap::new();
1638 if let Some(part) = package.parts.get(owner_part_name) {
1639 let mut reader = quick_xml::Reader::from_str(&part.content);
1640 loop {
1641 match reader.read_event().ok()? {
1642 quick_xml::events::Event::Start(tag) | quick_xml::events::Event::Empty(tag) => {
1643 for attribute in tag.attributes() {
1644 let attribute = attribute.ok()?;
1645 let name = attribute.key.as_ref();
1646 if name == "xmlns" {
1647 namespaces.insert(
1648 String::new(),
1649 decode_xml_entities(attribute.value.as_ref()),
1650 );
1651 } else if let Some(prefix) = name.strip_prefix("xmlns:") {
1652 namespaces.insert(
1653 prefix.to_owned(),
1654 decode_xml_entities(attribute.value.as_ref()),
1655 );
1656 }
1657 }
1658 break;
1659 }
1660 quick_xml::events::Event::Eof => return None,
1661 _ => {}
1662 }
1663 }
1664 }
1665 for (prefix, uri) in [
1666 ("w", WORD_MAIN_NS),
1667 ("r", OFFICE_REL_NS),
1668 ("a", DRAWING_MAIN_NS),
1669 ("wp", DRAWING_WORD_NS),
1670 ("pic", DRAWING_PICTURE_NS),
1671 ("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"),
1672 ("am3d", "http://schemas.microsoft.com/office/drawing/2017/model3d"),
1673 ("aink", "http://schemas.microsoft.com/office/drawing/2016/ink"),
1674 ("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"),
1675 ("v", VML_NS),
1676 ("o", "urn:schemas-microsoft-com:office:office"),
1677 ("w10", VML_WORD_NS),
1678 ("w14", WORD_2010_NS),
1679 ("mc", MARKUP_COMPATIBILITY_NS),
1680 (
1681 "a14",
1682 "http://schemas.microsoft.com/office/drawing/2010/main",
1683 ),
1684 (
1685 "asvg",
1686 "http://schemas.microsoft.com/office/drawing/2016/SVG/main",
1687 ),
1688 (
1689 "wp14",
1690 "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
1691 ),
1692 (
1693 "wps",
1694 "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
1695 ),
1696 (
1697 "wpg",
1698 "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup",
1699 ),
1700 ] {
1701 namespaces
1702 .entry(prefix.to_owned())
1703 .or_insert_with(|| uri.to_owned());
1704 }
1705 Some(namespaces)
1706}
1707
1708fn preserved_picture_run_xml(
1709 image: &ImageRunNode,
1710 owner_part_name: &str,
1711 relationships: &[Relationship],
1712 package: &OoxmlPackage,
1713) -> Option<(String, i64)> {
1714 if image.synthetic_text_box == Some(true)
1715 || image.content_nodes.is_some()
1716 || image.text_box_text.is_some()
1717 || image.math_ml.is_some()
1718 || image.chart_part_name.is_some()
1719 || image.chart_data.is_some()
1720 || !valid_image_paint_geometry(image)
1721 {
1722 return None;
1723 }
1724 let source = image.source_xml.as_deref()?;
1725 let namespaces = picture_source_namespaces(package, owner_part_name)?;
1726 let declarations = namespaces
1727 .iter()
1728 .map(|(prefix, uri)| {
1729 if prefix.is_empty() {
1730 format!(" xmlns=\"{}\"", escape_xml(uri))
1731 } else {
1732 format!(" xmlns:{prefix}=\"{}\"", escape_xml(uri))
1733 }
1734 })
1735 .collect::<String>();
1736 let open = format!("<root{declarations}>");
1737 let wrapped = format!("{open}{source}</root>");
1738 let document = roxmltree::Document::parse(&wrapped).ok()?;
1739 let root = document.root_element();
1740 if root
1741 .children()
1742 .any(|node| node.is_text() && node.text().is_some_and(|text| !text.trim().is_empty()))
1743 {
1744 return None;
1745 }
1746 let top: Vec<_> = root.children().filter(|node| node.is_element()).collect();
1747 let [top] = top.as_slice() else {
1748 return None;
1749 };
1750 let is_visual = |node: roxmltree::Node<'_, '_>| {
1751 node.has_tag_name((WORD_MAIN_NS, "drawing"))
1752 || node.has_tag_name((WORD_MAIN_NS, "pict"))
1753 || node.has_tag_name((MARKUP_COMPATIBILITY_NS, "AlternateContent"))
1754 };
1755 let is_run = top.has_tag_name((WORD_MAIN_NS, "r"));
1756 if is_run {
1757 let children: Vec<_> = top.children().filter(|node| node.is_element()).collect();
1758 if children.iter().filter(|node| is_visual(**node)).count() != 1
1759 || children
1760 .iter()
1761 .any(|node| !is_visual(*node) && !node.has_tag_name((WORD_MAIN_NS, "rPr")))
1762 {
1763 return None;
1764 }
1765 } else if !is_visual(*top) {
1766 return None;
1767 }
1768 if document
1769 .descendants()
1770 .filter(|node| node.is_element() && *node != root)
1771 .any(|node| {
1772 if node.tag_name().namespace() != Some(WORD_MAIN_NS) {
1773 return false;
1774 }
1775 if (node == *top && is_run) || matches!(node.tag_name().name(), "drawing" | "pict") {
1776 return false;
1777 }
1778 let inside_run_properties = is_run
1779 && node.ancestors().any(|ancestor| {
1780 ancestor.has_tag_name((WORD_MAIN_NS, "rPr")) && ancestor.parent() == Some(*top)
1781 });
1782 !inside_run_properties
1783 })
1784 {
1785 return None;
1786 }
1787
1788 let mut targets = HashMap::new();
1789 for relationship in relationships {
1790 let target = if relationship.target_mode.as_deref() == Some("External") {
1791 relationship.target.clone()
1792 } else {
1793 crate::parse::resolve_part_path(owner_part_name, &relationship.target)
1794 };
1795 targets.insert(relationship.id.clone(), target);
1796 }
1797 let mut reserved_relationship_index = 1i64;
1798 for (node, attribute) in document
1799 .descendants()
1800 .flat_map(|node| node.attributes().map(move |attribute| (node, attribute)))
1801 {
1802 let namespace = attribute.namespace();
1803 if namespace != Some(OFFICE_REL_NS)
1804 && !(namespace == Some("urn:schemas-microsoft-com:office:office")
1805 && attribute.name() == "relid")
1806 {
1807 continue;
1808 }
1809 let diagram_relationship = node.has_tag_name((
1810 "http://schemas.openxmlformats.org/drawingml/2006/diagram", "relIds",
1811 )).then(|| match attribute.name() {
1812 "dm" => Some("diagramData"),
1813 "lo" => Some("diagramLayout"),
1814 "qs" => Some("diagramQuickStyle"),
1815 "cs" => Some("diagramColors"),
1816 _ => None,
1817 }).flatten();
1818 if diagram_relationship.is_none()
1819 && !matches!(attribute.name(), "id" | "embed" | "link" | "href" | "relid") {
1820 return None;
1821 }
1822 let mut matching = relationships
1823 .iter()
1824 .filter(|relationship| relationship.id == attribute.value());
1825 let Some(relationship) = matching.next() else {
1826 if diagram_relationship.is_some() {
1827 return None;
1828 }
1829 if let Some(index) = attribute
1830 .value()
1831 .strip_prefix("rId")
1832 .and_then(|value| value.parse::<i64>().ok())
1833 {
1834 reserved_relationship_index =
1835 reserved_relationship_index.max(index.checked_add(1)?);
1836 }
1837 continue;
1838 };
1839 if matching.next().is_some() || relationship.target.is_empty() {
1840 return None;
1841 }
1842 let hyperlink = node.tag_name().namespace() == Some(DRAWING_MAIN_NS)
1843 && matches!(node.tag_name().name(), "hlinkClick" | "hlinkHover");
1844 let visual_source_relationship = if node.has_tag_name((
1845 "http://schemas.microsoft.com/office/drawing/2017/model3d", "model3d",
1846 )) && attribute.name() == "embed" {
1847 Some(("http://schemas.microsoft.com/office/2017/06/relationships/model3d", false))
1848 } else if node.has_tag_name((WORD_2010_NS, "contentPart")) && attribute.name() == "id" {
1849 Some(("http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml", true))
1850 } else {
1851 None
1852 };
1853 if let Some(kind) = diagram_relationship {
1854 if relationship.r#type != format!("{OFFICE_REL_NS}/{kind}")
1855 || !matches!(relationship.target_mode.as_deref(), None | Some("Internal"))
1856 || !package.parts.contains_key(&crate::parse::resolve_part_path(owner_part_name, &relationship.target))
1857 {
1858 return None;
1859 }
1860 } else if let Some((kind, xml_part)) = visual_source_relationship {
1861 let target = crate::parse::resolve_part_path(owner_part_name, &relationship.target);
1862 if relationship.r#type != kind
1863 || !matches!(relationship.target_mode.as_deref(), None | Some("Internal"))
1864 || if xml_part { !package.parts.contains_key(&target) } else { !package.binary_assets.contains_key(&target) }
1865 {
1866 return None;
1867 }
1868 } else if hyperlink {
1869 if relationship.r#type != REL_TYPE_HYPERLINK
1870 || relationship.target_mode.as_deref() != Some("External")
1871 {
1872 return None;
1873 }
1874 } else if relationship.r#type != REL_TYPE_IMAGE
1875 || !matches!(
1876 relationship.target_mode.as_deref(),
1877 None | Some("Internal" | "External")
1878 )
1879 {
1880 return None;
1881 }
1882 }
1883 let context = crate::parse::ParseContext {
1884 relationships: targets,
1885 content_types: crate::parse::parse_content_types(package),
1886 parts: &package.parts,
1887 binary_assets: &package.binary_assets,
1888 style_sheet: crate::parse::parse_style_sheet(package),
1889 warnings: std::cell::RefCell::new(Vec::new()),
1890 };
1891 let parsed = crate::parse::parse_run_images(source, &context);
1892 let [parsed] = parsed.as_slice() else {
1893 return None;
1894 };
1895 if parsed.src.is_none()
1896 || parsed.src != image.src
1897 || parsed.alt != image.alt
1898 || parsed.width_px != image.width_px
1899 || parsed.height_px != image.height_px
1900 || parsed.part_name != image.part_name
1901 || parsed.content_type != image.content_type
1902 || parsed.data != image.data
1903 || parsed.crop != image.crop
1904 || parsed.css_filter != image.css_filter
1905 || parsed.css_opacity != image.css_opacity
1906 || parsed.paint_bounds != image.paint_bounds
1907 || parsed.floating != image.floating
1908 || parsed.presentation != image.presentation
1909 || parsed.synthetic_text_box != image.synthetic_text_box
1910 {
1911 return None;
1912 }
1913 if is_run {
1914 let start = top.range().start - open.len();
1915 let end = opening_tag_end(source, start)?;
1916 let mut tag = source[start..end].to_owned();
1917 for (prefix, uri) in namespaces {
1918 if prefix.is_empty() {
1919 if get_attribute(&tag, "xmlns").is_none() {
1920 tag.insert_str(tag.len() - 1, &format!(" xmlns=\"{}\"", escape_xml(&uri)));
1921 }
1922 } else {
1923 tag = ensure_namespace(&tag, &prefix, &escape_xml(&uri));
1924 }
1925 }
1926 Some((
1927 format!("{}{}{}", &source[..start], tag, &source[end..]),
1928 reserved_relationship_index,
1929 ))
1930 } else {
1931 Some((
1932 format!("<w:r{declarations}>{source}</w:r>"),
1933 reserved_relationship_index,
1934 ))
1935 }
1936}
1937
1938fn vml_source_frame(root: roxmltree::Node<'_, '_>) -> Option<(f64, f64, Option<ImageFloating>)> {
1939 let style = root.attribute("style")?;
1940 let mut declarations = HashMap::new();
1941 for entry in style
1942 .split(';')
1943 .map(str::trim)
1944 .filter(|entry| !entry.is_empty())
1945 {
1946 let (key, value) = entry.split_once(':')?;
1947 let key = key.trim().to_ascii_lowercase();
1948 let value = value.trim().to_ascii_lowercase();
1949 if key.is_empty() || value.is_empty() || declarations.insert(key, value).is_some() {
1950 return None;
1951 }
1952 }
1953 let dimension = |name: &str| -> Option<f64> {
1954 let value = declarations.get(name)?;
1955 let split = value.find(|c: char| c.is_ascii_alphabetic())?;
1956 let number: f64 = value[..split].trim().parse().ok()?;
1957 let scale = match value[split..].trim() {
1958 "px" => 1.0,
1959 "pt" => 96.0 / 72.0,
1960 "in" => 96.0,
1961 "cm" => 96.0 / 2.54,
1962 "mm" => 96.0 / 25.4,
1963 _ => return None,
1964 };
1965 let pixels = number * scale;
1966 (pixels.is_finite() && pixels > 0.0).then_some(pixels)
1967 };
1968 let width = dimension("width")?;
1969 let height = dimension("height")?;
1970 let wraps: Vec<_> = root
1971 .children()
1972 .filter(|node| node.has_tag_name((VML_WORD_NS, "wrap")))
1973 .collect();
1974 if wraps.len() > 1 {
1975 return None;
1976 }
1977 let wrap = wraps
1978 .first()
1979 .map(|node| {
1980 let attributes = ["type", "side"]
1981 .into_iter()
1982 .filter_map(|name| {
1983 node.attribute(name)
1984 .map(|value| format!(" {name}=\"{}\"", escape_xml(value)))
1985 })
1986 .collect::<String>();
1987 format!("<w10:wrap{attributes}/>")
1988 })
1989 .unwrap_or_default();
1990 let synthetic = format!("<v:shape style=\"{}\">{wrap}</v:shape>", escape_xml(style));
1991 let mut floating = crate::parse::parse_vml_floating_anchor_from_run_xml(&synthetic);
1992 if let Some(anchor) = floating.as_mut() {
1993 if anchor.horizontal_align.is_none() {
1994 if let Some(value) = declarations.get("margin-left") {
1995 anchor.x_px = Some(crate::parse::vml_pixels(value)?);
1996 }
1997 }
1998 if anchor.vertical_align.is_none() {
1999 if let Some(value) = declarations.get("margin-top") {
2000 anchor.y_px = Some(crate::parse::vml_pixels(value)?);
2001 }
2002 }
2003 }
2004 let inert = declarations.keys().all(|key| {
2005 matches!(
2006 key.as_str(),
2007 "width"
2008 | "height"
2009 | "position"
2010 | "mso-position-horizontal-relative"
2011 | "mso-position-vertical-relative"
2012 )
2013 }) && declarations
2014 .get("position")
2015 .is_none_or(|value| matches!(value.as_str(), "static" | "relative"));
2016 if inert {
2017 if let Some(anchor) = floating.as_mut() {
2018 anchor.horizontal_relative_to = None;
2019 anchor.vertical_relative_to = None;
2020 if anchor.x_px.is_none()
2021 && anchor.y_px.is_none()
2022 && anchor.horizontal_align.is_none()
2023 && anchor.vertical_align.is_none()
2024 && anchor.dist_l_px.is_none()
2025 && anchor.dist_r_px.is_none()
2026 && anchor.dist_t_px.is_none()
2027 && anchor.dist_b_px.is_none()
2028 && anchor.wrap_type.is_none()
2029 && anchor.wrap_text.is_none()
2030 && anchor.behind_document.is_none()
2031 && anchor.z_index.is_none()
2032 {
2033 floating = None;
2034 }
2035 }
2036 }
2037 Some((width, height, floating))
2038}
2039
2040fn vml_paragraph_bookmarks(
2041 paragraph: roxmltree::Node<'_, '_>,
2042 source: &str,
2043 seen_ids: &mut std::collections::HashSet<String>,
2044) -> Option<(String, String)> {
2045 let text_runs: Vec<_> = paragraph
2046 .children()
2047 .filter(|node| {
2048 node.has_tag_name((WORD_MAIN_NS, "r"))
2049 && node.children().any(|child| {
2050 child.has_tag_name((WORD_MAIN_NS, "t"))
2051 && child.text().is_some_and(|text| !text.is_empty())
2052 || child.tag_name().namespace() == Some(WORD_MAIN_NS)
2053 && matches!(child.tag_name().name(), "tab" | "br" | "cr")
2054 })
2055 })
2056 .collect();
2057 let mut open = std::collections::HashSet::new();
2058 let mut before = String::new();
2059 let mut after = String::new();
2060 for marker in paragraph.children().filter(|node| {
2061 node.has_tag_name((WORD_MAIN_NS, "bookmarkStart"))
2062 || node.has_tag_name((WORD_MAIN_NS, "bookmarkEnd"))
2063 }) {
2064 let raw = &source[marker.range()];
2065 if !raw.starts_with(&format!("<w:{}", marker.tag_name().name()))
2066 || marker.children().any(|node| {
2067 node.is_element() || node.text().is_some_and(|text| !text.trim().is_empty())
2068 })
2069 || marker.attributes().any(|attribute| {
2070 attribute.namespace() != Some(WORD_MAIN_NS)
2071 || &source[attribute.range_qname()] != format!("w:{}", attribute.name())
2072 })
2073 {
2074 return None;
2075 }
2076 let id = marker.attribute((WORD_MAIN_NS, "id"))?;
2077 if id.is_empty() {
2078 return None;
2079 }
2080 if marker.has_tag_name((WORD_MAIN_NS, "bookmarkStart")) {
2081 if !open.insert(id) || !seen_ids.insert(id.to_owned()) {
2082 return None;
2083 }
2084 } else if !open.remove(id) {
2085 return None;
2086 }
2087 if let (Some(first), Some(last)) = (text_runs.first(), text_runs.last()) {
2088 if marker.range().start > first.range().start && marker.range().end < last.range().end {
2089 return None;
2090 }
2091 if marker.range().start < first.range().start {
2092 before.push_str(raw);
2093 } else {
2094 after.push_str(raw);
2095 }
2096 } else {
2097 before.push_str(raw);
2098 }
2099 }
2100 open.is_empty().then_some((before, after))
2101}
2102
2103struct VmlTextboxSourcePlan {
2104 wrapped: String,
2105 open_len: usize,
2106 paragraphs: Vec<(
2107 std::ops::Range<usize>,
2108 std::ops::Range<usize>,
2109 Vec<(String, String)>,
2110 )>,
2111 run_start: Option<usize>,
2112 is_pict: bool,
2113 namespaces: Vec<(String, String)>,
2114}
2115
2116fn finite_coordinates<const N: usize>(value: &str) -> Option<[f64; N]> {
2117 let values = value
2118 .split(|character: char| character == ',' || character.is_ascii_whitespace())
2119 .filter(|value| !value.is_empty())
2120 .map(|value| value.parse::<f64>().ok().filter(|value| value.is_finite()))
2121 .collect::<Option<Vec<_>>>()?;
2122 values.try_into().ok()
2123}
2124
2125fn valid_image_paint_geometry(image: &ImageRunNode) -> bool {
2126 let Some(bounds) = image.paint_bounds.as_deref() else {
2127 return true;
2128 };
2129 let Some((width, height)) = image.width_px.zip(image.height_px) else {
2130 return false;
2131 };
2132 let scale_x = width / bounds.layout_width_px;
2133 let scale_y = height / bounds.layout_height_px;
2134 bounds.x_px.is_finite()
2135 && bounds.y_px.is_finite()
2136 && [
2137 bounds.width_px,
2138 bounds.height_px,
2139 bounds.layout_width_px,
2140 bounds.layout_height_px,
2141 ]
2142 .into_iter()
2143 .all(|value| value.is_finite() && value > 0.0)
2144 && image
2145 .width_px
2146 .is_some_and(|value| value.is_finite() && value > 0.0)
2147 && image
2148 .height_px
2149 .is_some_and(|value| value.is_finite() && value > 0.0)
2150 && [
2151 scale_x,
2152 scale_y,
2153 bounds.width_px * scale_x,
2154 bounds.height_px * scale_y,
2155 ]
2156 .into_iter()
2157 .all(|value| value.is_finite() && value > 0.0)
2158 && (bounds.x_px * scale_x).is_finite()
2159 && (bounds.y_px * scale_y).is_finite()
2160 && (bounds.x_px + bounds.width_px).is_finite()
2161 && (bounds.y_px + bounds.height_px).is_finite()
2162 && (bounds.x_px * scale_x + bounds.width_px * scale_x).is_finite()
2163 && (bounds.y_px * scale_y + bounds.height_px * scale_y).is_finite()
2164}
2165
2166fn valid_image_paint_bounds(image: &ImageRunNode) -> bool {
2167 valid_image_paint_geometry(image) && (image.paint_bounds.is_none() || image.floating.is_none())
2168}
2169
2170fn vml_paint_matches_source(
2171 image: &ImageRunNode,
2172 svg: roxmltree::Node<'_, '_>,
2173 source: roxmltree::Node<'_, '_>,
2174 source_width: f64,
2175 source_height: f64,
2176 frame_width: f64,
2177 frame_height: f64,
2178) -> bool {
2179 let Some(bounds) = image.paint_bounds.as_deref() else {
2180 return source_width == frame_width
2181 && source_height == frame_height
2182 && image.width_px == Some(frame_width)
2183 && image.height_px == Some(frame_height);
2184 };
2185 if !valid_image_paint_bounds(image)
2186 || image.width_px != Some(source_width)
2187 || image.height_px != Some(source_height)
2188 || bounds.layout_width_px != source_width
2189 || bounds.layout_height_px != source_height
2190 || bounds.width_px != frame_width
2191 || bounds.height_px != frame_height
2192 || svg.attribute("preserveAspectRatio") != Some("none")
2193 {
2194 return false;
2195 }
2196 let Some([origin_x, origin_y]) =
2197 finite_coordinates::<2>(source.attribute("coordorigin").unwrap_or("0,0"))
2198 else {
2199 return false;
2200 };
2201 let Some([extent_x, extent_y]) =
2202 finite_coordinates::<2>(source.attribute("coordsize").unwrap_or("1000,1000"))
2203 else {
2204 return false;
2205 };
2206 let Some([canvas_x, canvas_y, canvas_width, canvas_height]) =
2207 svg.attribute("viewBox").and_then(finite_coordinates::<4>)
2208 else {
2209 return false;
2210 };
2211 extent_x > 0.0
2212 && extent_y > 0.0
2213 && canvas_width > 0.0
2214 && canvas_height > 0.0
2215 && bounds.x_px
2216 == if source_width == extent_x {
2217 canvas_x - origin_x
2218 } else {
2219 (canvas_x - origin_x) * source_width / extent_x
2220 }
2221 && bounds.y_px
2222 == if source_height == extent_y {
2223 canvas_y - origin_y
2224 } else {
2225 (canvas_y - origin_y) * source_height / extent_y
2226 }
2227 && bounds.width_px
2228 == if source_width == extent_x {
2229 canvas_width
2230 } else if canvas_width == extent_x {
2231 source_width
2232 } else {
2233 canvas_width * source_width / extent_x
2234 }
2235 && bounds.height_px
2236 == if source_height == extent_y {
2237 canvas_height
2238 } else if canvas_height == extent_y {
2239 source_height
2240 } else {
2241 canvas_height * source_height / extent_y
2242 }
2243}
2244
2245fn vml_floating_paint_matches_source(
2246 image: &ImageRunNode,
2247 svg: roxmltree::Node<'_, '_>,
2248 source: &str,
2249 owner_part_name: &str,
2250 relationships: &[Relationship],
2251 package: &OoxmlPackage,
2252) -> bool {
2253 if image.floating.is_none() || image.paint_bounds.is_some() {
2254 return false;
2255 }
2256 let mut targets = HashMap::new();
2257 for relationship in relationships {
2258 if relationship.r#type == REL_TYPE_IMAGE && relationship.target_mode.is_none() {
2259 targets.entry(relationship.id.clone()).or_insert_with(|| {
2260 crate::parse::resolve_part_path(owner_part_name, &relationship.target)
2261 });
2262 }
2263 }
2264 let context = crate::parse::ParseContext {
2265 relationships: targets,
2266 content_types: crate::parse::parse_content_types(package),
2267 parts: &package.parts,
2268 binary_assets: &package.binary_assets,
2269 style_sheet: crate::parse::parse_style_sheet(package),
2270 warnings: std::cell::RefCell::new(Vec::new()),
2271 };
2272 let Some(parsed) = crate::parse::parse_vml_group_image_from_part(source, &context, owner_part_name) else {
2273 return false;
2274 };
2275 if parsed.synthetic_text_box != Some(true)
2276 || parsed.paint_bounds.is_some()
2277 || parsed.width_px != image.width_px
2278 || parsed.height_px != image.height_px
2279 || parsed.floating != image.floating
2280 {
2281 return false;
2282 }
2283 let Some((mime, bytes)) = parsed.src.as_deref().and_then(decode_data_uri) else {
2284 return false;
2285 };
2286 if mime != "image/svg+xml" {
2287 return false;
2288 }
2289 let Some(expected) = std::str::from_utf8(&bytes)
2290 .ok()
2291 .and_then(|text| roxmltree::Document::parse(text).ok())
2292 else {
2293 return false;
2294 };
2295 ["width", "height", "viewBox", "preserveAspectRatio"]
2296 .into_iter()
2297 .all(|name| svg.attribute(name) == expected.root_element().attribute(name))
2298}
2299
2300fn edited_vml_textbox_source_plan(
2301 image: &ImageRunNode,
2302 owner_part_name: &str,
2303 relationships: &[Relationship],
2304 package: &OoxmlPackage,
2305) -> Option<VmlTextboxSourcePlan> {
2306 if image.synthetic_text_box != Some(true)
2307 || image.part_name.is_some()
2308 || image.data.is_some()
2309 || image.crop.is_some()
2310 || image.css_filter.is_some()
2311 || image.css_opacity.is_some()
2312 {
2313 return None;
2314 }
2315 let source = image.source_xml.as_deref()?;
2316 let (mime, svg_bytes) = decode_data_uri(image.src.as_deref()?)?;
2317 if mime != "image/svg+xml" {
2318 return None;
2319 }
2320 let svg_text = std::str::from_utf8(&svg_bytes).ok()?;
2321 let svg = roxmltree::Document::parse(svg_text).ok()?;
2322 if !svg
2323 .root_element()
2324 .has_tag_name(("http://www.w3.org/2000/svg", "svg"))
2325 {
2326 return None;
2327 }
2328 let frame_width: f64 = svg.root_element().attribute("width")?.parse().ok()?;
2329 let frame_height: f64 = svg.root_element().attribute("height")?.parse().ok()?;
2330 if !frame_width.is_finite()
2331 || !frame_height.is_finite()
2332 || frame_width <= 0.0
2333 || frame_height <= 0.0
2334 {
2335 return None;
2336 }
2337 let open = format!("<root {VML_SOURCE_NAMESPACES}>");
2338 let wrapped = format!("{open}{source}</root>");
2339 let document = roxmltree::Document::parse(&wrapped).ok()?;
2340 let top: Vec<_> = document
2341 .root_element()
2342 .children()
2343 .filter(|node| node.is_element())
2344 .collect();
2345 match top.as_slice() {
2346 [node] if node.has_tag_name((WORD_MAIN_NS, "pict")) => {}
2347 [node] if node.has_tag_name((WORD_MAIN_NS, "r")) => {
2348 let children: Vec<_> = node.children().filter(|child| child.is_element()).collect();
2349 if children
2350 .iter()
2351 .filter(|child| child.has_tag_name((WORD_MAIN_NS, "pict")))
2352 .count()
2353 != 1
2354 || children.iter().any(|child| {
2355 !child.has_tag_name((WORD_MAIN_NS, "pict"))
2356 && !child.has_tag_name((WORD_MAIN_NS, "rPr"))
2357 })
2358 {
2359 return None;
2360 }
2361 }
2362 _ => {
2363 if top.is_empty()
2364 || top.iter().any(|node| {
2365 !node.has_tag_name((VML_NS, "group"))
2366 && !node.has_tag_name((VML_NS, "shapetype"))
2367 })
2368 {
2369 return None;
2370 }
2371 }
2372 };
2373 let groups: Vec<_> = document
2374 .descendants()
2375 .filter(|node| {
2376 node.has_tag_name((VML_NS, "group"))
2377 && !node
2378 .ancestors()
2379 .skip(1)
2380 .any(|parent| parent.has_tag_name((VML_NS, "group")))
2381 })
2382 .collect();
2383 if groups.len() != 1 {
2384 return None;
2385 }
2386 let root = groups[0];
2387 let (source_width, source_height, source_anchor) = vml_source_frame(root)?;
2388 let direct_frame_matches = vml_paint_matches_source(
2389 image,
2390 svg.root_element(),
2391 root,
2392 source_width,
2393 source_height,
2394 frame_width,
2395 frame_height,
2396 ) && source_anchor == image.floating;
2397 if !direct_frame_matches
2398 && !vml_floating_paint_matches_source(
2399 image,
2400 svg.root_element(),
2401 source,
2402 owner_part_name,
2403 relationships,
2404 package,
2405 )
2406 {
2407 return None;
2408 }
2409 for attribute in document.descendants().flat_map(|node| node.attributes()) {
2410 if attribute.namespace() == Some(OFFICE_REL_NS) {
2411 if !matches!(attribute.name(), "id" | "embed") {
2412 return None;
2413 }
2414 let relationship = relationships
2415 .iter()
2416 .find(|relationship| relationship.id == attribute.value())?;
2417 if relationship.r#type != format!("{OFFICE_REL_NS}/image")
2418 || relationship.target_mode.is_some()
2419 || !package
2420 .binary_assets
2421 .contains_key(&crate::parse::resolve_part_path(
2422 owner_part_name,
2423 &relationship.target,
2424 ))
2425 {
2426 return None;
2427 }
2428 }
2429 }
2430 if image.paint_bounds.is_some() || !direct_frame_matches {
2431 let source_bitmaps: Vec<_> = root
2432 .descendants()
2433 .filter(|node| node.has_tag_name((VML_NS, "imagedata")))
2434 .collect();
2435 let rendered_bitmaps: Vec<_> = svg
2436 .descendants()
2437 .filter(|node| node.has_tag_name(("http://www.w3.org/2000/svg", "image")))
2438 .collect();
2439 if source_bitmaps.len() != rendered_bitmaps.len() {
2440 return None;
2441 }
2442 let mut remaining: Vec<_> = source_bitmaps
2443 .iter()
2444 .map(|bitmap| {
2445 let id = bitmap.attribute((OFFICE_REL_NS, "id"))?;
2446 let relationship = relationships
2447 .iter()
2448 .find(|relationship| relationship.id == id)?;
2449 package.binary_assets.get(&crate::parse::resolve_part_path(
2450 owner_part_name,
2451 &relationship.target,
2452 ))
2453 })
2454 .collect::<Option<Vec<_>>>()?;
2455 for bitmap in rendered_bitmaps {
2456 let (_, bytes) = decode_data_uri(bitmap.attribute("href")?)?;
2457 let index = remaining
2458 .iter()
2459 .position(|source| source.as_slice() == bytes.as_slice())?;
2460 remaining.swap_remove(index);
2461 }
2462 }
2463 let stories: Vec<_> = root
2464 .descendants()
2465 .filter(|node| node.has_tag_name((WORD_MAIN_NS, "txbxContent")))
2466 .collect();
2467 let content = image.content_nodes.as_ref()?;
2468 if stories.is_empty() {
2469 return None;
2470 }
2471 let mut cursor = 0;
2472 let mut story_texts = Vec::with_capacity(stories.len());
2473 let mut plans = Vec::with_capacity(stories.len());
2474 let mut bookmark_ids = std::collections::HashSet::new();
2475 for story in stories {
2476 if story
2477 .ancestors()
2478 .skip(1)
2479 .any(|node| node.has_tag_name((WORD_MAIN_NS, "txbxContent")))
2480 {
2481 return None;
2482 }
2483 let paragraphs: Vec<_> = story.children().filter(|node| node.is_element()).collect();
2484 if paragraphs.is_empty()
2485 || paragraphs
2486 .iter()
2487 .any(|node| !node.has_tag_name((WORD_MAIN_NS, "p")))
2488 {
2489 return None;
2490 }
2491 let mut paragraph_markers = Vec::with_capacity(paragraphs.len());
2492 for paragraph in ¶graphs {
2493 if paragraph
2494 .descendants()
2495 .filter(|node| node.is_element())
2496 .any(|node| {
2497 node.tag_name().namespace() != Some(WORD_MAIN_NS)
2498 || (matches!(node.tag_name().name(), "bookmarkStart" | "bookmarkEnd" | "proofErr")
2499 && node.parent() != Some(*paragraph))
2500 || matches!(
2501 node.tag_name().name(),
2502 "drawing"
2503 | "pict"
2504 | "object"
2505 | "txbxContent"
2506 | "fldChar"
2507 | "instrText"
2508 | "hyperlink"
2509 | "sdt"
2510 )
2511 })
2512 {
2513 return None;
2514 }
2515 for node in paragraph.children().filter(|node| node.is_element()) {
2516 if node.has_tag_name((WORD_MAIN_NS, "pPr")) {
2517 if node
2518 .descendants()
2519 .any(|child| child.has_tag_name((WORD_MAIN_NS, "sectPr")))
2520 {
2521 return None;
2522 }
2523 } else if node.has_tag_name((WORD_MAIN_NS, "r")) {
2524 if node
2525 .children()
2526 .filter(|child| child.is_element())
2527 .any(|child| {
2528 child.tag_name().namespace() != Some(WORD_MAIN_NS)
2529 || !matches!(
2530 child.tag_name().name(),
2531 "rPr" | "t" | "tab" | "br" | "cr"
2532 )
2533 })
2534 {
2535 return None;
2536 }
2537 } else if node.has_tag_name((WORD_MAIN_NS, "proofErr")) {
2538 if !matches!(node.attribute((WORD_MAIN_NS, "type")), Some("spellStart" | "spellEnd" | "gramStart" | "gramEnd"))
2539 || node.attributes().any(|attribute| attribute.namespace() != Some(WORD_MAIN_NS) || attribute.name() != "type")
2540 || node.children().any(|child| child.is_element() || (child.is_text() && child.text().is_some_and(|text| !text.trim().is_empty())))
2541 {
2542 return None;
2543 }
2544 } else if !node.has_tag_name((WORD_MAIN_NS, "bookmarkStart"))
2545 && !node.has_tag_name((WORD_MAIN_NS, "bookmarkEnd"))
2546 {
2547 return None;
2548 }
2549 }
2550 paragraph_markers.push(vml_paragraph_bookmarks(
2551 *paragraph,
2552 &wrapped,
2553 &mut bookmark_ids,
2554 )?);
2555 }
2556 let end = cursor + paragraphs.len();
2557 let typed = content.get(cursor..end)?;
2558 let mut texts = Vec::with_capacity(typed.len());
2559 for node in typed {
2560 let DocNode::Paragraph(paragraph) = node else {
2561 return None;
2562 };
2563 let mut text = String::new();
2564 for child in ¶graph.children {
2565 let ParagraphChildNode::Text(run) = child else {
2566 return None;
2567 };
2568 if run.note_reference.is_some() {
2569 return None;
2570 }
2571 text.push_str(&run.text);
2572 }
2573 texts.push(text);
2574 }
2575 story_texts.push(texts.join("\n"));
2576 plans.push((story.range(), cursor..end, paragraph_markers));
2577 cursor = end;
2578 }
2579 if cursor != content.len()
2580 || image.text_box_text.as_ref().is_some_and(|current| {
2581 story_texts
2582 .iter()
2583 .filter(|text| !text.is_empty())
2584 .cloned()
2585 .collect::<Vec<_>>()
2586 .join("\n")
2587 != *current
2588 })
2589 {
2590 return None;
2591 }
2592 let run_start = (top.len() == 1 && top[0].has_tag_name((WORD_MAIN_NS, "r")))
2593 .then(|| top[0].range().start - open.len());
2594 let is_pict = top.len() == 1 && top[0].has_tag_name((WORD_MAIN_NS, "pict"));
2595 let namespaces = document
2596 .root_element()
2597 .namespaces()
2598 .filter_map(|namespace| {
2599 namespace
2600 .name()
2601 .map(|prefix| (prefix.to_owned(), namespace.uri().to_owned()))
2602 })
2603 .collect();
2604 Some(VmlTextboxSourcePlan {
2605 wrapped,
2606 open_len: open.len(),
2607 paragraphs: plans,
2608 run_start,
2609 is_pict,
2610 namespaces,
2611 })
2612}
2613
2614fn edited_vml_textbox_run_xml(
2615 image: &ImageRunNode,
2616 state: &mut ImageSerializationState<'_>,
2617 run_id: i64,
2618) -> Option<String> {
2619 let plan = edited_vml_textbox_source_plan(
2620 image,
2621 &state.owner_part_name,
2622 &state.relationships,
2623 state.pkg,
2624 )?;
2625 let content = image.content_nodes.as_ref()?;
2626 let mut output = plan.wrapped;
2627 let mut next_run_id = run_id;
2628 for (range, nodes, paragraph_markers) in plan.paragraphs.into_iter().rev() {
2629 let mut replacement = format!("<w:txbxContent {VML_SOURCE_NAMESPACES}>");
2630 for (node, (before, after)) in content[nodes].iter().zip(paragraph_markers) {
2631 let DocNode::Paragraph(paragraph) = node else {
2632 unreachable!()
2633 };
2634 let mut current = paragraph.clone();
2635 current.source_xml = None;
2636 current.source_text_patch = None;
2637 current.source_run_provenance = None;
2638 current.source_paragraph_mark_style = None;
2639 current.source_paragraph_mark_formatting = None;
2640 current.source_paragraph_mark_properties_xml = None;
2641 let mut generated = paragraph_xml(¤t, state, &mut next_run_id);
2642 let before_offset = direct_property_children(&generated)
2643 .into_iter()
2644 .find(|(name, _)| name != "w:pPr")
2645 .map(|(_, range)| range.start)
2646 .unwrap_or("<w:p>".len());
2647 generated.insert_str(generated.len() - "</w:p>".len(), &after);
2648 generated.insert_str(before_offset, &before);
2649 replacement.push_str(&generated);
2650 }
2651 replacement.push_str("</w:txbxContent>");
2652 output.replace_range(range, &replacement);
2653 }
2654 let output = &output[plan.open_len..output.len() - "</root>".len()];
2655 if let Some(start) = plan.run_start {
2656 let end = opening_tag_end(output, start)?;
2657 let mut tag = output[start..end].to_string();
2658 for (prefix, uri) in plan.namespaces {
2659 tag = ensure_namespace(&tag, &prefix, &uri);
2660 }
2661 Some(format!("{}{}{}", &output[..start], tag, &output[end..]))
2662 } else if plan.is_pict {
2663 Some(format!("<w:r {VML_SOURCE_NAMESPACES}>{output}</w:r>"))
2664 } else {
2665 Some(format!(
2666 "<w:r {VML_SOURCE_NAMESPACES}><w:pict>{output}</w:pict></w:r>"
2667 ))
2668 }
2669}
2670
2671fn overflowing_paint_viewport(
2672 x: f64,
2673 y: f64,
2674 width: f64,
2675 height: f64,
2676 layout_width: f64,
2677 layout_height: f64,
2678) -> bool {
2679 let right = x + width;
2680 let bottom = y + height;
2681 x <= 0.0
2682 && y <= 0.0
2683 && right.is_finite()
2684 && bottom.is_finite()
2685 && right >= layout_width
2686 && bottom >= layout_height
2687 && (x < 0.0 || y < 0.0 || right > layout_width || bottom > layout_height)
2688}
2689
2690fn paint_png_bytes(image: &ImageRunNode) -> Option<Vec<u8>> {
2691 if !valid_image_paint_bounds(image)
2692 || image.paint_bounds.is_none()
2693 || image.part_name.is_some()
2694 || image.data.is_some()
2695 || image.crop.is_some()
2696 || image.css_filter.is_some()
2697 || image.css_opacity.is_some()
2698 || image
2699 .content_type
2700 .as_deref()
2701 .is_some_and(|mime| mime != "image/png")
2702 {
2703 return None;
2704 }
2705 let bounds = image.paint_bounds.as_deref()?;
2706 let width = image.width_px?;
2707 let height = image.height_px?;
2708 let scale_x = width / bounds.layout_width_px;
2709 let scale_y = height / bounds.layout_height_px;
2710 if !overflowing_paint_viewport(
2711 bounds.x_px,
2712 bounds.y_px,
2713 bounds.width_px,
2714 bounds.height_px,
2715 bounds.layout_width_px,
2716 bounds.layout_height_px,
2717 ) || !overflowing_paint_viewport(
2718 bounds.x_px * scale_x,
2719 bounds.y_px * scale_y,
2720 bounds.width_px * scale_x,
2721 bounds.height_px * scale_y,
2722 width,
2723 height,
2724 ) {
2725 return None;
2726 }
2727 let (mime, bytes) = decode_data_uri(image.src.as_deref()?)?;
2728 if mime != "image/png"
2729 || bytes.get(..8) != Some(b"\x89PNG\r\n\x1a\n")
2730 || bytes.get(8..16) != Some(b"\x00\x00\x00\x0dIHDR")
2731 || !bytes.ends_with(b"\x00\x00\x00\x00IEND\xae\x42\x60\x82")
2732 || u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?) == 0
2733 || u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?) == 0
2734 {
2735 return None;
2736 }
2737 Some(bytes)
2738}
2739
2740const 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>"#;
2741
2742fn inline_paint_png_run_xml(
2743 image: &ImageRunNode,
2744 state: &mut ImageSerializationState<'_>,
2745 run_id: i64,
2746) -> Option<String> {
2747 paint_png_bytes(image)?;
2748 let bounds = image.paint_bounds.as_deref()?;
2749 let (relationship, width, height, alt) = ensure_image_part_and_relationship(image, state)?;
2750 let scale_x = width / bounds.layout_width_px;
2751 let scale_y = height / bounds.layout_height_px;
2752 let x = bounds.x_px * scale_x;
2753 let y = bounds.y_px * scale_y;
2754 let paint_width = bounds.width_px * scale_x;
2755 let paint_height = bounds.height_px * scale_y;
2756 Some(format!(
2757 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>"##,
2758 width,
2759 height,
2760 x,
2761 y,
2762 paint_width,
2763 paint_height,
2764 escape_xml(&relationship),
2765 escape_xml(&alt),
2766 ))
2767}
2768
2769fn drawing_run_xml(
2770 image: &ImageRunNode,
2771 state: &mut ImageSerializationState<'_>,
2772 run_id: i64,
2773) -> String {
2774 if let Some((source, next_relationship_index)) = preserved_picture_run_xml(image, &state.owner_part_name, &state.relationships, state.pkg) {
2775 state.next_relationship_index = state.next_relationship_index.max(next_relationship_index);
2776 return source;
2777 }
2778 if let Some(source) = edited_vml_textbox_run_xml(image, state, run_id) {
2779 return source;
2780 }
2781 if image.paint_bounds.is_some() {
2782 return inline_paint_png_run_xml(image, state, run_id)
2783 .expect("Inline paint requires preserved source or a current PNG viewport; validate before serialization");
2784 }
2785 let Some((relationship_id, width_px, height_px, alt)) =
2786 ensure_image_part_and_relationship(image, state)
2787 else {
2788 return String::new();
2789 };
2790
2791 let cx = emu_from_px(Some(width_px), 240.0);
2792 let cy = emu_from_px(Some(height_px), 160.0);
2793
2794 if let Some(floating) = &image.floating {
2795 let dist_l = emu_from_px(floating.dist_l_px, 0.0);
2796 let dist_r = emu_from_px(floating.dist_r_px, 0.0);
2797 let dist_t = emu_from_px(floating.dist_t_px, 0.0);
2798 let dist_b = emu_from_px(floating.dist_b_px, 0.0);
2799 let relative_height = floating
2800 .z_index
2801 .filter(|value| *value >= 0)
2802 .unwrap_or(251_658_240);
2803 let horizontal_relative_to = floating
2804 .horizontal_relative_to
2805 .as_deref()
2806 .map(str::trim)
2807 .filter(|value| !value.is_empty())
2808 .unwrap_or("margin");
2809 let vertical_relative_to = floating
2810 .vertical_relative_to
2811 .as_deref()
2812 .map(str::trim)
2813 .filter(|value| !value.is_empty())
2814 .unwrap_or("paragraph");
2815 let position_h_xml = if let Some(horizontal_align) = floating.horizontal_align {
2816 format!(
2817 r#"<wp:positionH relativeFrom="{}"><wp:align>{}</wp:align></wp:positionH>"#,
2818 escape_xml(horizontal_relative_to),
2819 escape_xml(image_horizontal_align_str(horizontal_align))
2820 )
2821 } else {
2822 format!(
2823 r#"<wp:positionH relativeFrom="{}"><wp:posOffset>{}</wp:posOffset></wp:positionH>"#,
2824 escape_xml(horizontal_relative_to),
2825 emu_from_px(floating.x_px, 0.0)
2826 )
2827 };
2828 let position_v_xml = if let Some(vertical_align) = floating.vertical_align {
2829 format!(
2830 r#"<wp:positionV relativeFrom="{}"><wp:align>{}</wp:align></wp:positionV>"#,
2831 escape_xml(vertical_relative_to),
2832 escape_xml(image_vertical_align_str(vertical_align))
2833 )
2834 } else {
2835 format!(
2836 r#"<wp:positionV relativeFrom="{}"><wp:posOffset>{}</wp:posOffset></wp:positionV>"#,
2837 escape_xml(vertical_relative_to),
2838 emu_from_px(floating.y_px, 0.0)
2839 )
2840 };
2841 let wrap_xml = match floating.wrap_type {
2842 Some(ImageWrapType::WrapNone) => "<wp:wrapNone/>".to_string(),
2843 Some(ImageWrapType::TopAndBottom) => "<wp:wrapTopAndBottom/>".to_string(),
2844 Some(ImageWrapType::Tight) => format!(
2845 "<wp:wrapTight{}/>",
2846 image_wrap_text_attr(floating.wrap_text)
2847 ),
2848 Some(ImageWrapType::Through) => format!(
2849 "<wp:wrapThrough{}/>",
2850 image_wrap_text_attr(floating.wrap_text)
2851 ),
2852 Some(ImageWrapType::Square) => format!(
2853 "<wp:wrapSquare{}/>",
2854 image_wrap_text_attr(floating.wrap_text)
2855 ),
2856 None => "<wp:wrapNone/>".to_string(),
2857 };
2858
2859 return format!(
2860 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>"#,
2861 if floating.behind_document == Some(true) {
2862 1
2863 } else {
2864 0
2865 },
2866 escape_xml(&alt)
2867 );
2868 }
2869
2870 format!(
2871 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>"#,
2872 escape_xml(&alt)
2873 )
2874}
2875
2876fn code_point_hex_from_symbol(symbol: Option<&str>, fallback_hex: &str) -> String {
2877 let Some(symbol) = symbol else {
2878 return fallback_hex.to_string();
2879 };
2880 let Some(first) = symbol.chars().next() else {
2881 return fallback_hex.to_string();
2882 };
2883 let code_point = first as u32;
2884 if code_point == 0 {
2885 return fallback_hex.to_string();
2886 }
2887 format!("{:04X}", code_point)
2888}
2889
2890fn wrap_with_hyperlink_xml(
2891 xml: &str,
2892 link: Option<&str>,
2893 state: &mut ImageSerializationState<'_>,
2894) -> String {
2895 let normalized_link = link.map(str::trim).filter(|value| !value.is_empty());
2896 let Some(normalized_link) = normalized_link else {
2897 return xml.to_string();
2898 };
2899
2900 if let Some(anchor) = normalized_link.strip_prefix('#') {
2901 return format!(
2902 r#"<w:hyperlink w:anchor="{}">{}</w:hyperlink>"#,
2903 escape_xml(anchor),
2904 xml
2905 );
2906 }
2907
2908 let relationship_id = ensure_hyperlink_relationship(normalized_link, state);
2909 format!(r#"<w:hyperlink r:id="{relationship_id}">{xml}</w:hyperlink>"#)
2910}
2911
2912fn resolve_dropdown_display_value(field: &FormFieldRunNode) -> String {
2913 let selected = field.value.as_deref().map(str::trim).unwrap_or("");
2914 if selected.is_empty() {
2915 return field
2916 .options
2917 .as_ref()
2918 .and_then(|options| options.first())
2919 .map(|option| option.display_text.clone())
2920 .unwrap_or_default();
2921 }
2922
2923 if let Some(options) = &field.options {
2924 if let Some(matching_option) = options.iter().find(|option| {
2925 option.display_text.trim() == selected
2926 || option
2927 .value
2928 .as_deref()
2929 .map(str::trim)
2930 .filter(|value| !value.is_empty())
2931 .is_some_and(|value| value == selected)
2932 }) {
2933 return matching_option.display_text.clone();
2934 }
2935 }
2936
2937 selected.to_string()
2938}
2939
2940#[derive(Clone, Copy, PartialEq, Eq)]
2941enum LegacyTextInputType {
2942 Regular,
2943 Number,
2944 Date,
2945 CurrentDate,
2946 CurrentTime,
2947 Calculated,
2948}
2949
2950fn normalize_legacy_text_input_type(raw_value: Option<&str>) -> Option<LegacyTextInputType> {
2951 let raw_value = raw_value?;
2952 let normalized = raw_value.trim().to_ascii_lowercase();
2953 if normalized.is_empty() {
2954 return None;
2955 }
2956
2957 match normalized.as_str() {
2958 "regular" => Some(LegacyTextInputType::Regular),
2959 "number" => Some(LegacyTextInputType::Number),
2960 "date" => Some(LegacyTextInputType::Date),
2961 "currentdate" | "current_date" | "current-date" => Some(LegacyTextInputType::CurrentDate),
2962 "currenttime" | "current_time" | "current-time" => Some(LegacyTextInputType::CurrentTime),
2963 "calculated" | "calculation" => Some(LegacyTextInputType::Calculated),
2964 _ => None,
2965 }
2966}
2967
2968fn legacy_text_input_type_str(value: LegacyTextInputType) -> &'static str {
2969 match value {
2970 LegacyTextInputType::Regular => "regular",
2971 LegacyTextInputType::Number => "number",
2972 LegacyTextInputType::Date => "date",
2973 LegacyTextInputType::CurrentDate => "currentDate",
2974 LegacyTextInputType::CurrentTime => "currentTime",
2975 LegacyTextInputType::Calculated => "calculated",
2976 }
2977}
2978
2979fn legacy_on_off_tag_xml(tag_name: &str, value: Option<bool>) -> String {
2980 let Some(value) = value else {
2981 return String::new();
2982 };
2983 format!(
2984 r#"<w:{tag_name} w:val="{}"/>"#,
2985 if value { "1" } else { "0" }
2986 )
2987}
2988
2989fn legacy_form_field_run_xml(
2990 field: &FormFieldRunNode,
2991 state: &mut ImageSerializationState<'_>,
2992) -> String {
2993 let mut ff_data_fragments: Vec<String> = Vec::new();
2994 let widget = field.widget.as_ref();
2995
2996 if let Some(name) = widget.and_then(|w| w.name.as_deref()).map(str::trim) {
2997 if !name.is_empty() {
2998 ff_data_fragments.push(format!(r#"<w:name w:val="{}"/>"#, escape_xml(name)));
2999 }
3000 }
3001 ff_data_fragments.push(legacy_on_off_tag_xml(
3002 "enabled",
3003 widget.and_then(|w| w.enabled),
3004 ));
3005 ff_data_fragments.push(legacy_on_off_tag_xml(
3006 "calcOnExit",
3007 widget.and_then(|w| w.calc_on_exit),
3008 ));
3009
3010 let instruction;
3011 let display_value;
3012
3013 match field.field_type {
3014 FormFieldType::Checkbox => {
3015 instruction = "FORMCHECKBOX";
3016 let checked_symbol = field.checked_symbol.as_deref().unwrap_or("☒");
3017 let unchecked_symbol = field.unchecked_symbol.as_deref().unwrap_or("☐");
3018 display_value = if field.checked == Some(true) {
3019 checked_symbol.to_string()
3020 } else {
3021 unchecked_symbol.to_string()
3022 };
3023
3024 let mut checkbox_fragments: Vec<String> = Vec::new();
3025 checkbox_fragments.push(legacy_on_off_tag_xml(
3026 "default",
3027 widget
3028 .and_then(|w| w.checkbox.as_ref())
3029 .and_then(|c| c.default_checked),
3030 ));
3031 checkbox_fragments.push(legacy_on_off_tag_xml("checked", field.checked));
3032 if widget
3033 .and_then(|w| w.checkbox.as_ref())
3034 .and_then(|c| c.size_mode)
3035 == Some(FormFieldCheckboxSizeMode::Auto)
3036 {
3037 checkbox_fragments.push("<w:sizeAuto/>".to_string());
3038 } else if let Some(size_pt) = widget
3039 .and_then(|w| w.checkbox.as_ref())
3040 .and_then(|c| c.size_pt)
3041 {
3042 if size_pt.is_finite() && size_pt > 0.0 {
3043 checkbox_fragments.push(format!(
3044 r#"<w:size w:val="{}"/>"#,
3045 (size_pt * 2.0).round() as i64
3046 ));
3047 }
3048 }
3049 if checkbox_fragments.is_empty() {
3050 ff_data_fragments.push("<w:checkBox/>".to_string());
3051 } else {
3052 ff_data_fragments.push(format!(
3053 "<w:checkBox>{}</w:checkBox>",
3054 checkbox_fragments.join("")
3055 ));
3056 }
3057 }
3058 FormFieldType::Dropdown => {
3059 instruction = "FORMDROPDOWN";
3060 display_value = resolve_dropdown_display_value(field);
3061 let mut dropdown_fragments: Vec<String> = Vec::new();
3062 if let Some(options) = &field.options {
3063 for option in options {
3064 let display_text = option.display_text.trim();
3065 if display_text.is_empty() {
3066 continue;
3067 }
3068 dropdown_fragments.push(format!(
3069 r#"<w:listEntry w:val="{}"/>"#,
3070 escape_xml(display_text)
3071 ));
3072 }
3073 }
3074 if let Some(default_value) = widget
3075 .and_then(|w| w.dropdown.as_ref())
3076 .and_then(|d| d.default_value.as_deref())
3077 .map(str::trim)
3078 {
3079 if !default_value.is_empty() {
3080 dropdown_fragments.push(format!(
3081 r#"<w:default w:val="{}"/>"#,
3082 escape_xml(default_value)
3083 ));
3084 }
3085 }
3086 if dropdown_fragments.is_empty() {
3087 ff_data_fragments.push("<w:ddList/>".to_string());
3088 } else {
3089 ff_data_fragments.push(format!(
3090 "<w:ddList>{}</w:ddList>",
3091 dropdown_fragments.join("")
3092 ));
3093 }
3094 }
3095 _ => {
3096 instruction = "FORMTEXT";
3097 let text_widget = widget.and_then(|w| w.text.as_ref());
3098 let normalized_input_type =
3099 normalize_legacy_text_input_type(text_widget.and_then(|t| t.input_type.as_deref()));
3100 let current_value = field.value.as_deref();
3101 let has_current_value = current_value
3102 .map(str::trim)
3103 .is_some_and(|value| !value.is_empty());
3104 let configured_default_text = text_widget.and_then(|t| t.default_text.as_deref());
3105 let has_configured_default_text = configured_default_text
3106 .map(str::trim)
3107 .is_some_and(|value| !value.is_empty());
3108 let default_text = if normalized_input_type == Some(LegacyTextInputType::Number)
3109 && !has_current_value
3110 && !has_configured_default_text
3111 {
3112 Some("0".to_string())
3113 } else {
3114 configured_default_text.map(str::to_string)
3115 };
3116 display_value = current_value
3117 .map(str::to_string)
3118 .or(default_text.clone())
3119 .unwrap_or_else(|| {
3120 if normalized_input_type == Some(LegacyTextInputType::Number) {
3121 "0".to_string()
3122 } else {
3123 String::new()
3124 }
3125 });
3126
3127 let mut text_fragments: Vec<String> = Vec::new();
3128 if let Some(input_type) = normalized_input_type {
3129 text_fragments.push(format!(
3130 r#"<w:type w:val="{}"/>"#,
3131 escape_xml(legacy_text_input_type_str(input_type))
3132 ));
3133 }
3134 if let Some(default_text) = &default_text {
3135 text_fragments.push(format!(
3136 r#"<w:default w:val="{}"/>"#,
3137 escape_xml(default_text)
3138 ));
3139 }
3140 if let Some(max_length) = text_widget.and_then(|t| t.max_length) {
3141 if max_length >= 0 {
3142 text_fragments.push(format!(r#"<w:maxLength w:val="{max_length}"/>"#));
3143 }
3144 }
3145 let normalized_text_format = text_widget
3146 .and_then(|t| t.text_format.as_deref())
3147 .map(str::trim)
3148 .filter(|value| !value.is_empty());
3149 if let Some(format_value) = normalized_text_format {
3150 text_fragments.push(format!(
3151 r#"<w:format w:val="{}"/>"#,
3152 escape_xml(format_value)
3153 ));
3154 } else if normalized_input_type == Some(LegacyTextInputType::Number) {
3155 text_fragments.push(r#"<w:format w:val="0"/>"#.to_string());
3156 }
3157 if text_fragments.is_empty() {
3158 ff_data_fragments.push("<w:textInput/>".to_string());
3159 } else {
3160 ff_data_fragments.push(format!(
3161 "<w:textInput>{}</w:textInput>",
3162 text_fragments.join("")
3163 ));
3164 }
3165 }
3166 }
3167
3168 let ff_data_xml = format!("<w:ffData>{}</w:ffData>", ff_data_fragments.join(""));
3169 let result_run_xml = format!(
3170 "<w:r>{}</w:r>",
3171 format!(
3172 "{}{}",
3173 run_properties_xml(field.style.as_deref(), state),
3174 render_text_tokens(&display_value)
3175 )
3176 );
3177 let legacy_field_xml = format!(
3178 "{}{}{}{}{}",
3179 format!(r#"<w:r><w:fldChar w:fldCharType="begin">{ff_data_xml}</w:fldChar></w:r>"#),
3180 format!(r#"<w:r><w:instrText xml:space="preserve"> {instruction} </w:instrText></w:r>"#),
3181 r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
3182 result_run_xml,
3183 r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#
3184 );
3185
3186 wrap_with_hyperlink_xml(&legacy_field_xml, field.link.as_deref(), state)
3187}
3188
3189fn form_field_source_preview_is_stale(field: &FormFieldRunNode) -> bool {
3190 field.source_preview.as_ref().is_some_and(|preview| {
3191 let checked = field
3192 .checked
3193 .or_else(|| {
3194 field
3195 .widget
3196 .as_ref()
3197 .and_then(|widget| widget.checkbox.as_ref())
3198 .and_then(|checkbox| checkbox.default_checked)
3199 })
3200 .unwrap_or(false);
3201 field.field_type != FormFieldType::Checkbox
3202 || checked != preview.checked
3203 || field.source_xml.as_deref().map_or(true, str::is_empty)
3204 })
3205}
3206
3207fn paragraph_has_stale_form_field_preview(paragraph: &ParagraphNode) -> bool {
3208 paragraph.children.iter().any(|child| match child {
3209 ParagraphChildNode::FormField(field) => form_field_source_preview_is_stale(field),
3210 _ => false,
3211 })
3212}
3213
3214fn table_has_stale_form_field_preview(table: &TableNode) -> bool {
3215 table.rows.iter().any(|row| {
3216 row.cells.iter().any(|cell| {
3217 cell.nodes.iter().any(|node| match node {
3218 TableCellContentNode::Paragraph(paragraph) => {
3219 paragraph_has_stale_form_field_preview(paragraph)
3220 }
3221 TableCellContentNode::Table(table) => table_has_stale_form_field_preview(table),
3222 })
3223 })
3224 })
3225}
3226
3227fn form_field_run_xml(field: &FormFieldRunNode, state: &mut ImageSerializationState<'_>) -> String {
3228 if !form_field_source_preview_is_stale(field) {
3229 if let Some(source_xml) = &field.source_xml {
3230 return wrap_with_hyperlink_xml(source_xml, field.link.as_deref(), state);
3231 }
3232 }
3233
3234 if field.source_kind == Some(FormFieldSourceKind::Legacy) {
3235 return legacy_form_field_run_xml(field, state);
3236 }
3237
3238 let mut properties: Vec<String> = Vec::new();
3239 if let Some(id) = field.id {
3240 properties.push(format!(r#"<w:id w:val="{id}"/>"#));
3241 }
3242 if let Some(title) = field.title.as_deref().map(str::trim) {
3243 if !title.is_empty() {
3244 properties.push(format!(r#"<w:alias w:val="{}"/>"#, escape_xml(title)));
3245 }
3246 }
3247 if let Some(tag) = field.tag.as_deref().map(str::trim) {
3248 if !tag.is_empty() {
3249 properties.push(format!(r#"<w:tag w:val="{}"/>"#, escape_xml(tag)));
3250 }
3251 }
3252 if let Some(placeholder) = field.placeholder.as_deref().map(str::trim) {
3253 if !placeholder.is_empty() {
3254 properties.push(format!(
3255 r#"<w:placeholder><w:docPart w:val="{}"/></w:placeholder>"#,
3256 escape_xml(placeholder)
3257 ));
3258 }
3259 }
3260
3261 let display_value;
3262 match field.field_type {
3263 FormFieldType::Checkbox => {
3264 let checked_symbol = field.checked_symbol.as_deref().unwrap_or("☒");
3265 let unchecked_symbol = field.unchecked_symbol.as_deref().unwrap_or("☐");
3266 let checked_hex = code_point_hex_from_symbol(field.checked_symbol.as_deref(), "2612");
3267 let unchecked_hex =
3268 code_point_hex_from_symbol(field.unchecked_symbol.as_deref(), "2610");
3269 properties.push(format!(
3270 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>"#,
3271 if field.checked == Some(true) { "1" } else { "0" }
3272 ));
3273 display_value = if field.checked == Some(true) {
3274 checked_symbol.to_string()
3275 } else {
3276 unchecked_symbol.to_string()
3277 };
3278 }
3279 FormFieldType::Dropdown => {
3280 let options_xml = field
3281 .options
3282 .as_ref()
3283 .map(|options| {
3284 options
3285 .iter()
3286 .filter_map(|option| {
3287 let display_text = option.display_text.trim();
3288 if display_text.is_empty() {
3289 return None;
3290 }
3291 let value = option
3292 .value
3293 .as_deref()
3294 .map(str::trim)
3295 .filter(|value| !value.is_empty())
3296 .unwrap_or(display_text);
3297 Some(format!(
3298 r#"<w:listItem w:displayText="{}" w:value="{}"/>"#,
3299 escape_xml(display_text),
3300 escape_xml(value)
3301 ))
3302 })
3303 .collect::<String>()
3304 })
3305 .unwrap_or_default();
3306 let last_value = field
3307 .value
3308 .as_deref()
3309 .map(str::trim)
3310 .filter(|v| !v.is_empty());
3311 properties.push(format!(
3312 "<w:dropDownList>{options_xml}{}</w:dropDownList>",
3313 last_value
3314 .map(|value| format!(r#"<w:lastValue w:val="{}"/>"#, escape_xml(value)))
3315 .unwrap_or_default()
3316 ));
3317 display_value = resolve_dropdown_display_value(field);
3318 }
3319 FormFieldType::Date => {
3320 let normalized_date = field
3321 .value
3322 .as_deref()
3323 .map(str::trim)
3324 .filter(|v| !v.is_empty());
3325 properties.push(format!(
3326 "<w:date>{}</w:date>",
3327 normalized_date
3328 .map(|value| format!(r#"<w:fullDate w:val="{}"/>"#, escape_xml(value)))
3329 .unwrap_or_default()
3330 ));
3331 display_value = normalized_date.unwrap_or("").to_string();
3332 }
3333 FormFieldType::Text => {
3334 properties.push("<w:text/>".to_string());
3335 display_value = field.value.clone().unwrap_or_default();
3336 }
3337 }
3338
3339 let properties_xml = if properties.is_empty() {
3340 "<w:sdtPr/>".to_string()
3341 } else {
3342 format!("<w:sdtPr>{}</w:sdtPr>", properties.join(""))
3343 };
3344 let content_xml = format!(
3345 "<w:sdtContent><w:r>{}{}</w:r></w:sdtContent>",
3346 run_properties_xml(field.style.as_deref(), state),
3347 render_text_tokens(&display_value)
3348 );
3349 let sdt_xml = format!("<w:sdt>{properties_xml}{content_xml}</w:sdt>");
3350 wrap_with_hyperlink_xml(&sdt_xml, field.link.as_deref(), state)
3351}
3352
3353fn source_run_text(run_xml: &str) -> Option<String> {
3354 let tokens = crate::parse::parse_run_text_tokens(run_xml);
3355 if tokens.len() != 1 || tokens[0].note_reference.is_some() {
3356 return None;
3357 }
3358
3359 let text_ranges = extract_balanced_tag_ranges(run_xml, "w:t");
3360 if text_ranges.len() != 1 {
3361 return None;
3362 }
3363
3364 Some(tokens[0].text.clone())
3365}
3366
3367fn ensure_xml_space_preserve(opening_tag: &str) -> String {
3368 let lower = opening_tag.to_ascii_lowercase();
3369 let needle = "xml:space";
3370 let mut search_from = 0usize;
3371
3372 while let Some(relative_start) = lower[search_from..].find(needle) {
3373 let attribute_start = search_from + relative_start;
3374 let before_is_boundary = attribute_start == 0
3375 || opening_tag.as_bytes()[attribute_start - 1].is_ascii_whitespace();
3376 let mut cursor = attribute_start + needle.len();
3377 while opening_tag
3378 .as_bytes()
3379 .get(cursor)
3380 .is_some_and(|byte| byte.is_ascii_whitespace())
3381 {
3382 cursor += 1;
3383 }
3384 if before_is_boundary && opening_tag.as_bytes().get(cursor) == Some(&b'=') {
3385 cursor += 1;
3386 while opening_tag
3387 .as_bytes()
3388 .get(cursor)
3389 .is_some_and(|byte| byte.is_ascii_whitespace())
3390 {
3391 cursor += 1;
3392 }
3393 let quote = opening_tag.as_bytes().get(cursor).copied();
3394 if quote == Some(b'\'') || quote == Some(b'"') {
3395 let quote = quote.unwrap_or(b'"');
3396 let value_start = cursor + 1;
3397 let value_end = opening_tag.as_bytes()[value_start..]
3398 .iter()
3399 .position(|byte| *byte == quote)
3400 .map(|offset| value_start + offset);
3401 if let Some(value_end) = value_end {
3402 let mut updated = opening_tag.to_string();
3403 updated.replace_range(value_start..value_end, "preserve");
3404 return updated;
3405 }
3406 }
3407 }
3408 search_from = attribute_start + needle.len();
3409 }
3410
3411 let mut updated = opening_tag.to_string();
3412 updated.insert_str(updated.len() - 1, r#" xml:space="preserve""#);
3413 updated
3414}
3415
3416fn replace_source_run_text(run_xml: &str, text: &str) -> Option<String> {
3417 let text_range = extract_balanced_tag_ranges(run_xml, "w:t")
3418 .into_iter()
3419 .next()?;
3420 let source_text_tag = &run_xml[text_range.start..text_range.end];
3421
3422 let replacement = if text.contains(['\n', '\t']) || source_text_tag.trim_end().ends_with("/>") {
3423 render_text_tokens(text)
3424 } else {
3425 let opening_end = source_text_tag.find('>')?;
3426 let closing_start = source_text_tag.rfind("</w:t")?;
3427 if closing_start < opening_end {
3428 return None;
3429 }
3430
3431 let mut opening_tag = source_text_tag[..=opening_end].to_string();
3432 if should_preserve_whitespace(text) {
3433 opening_tag = ensure_xml_space_preserve(&opening_tag);
3434 }
3435 format!(
3436 "{opening_tag}{}{closing_tag}",
3437 escape_xml(text),
3438 closing_tag = &source_text_tag[closing_start..]
3439 )
3440 };
3441
3442 let mut patched = String::with_capacity(run_xml.len() + replacement.len());
3443 patched.push_str(&run_xml[..text_range.start]);
3444 patched.push_str(&replacement);
3445 patched.push_str(&run_xml[text_range.end..]);
3446 Some(patched)
3447}
3448
3449fn source_text_patch_xml_is_safe(source_xml: &str) -> bool {
3455 let lower = source_xml.to_ascii_lowercase();
3456 [
3457 "<w:fldchar",
3458 "<w:fldsimple",
3459 "<w:instrtext",
3460 "<w:tab",
3461 "<w:br",
3462 "<w:cr",
3463 "<w:footnotereference",
3464 "<w:endnotereference",
3465 "<w:drawing",
3466 "<w:pict",
3467 "<w:object",
3468 "<w:sdt",
3469 "<w:ins",
3470 "<w:del",
3471 "<w:conflictins",
3472 "<w:conflictdel",
3473 "<w:movefrom",
3474 "<w:moveto",
3475 "<w:commentrangestart",
3476 "<w:commentrangeend",
3477 "<w:commentreference",
3478 "<w:bookmarkstart",
3479 "<w:bookmarkend",
3480 "<w:customxml",
3481 "<w:smarttag",
3482 "<w:permstart",
3483 "<w:permend",
3484 "<w:prooferr",
3485 "<w:subdoc",
3486 "<w:altchunk",
3487 "<mc:alternatecontent",
3488 ]
3489 .iter()
3490 .all(|marker| !lower.contains(marker))
3491}
3492
3493fn patch_source_paragraph_text(
3494 paragraph: &ParagraphNode,
3495 source_xml: &str,
3496 patch_plan: &ParagraphSourceTextPatch,
3497) -> Option<String> {
3498 if !source_text_patch_xml_is_safe(source_xml) {
3499 return None;
3500 }
3501 let current_runs = paragraph
3502 .children
3503 .iter()
3504 .map(|child| match child {
3505 ParagraphChildNode::Text(run) => Some(run),
3506 _ => None,
3507 })
3508 .collect::<Option<Vec<_>>>()?;
3509 let source_ranges = extract_balanced_tag_ranges(source_xml, "w:r");
3510 if source_ranges.len() != current_runs.len() || patch_plan.runs.len() != current_runs.len() {
3511 return None;
3512 }
3513 if current_runs
3514 .iter()
3515 .zip(&patch_plan.runs)
3516 .any(|(current, expected)| {
3517 current.style != expected.style
3518 || current.link != expected.link
3519 || current.note_reference != expected.note_reference
3520 })
3521 {
3522 return None;
3523 }
3524
3525 let source_text = source_ranges
3526 .iter()
3527 .flat_map(|range| crate::parse::parse_run_text_tokens(&source_xml[range.start..range.end]))
3528 .map(|token| token.text)
3529 .collect::<String>();
3530 let current_text = current_runs
3531 .iter()
3532 .map(|run| run.text.as_str())
3533 .collect::<String>();
3534
3535 if source_text == current_text {
3536 return Some(source_xml.to_string());
3537 }
3538 let mut replacements = Vec::with_capacity(source_ranges.len());
3539 for (range, current_run) in source_ranges.iter().zip(current_runs) {
3540 let source_run_xml = &source_xml[range.start..range.end];
3541 let original_text = source_run_text(source_run_xml)?;
3542 if original_text == current_run.text {
3543 continue;
3544 }
3545 replacements.push((
3546 range.start,
3547 range.end,
3548 replace_source_run_text(source_run_xml, ¤t_run.text)?,
3549 ));
3550 }
3551
3552 let mut patched = source_xml.to_string();
3553 for (start, end, replacement) in replacements.into_iter().rev() {
3554 patched.replace_range(start..end, &replacement);
3555 }
3556 Some(patched)
3557}
3558
3559const PARAGRAPH_MARK_TAIL_ORDER: &[&str] = &["w:rPr", "w:sectPr", "w:pPrChange"];
3560const MARK_PROPERTY_ORDER: &[&str] = &[
3561 "w:ins",
3562 "w:del",
3563 "w:moveFrom",
3564 "w:moveTo",
3565 "w14:conflictIns",
3566 "w14:conflictDel",
3567 "w:rStyle",
3568 "w:rFonts",
3569 "w:b",
3570 "w:bCs",
3571 "w:i",
3572 "w:iCs",
3573 "w:caps",
3574 "w:smallCaps",
3575 "w:strike",
3576 "w:dStrike",
3577 "w:outline",
3578 "w:shadow",
3579 "w:emboss",
3580 "w:imprint",
3581 "w:noProof",
3582 "w:snapToGrid",
3583 "w:vanish",
3584 "w:webHidden",
3585 "w:color",
3586 "w:spacing",
3587 "w:w",
3588 "w:kern",
3589 "w:position",
3590 "w:sz",
3591 "w:szCs",
3592 "w:highlight",
3593 "w:u",
3594 "w:effect",
3595 "w:bdr",
3596 "w:shd",
3597 "w:fitText",
3598 "w:vertAlign",
3599 "w:rtl",
3600 "w:cs",
3601 "w:em",
3602 "w:lang",
3603 "w:eastAsianLayout",
3604 "w:specVanish",
3605 "w:oMath",
3606 "w14:glow",
3607 "w14:shadow",
3608 "w14:reflection",
3609 "w14:textOutline",
3610 "w14:textFill",
3611 "w14:scene3d",
3612 "w14:props3d",
3613 "w14:ligatures",
3614 "w14:numForm",
3615 "w14:numSpacing",
3616 "w14:stylisticSets",
3617 "w14:cntxtAlts",
3618 "w:rPrChange",
3619];
3620
3621fn direct_property_children(xml: &str) -> Vec<(String, crate::xml::TagRange)> {
3622 crate::xml::extract_direct_child_tag_ranges(xml)
3623 .into_iter()
3624 .map(|range| {
3625 (
3626 range.tag_name,
3627 crate::xml::TagRange {
3628 start: range.start,
3629 end: range.end,
3630 },
3631 )
3632 })
3633 .collect()
3634}
3635
3636const MARK_MANAGED_PROPERTIES: &[&str] = &[
3637 "w:rFonts",
3638 "w:b",
3639 "w:i",
3640 "w:u",
3641 "w:strike",
3642 "w:caps",
3643 "w:smallCaps",
3644 "w:color",
3645 "w:highlight",
3646 "w:shd",
3647 "w:sz",
3648 "w:szCs",
3649 "w:lang",
3650 "w:rtl",
3651 "w:cs",
3652 "w:spacing",
3653 "w:w",
3654 "w:kern",
3655 "w:vertAlign",
3656 "w:bdr",
3657 "w14:shadow",
3658 "w14:textOutline",
3659 "w14:ligatures",
3660 "w14:cntxtAlts",
3661];
3662
3663fn property_attributes(tag_name: &str) -> &'static [&'static str] {
3664 match tag_name {
3665 "w:rFonts" => &[
3666 "w:ascii",
3667 "w:hAnsi",
3668 "w:eastAsia",
3669 "w:cs",
3670 "w:asciiTheme",
3671 "w:hAnsiTheme",
3672 "w:eastAsiaTheme",
3673 "w:csTheme",
3674 "w:hint",
3675 ],
3676 "w:lang" => &["w:val", "w:eastAsia", "w:bidi"],
3677 "w:shd" => &["w:val", "w:color", "w:fill"],
3678 "w:bdr" => &["w:val", "w:color", "w:sz", "w:space", "w:frame", "w:shadow"],
3679 "w14:ligatures" | "w14:cntxtAlts" => &["w14:val"],
3680 _ => &["w:val"],
3681 }
3682}
3683
3684fn remove_xml_attribute(tag: &str, name: &str) -> String {
3685 let Some((value_start, value_end)) = attribute_value_range(tag, name) else {
3686 return tag.to_string();
3687 };
3688 let mut start = value_start.saturating_sub(1);
3689 while start > 0
3690 && (tag.as_bytes()[start - 1].is_ascii_whitespace() || tag.as_bytes()[start - 1] == b'=')
3691 {
3692 start -= 1;
3693 }
3694 start = start.saturating_sub(name.len());
3695 while start > 0 && tag.as_bytes()[start - 1].is_ascii_whitespace() {
3696 start -= 1;
3697 }
3698 let mut updated = tag.to_string();
3699 updated.replace_range(start..value_end + 1, "");
3700 updated
3701}
3702
3703fn insert_property_child(
3704 xml: &str,
3705 container: &str,
3706 name: &str,
3707 child: &str,
3708 order: &[&str],
3709) -> String {
3710 let open_end = opening_tag_end(xml, 0).unwrap_or(xml.len());
3711 let opening = &xml[..open_end];
3712 if opening.trim_end().ends_with("/>") {
3713 return format!(
3714 "{}>{child}</{container}>",
3715 opening
3716 .trim_end()
3717 .trim_end_matches('>')
3718 .trim_end()
3719 .trim_end_matches('/')
3720 .trim_end()
3721 );
3722 }
3723 let mut insert_at = xml.rfind(&format!("</{container}>")).unwrap_or(xml.len());
3724 if let Some(rank) = order.iter().position(|value| *value == name) {
3725 for (other_name, range) in direct_property_children(xml) {
3726 if order
3727 .iter()
3728 .position(|value| *value == other_name)
3729 .is_some_and(|other_rank| other_rank > rank)
3730 {
3731 insert_at = insert_at.min(range.start);
3732 }
3733 }
3734 }
3735 let mut updated = xml.to_string();
3736 updated.insert_str(insert_at, child);
3737 updated
3738}
3739
3740fn merge_mark_properties_xml(source: &str, generated: &str) -> String {
3741 let source_children = direct_property_children(source);
3742 let generated = generated.to_string();
3743 let generated_children = direct_property_children(&generated);
3744 let mut consumed = std::collections::HashSet::new();
3745 let mut replacements = Vec::new();
3746 for (name, range) in source_children {
3747 if !MARK_MANAGED_PROPERTIES.contains(&name.as_str()) {
3748 continue;
3749 }
3750 let block = &source[range.start..range.end];
3751 if matches!(name.as_str(), "w14:shadow" | "w14:textOutline")
3752 && !crate::parse::text_effects::supports(block, &name)
3753 {
3754 continue;
3755 }
3756 let replacement = generated_children
3757 .iter()
3758 .enumerate()
3759 .find(|(index, (other_name, _))| !consumed.contains(index) && *other_name == name);
3760 if let Some((index, (_, generated_range))) = replacement {
3761 consumed.insert(index);
3762 let generated_block = &generated[generated_range.start..generated_range.end];
3763 let merged = if matches!(name.as_str(), "w14:shadow" | "w14:textOutline") {
3764 generated_block.to_string()
3765 } else {
3766 let end = opening_tag_end(block, 0).unwrap_or(block.len());
3767 let generated_end =
3768 opening_tag_end(generated_block, 0).unwrap_or(generated_block.len());
3769 let mut opening = block[..end].to_string();
3770 for attribute in property_attributes(&name) {
3771 opening = if let Some(value) =
3772 get_attribute(&generated_block[..generated_end], attribute)
3773 {
3774 set_xml_attribute(&opening, attribute, &value)
3775 } else {
3776 remove_xml_attribute(&opening, attribute)
3777 };
3778 }
3779 format!("{opening}{}", &block[end..])
3780 };
3781 replacements.push((range.start, range.end, merged));
3782 } else {
3783 replacements.push((range.start, range.end, String::new()));
3784 }
3785 }
3786 let mut updated = replace_xml_ranges(source, replacements);
3787 for (index, (name, range)) in generated_children.iter().enumerate() {
3788 if !consumed.contains(&index) {
3789 updated = insert_property_child(
3790 &updated,
3791 "w:rPr",
3792 name,
3793 &generated[range.start..range.end],
3794 MARK_PROPERTY_ORDER,
3795 );
3796 }
3797 }
3798 updated
3799}
3800
3801fn paragraph_mark_properties_xml(style: Option<&TextStyle>, state: &ImageSerializationState<'_>) -> String {
3802 let order = MARK_PROPERTY_ORDER;
3803 let mut generated = run_properties_xml(style, state);
3804 if generated.is_empty() {
3805 generated = "<w:rPr/>".to_string();
3806 }
3807 if let Some(style) = style {
3808 for (name, value) in [
3809 ("b", style.bold),
3810 ("i", style.italic),
3811 ("strike", style.strike),
3812 ] {
3813 if value == Some(false) {
3814 generated = insert_property_child(
3815 &generated,
3816 "w:rPr",
3817 &format!("w:{name}"),
3818 &on_off_element_xml(name, value),
3819 order,
3820 );
3821 }
3822 }
3823 if style.underline == Some(false) {
3824 generated =
3825 insert_property_child(&generated, "w:rPr", "w:u", r#"<w:u w:val="none"/>"#, order);
3826 }
3827 }
3828 merge_mark_properties_xml("<w:rPr/>", &generated)
3829}
3830
3831fn paragraph_mark_xml(paragraph: &ParagraphNode, source: Option<&str>, state: &ImageSerializationState<'_>) -> String {
3832 if paragraph.paragraph_mark_style == paragraph.source_paragraph_mark_style
3833 && (source.is_some() || paragraph.source_paragraph_mark_style.is_some())
3834 {
3835 return source.unwrap_or_default().to_string();
3836 }
3837 if paragraph.paragraph_mark_style.is_none() && source.is_none() {
3838 return String::new();
3839 }
3840 let generated = paragraph_mark_properties_xml(paragraph.paragraph_mark_style.as_deref(), state);
3841 source
3842 .map(|source| merge_mark_properties_xml(source, &generated))
3843 .unwrap_or(generated)
3844}
3845
3846fn serialized_paragraph_snap_to_grid(
3847 style: &ParagraphStyle,
3848 state: &ImageSerializationState<'_>,
3849) -> Option<bool> {
3850 let effective = style.snap_to_grid.or(style.source_inherited_snap_to_grid);
3851 let style_id = style.style_id.clone().or_else(|| {
3852 style
3853 .heading_level
3854 .map(|level| format!("Heading{}", heading_level_number(level)))
3855 });
3856 let inherited = style_id
3857 .as_ref()
3858 .and_then(|id| state.paragraph_style_grid_owners.get(id).copied())
3859 .or(state.current_table_snap_to_grid)
3860 .or_else(|| {
3861 style_id
3862 .as_ref()
3863 .and_then(|id| state.paragraph_snap_to_grid_by_style_id.get(id).copied())
3864 })
3865 .or(state.default_paragraph_snap_to_grid);
3866 let inherited_only = style.snap_to_grid.is_none()
3867 || (style.source_has_snap_to_grid == Some(false)
3868 && style.snap_to_grid == style.source_snap_to_grid);
3869 if inherited_only && inherited == effective {
3870 None
3871 } else {
3872 effective
3873 }
3874}
3875
3876fn serialized_paragraph_text_alignment(
3877 style: &ParagraphStyle,
3878 state: &ImageSerializationState<'_>,
3879) -> Option<ParagraphTextAlignment> {
3880 let effective = style
3881 .text_alignment
3882 .or(style.source_inherited_text_alignment);
3883 let style_id = style.style_id.clone().or_else(|| {
3884 style
3885 .heading_level
3886 .map(|level| format!("Heading{}", heading_level_number(level)))
3887 });
3888 let inherited = style_id
3889 .as_ref()
3890 .and_then(|id| state.paragraph_style_alignment_owners.get(id).copied())
3891 .or(state.current_table_text_alignment)
3892 .or_else(|| {
3893 style_id
3894 .as_ref()
3895 .and_then(|id| state.paragraph_text_alignment_by_style_id.get(id).copied())
3896 })
3897 .or(state.default_paragraph_text_alignment);
3898 let inherited_only = style.text_alignment.is_none()
3899 || (style.source_has_text_alignment == Some(false)
3900 && style.text_alignment == style.source_text_alignment);
3901 if inherited_only && inherited == effective {
3902 None
3903 } else {
3904 effective
3905 }
3906}
3907
3908struct TableAlignmentContext {
3909 style_id: Option<String>,
3910 look: crate::parse::ParsedTableLook,
3911 grid_bound: i64,
3912 columns: i64,
3913 rows: i64,
3914}
3915
3916fn resolve_table_alignment_context(
3917 table: &TableNode,
3918 state: &ImageSerializationState<'_>,
3919 source_xml: Option<&str>,
3920) -> TableAlignmentContext {
3921 let style_id = table
3922 .style
3923 .as_ref()
3924 .and_then(|style| style.style_id.clone())
3925 .or_else(|| {
3926 if table
3927 .style
3928 .as_ref()
3929 .is_some_and(|style| style.source_style_id.is_some())
3930 {
3931 return None;
3932 }
3933 source_xml
3934 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblPr"))
3935 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblStyle"))
3936 .and_then(|tag| get_attribute(tag, "w:val"))
3937 });
3938 let inherited_look = style_id
3939 .as_ref()
3940 .and_then(|id| state.table_styles.get(id))
3941 .and_then(|style| {
3942 style
3943 .conditions
3944 .get(&crate::parse::TableConditionalStyleType::WholeTable)
3945 })
3946 .and_then(|condition| condition.table_look.clone());
3947 let direct_look = source_xml
3948 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblPr"))
3949 .and_then(|xml| crate::parse::direct_table_property(xml, "w:tblLook"))
3950 .and_then(|xml| crate::parse::parse_table_look(Some(xml)));
3951 let look = direct_look
3952 .or(inherited_look)
3953 .unwrap_or_else(crate::parse::default_table_look);
3954 let grid_bound = crate::parse::table_grid_column_bound(table);
3955 let columns = table
3956 .rows
3957 .iter()
3958 .map(|row| {
3959 row.cells
3960 .iter()
3961 .fold(0i64, |total, cell| {
3962 total.saturating_add(
3963 cell.style
3964 .as_ref()
3965 .and_then(|style| style.grid_span)
3966 .unwrap_or(1)
3967 .max(1),
3968 )
3969 })
3970 .saturating_add(crate::parse::resolve_table_grid_skip_count(
3971 row.style.as_ref().and_then(|style| style.grid_before),
3972 grid_bound,
3973 ))
3974 .saturating_add(crate::parse::resolve_table_grid_skip_count(
3975 row.style.as_ref().and_then(|style| style.grid_after),
3976 grid_bound,
3977 ))
3978 })
3979 .max()
3980 .unwrap_or(0)
3981 .max(grid_bound)
3982 .max(1);
3983 TableAlignmentContext {
3984 style_id,
3985 look,
3986 grid_bound,
3987 columns,
3988 rows: table.rows.len() as i64,
3989 }
3990}
3991
3992fn table_cell_text_alignment(
3993 context: &TableAlignmentContext,
3994 row: usize,
3995 column: i64,
3996 span: i64,
3997 state: &ImageSerializationState<'_>,
3998) -> Option<ParagraphTextAlignment> {
3999 let style = context
4000 .style_id
4001 .as_ref()
4002 .and_then(|id| state.table_styles.get(id))?;
4003 crate::parse::resolve_table_condition_for_cell(
4004 style,
4005 &context.look,
4006 row as i64,
4007 context.rows,
4008 column,
4009 column + span - 1,
4010 context.columns,
4011 )
4012 .and_then(|condition| condition.paragraph_text_alignment)
4013}
4014
4015fn table_cell_snap_to_grid(
4016 context: &TableAlignmentContext,
4017 row: usize,
4018 column: i64,
4019 span: i64,
4020 state: &ImageSerializationState<'_>,
4021) -> Option<bool> {
4022 let style = context
4023 .style_id
4024 .as_ref()
4025 .and_then(|id| state.table_styles.get(id))?;
4026 crate::parse::resolve_table_condition_for_cell(
4027 style,
4028 &context.look,
4029 row as i64,
4030 context.rows,
4031 column,
4032 column + span - 1,
4033 context.columns,
4034 )
4035 .and_then(|condition| condition.paragraph_snap_to_grid)
4036}
4037
4038fn cell_alignment_context_changed(
4039 cell: &TableCellNode,
4040 state: &ImageSerializationState<'_>,
4041) -> bool {
4042 cell.nodes.iter().any(|node| match node {
4043 TableCellContentNode::Paragraph(paragraph) => {
4044 paragraph.style.as_ref().is_some_and(|style| {
4045 (style.source_has_snap_to_grid == Some(false)
4046 && style.snap_to_grid == style.source_snap_to_grid
4047 && serialized_paragraph_snap_to_grid(style, state).is_some())
4048 || style.source_has_text_alignment == Some(false)
4049 && style.text_alignment == style.source_text_alignment
4050 && serialized_paragraph_text_alignment(style, state).is_some()
4051 })
4052 }
4053 TableCellContentNode::Table(table) => {
4054 table_alignment_context_changed(table, state, table.source_xml.as_deref())
4055 }
4056 })
4057}
4058
4059fn table_alignment_context_changed(
4060 table: &TableNode,
4061 state: &ImageSerializationState<'_>,
4062 source: Option<&str>,
4063) -> bool {
4064 let context = resolve_table_alignment_context(table, state, source);
4065 table.rows.iter().enumerate().any(|(row_index, row)| {
4066 let bound = context.grid_bound;
4067 let mut column = crate::parse::resolve_table_grid_skip_count(
4068 row.style.as_ref().and_then(|style| style.grid_before),
4069 bound,
4070 );
4071 row.cells.iter().any(|cell| {
4072 let span = cell
4073 .style
4074 .as_ref()
4075 .and_then(|style| style.grid_span)
4076 .unwrap_or(1)
4077 .max(1);
4078 let current = table_cell_text_alignment(&context, row_index, column, span, state);
4079 let current_snap = table_cell_snap_to_grid(&context, row_index, column, span, state);
4080 column += span;
4081 cell.nodes.iter().any(|node| match node {
4082 TableCellContentNode::Paragraph(paragraph) => {
4083 paragraph.style.as_ref().is_some_and(|style| {
4084 if style.source_has_snap_to_grid == Some(false)
4085 && style.snap_to_grid == style.source_snap_to_grid
4086 {
4087 let inherited = style
4088 .style_id
4089 .as_ref()
4090 .and_then(|id| state.paragraph_style_grid_owners.get(id).copied())
4091 .or(current_snap)
4092 .or_else(|| {
4093 style.style_id.as_ref().and_then(|id| {
4094 state.paragraph_snap_to_grid_by_style_id.get(id).copied()
4095 })
4096 })
4097 .or(state.default_paragraph_snap_to_grid);
4098 if inherited
4099 != style.snap_to_grid.or(style.source_inherited_snap_to_grid)
4100 {
4101 return true;
4102 }
4103 }
4104 if style.source_has_text_alignment != Some(false)
4105 || style.text_alignment != style.source_text_alignment
4106 {
4107 return false;
4108 }
4109 let style_id = style.style_id.as_ref();
4110 let inherited = style_id
4111 .and_then(|id| state.paragraph_style_alignment_owners.get(id).copied())
4112 .or(current)
4113 .or_else(|| {
4114 style_id.and_then(|id| {
4115 state.paragraph_text_alignment_by_style_id.get(id).copied()
4116 })
4117 })
4118 .or(state.default_paragraph_text_alignment);
4119 inherited
4120 != style
4121 .text_alignment
4122 .or(style.source_inherited_text_alignment)
4123 })
4124 }
4125 TableCellContentNode::Table(nested) => {
4126 table_alignment_context_changed(nested, state, nested.source_xml.as_deref())
4127 }
4128 })
4129 })
4130 })
4131}
4132
4133fn paragraph_properties_with_mark_xml(
4134 paragraph: &ParagraphNode,
4135 state: &ImageSerializationState<'_>,
4136) -> String {
4137 let text_alignment = paragraph
4138 .style
4139 .as_ref()
4140 .and_then(|style| serialized_paragraph_text_alignment(style, state));
4141 let snap_to_grid = paragraph
4142 .style
4143 .as_ref()
4144 .and_then(|style| serialized_paragraph_snap_to_grid(style, state));
4145 let properties =
4146 paragraph_properties_xml(paragraph.style.as_deref(), text_alignment, snap_to_grid);
4147 let mark = paragraph_mark_xml(
4148 paragraph,
4149 paragraph.source_paragraph_mark_properties_xml.as_deref(),
4150 state,
4151 );
4152 if mark.is_empty() {
4153 return properties;
4154 }
4155 insert_property_child(
4156 if properties.is_empty() {
4157 "<w:pPr/>"
4158 } else {
4159 &properties
4160 },
4161 "w:pPr",
4162 "w:rPr",
4163 &mark,
4164 PARAGRAPH_MARK_TAIL_ORDER,
4165 )
4166}
4167
4168fn patch_source_paragraph_mark(paragraph: &ParagraphNode, source: &str, state: &ImageSerializationState<'_>) -> String {
4169 if paragraph.paragraph_mark_style == paragraph.source_paragraph_mark_style {
4170 return source.to_string();
4171 }
4172 let ppr_range = direct_property_children(source)
4173 .into_iter()
4174 .find(|(name, _)| name == "w:pPr")
4175 .map(|(_, range)| range);
4176 let original_properties = ppr_range
4177 .as_ref()
4178 .map(|range| &source[range.start..range.end])
4179 .unwrap_or("<w:pPr/>");
4180 let mark_range = direct_property_children(original_properties)
4181 .into_iter()
4182 .find(|(name, _)| name == "w:rPr")
4183 .map(|(_, range)| range);
4184 let mark = paragraph_mark_xml(
4185 paragraph,
4186 mark_range
4187 .as_ref()
4188 .map(|range| &original_properties[range.start..range.end]),
4189 state,
4190 );
4191 let mut properties = original_properties.to_string();
4192 if let Some(range) = mark_range {
4193 properties.replace_range(range.start..range.end, &mark);
4194 } else if !mark.is_empty() {
4195 properties = insert_property_child(
4196 &properties,
4197 "w:pPr",
4198 "w:rPr",
4199 &mark,
4200 PARAGRAPH_MARK_TAIL_ORDER,
4201 );
4202 }
4203 let mut updated = source.to_string();
4204 if let Some(range) = ppr_range {
4205 updated.replace_range(range.start..range.end, &properties);
4206 } else if paragraph.paragraph_mark_style.is_some() {
4207 let insert_at = opening_tag_end(source, 0).unwrap_or(0);
4208 updated.insert_str(insert_at, &properties);
4209 }
4210 updated
4211}
4212fn paragraph_text_alignment_str(value: ParagraphTextAlignment) -> &'static str {
4213 match value {
4214 ParagraphTextAlignment::Auto => "auto",
4215 ParagraphTextAlignment::Top => "top",
4216 ParagraphTextAlignment::Center => "center",
4217 ParagraphTextAlignment::Baseline => "baseline",
4218 ParagraphTextAlignment::Bottom => "bottom",
4219 }
4220}
4221
4222fn patch_source_paragraph_text_alignment(
4223 paragraph: &ParagraphNode,
4224 source: &str,
4225 state: &ImageSerializationState<'_>,
4226) -> String {
4227 let Some(style) = paragraph.style.as_ref() else {
4228 return source.to_string();
4229 };
4230 if style.text_alignment == style.source_text_alignment
4231 && (style.source_has_text_alignment.is_none() || style.source_text_alignment.is_none())
4232 {
4233 return source.to_string();
4234 }
4235 let range = direct_property_children(source)
4236 .into_iter()
4237 .find(|(name, _)| name == "w:pPr")
4238 .map(|(_, range)| range);
4239 let properties = range
4240 .as_ref()
4241 .map(|range| &source[range.start..range.end])
4242 .unwrap_or("<w:pPr/>");
4243 let desired = serialized_paragraph_text_alignment(style, state);
4244 let current = crate::parse::direct_table_property(properties, "w:textAlignment")
4245 .and_then(|tag| get_attribute(tag, "w:val"));
4246 if current.as_deref() == desired.map(paragraph_text_alignment_str) {
4247 return source.to_string();
4248 }
4249 let attributes =
4250 desired.map(|value| [("w:val", paragraph_text_alignment_str(value).to_string())]);
4251 let updated = patch_direct_table_property(
4252 properties,
4253 "w:pPr",
4254 "w:textAlignment",
4255 attributes.as_ref().map(|values| values.as_slice()),
4256 &[
4257 "w:textDirection",
4258 "w:textAlignment",
4259 "w:textboxTightWrap",
4260 "w:outlineLvl",
4261 "w:divId",
4262 "w:cnfStyle",
4263 "w:rPr",
4264 "w:sectPr",
4265 "w:pPrChange",
4266 ],
4267 );
4268 let mut source = source.to_string();
4269 if let Some(range) = range {
4270 source.replace_range(range.start..range.end, &updated);
4271 } else if desired.is_some() {
4272 let end = opening_tag_end(&source, 0).unwrap_or(0);
4273 source.insert_str(end, &updated);
4274 }
4275 source
4276}
4277
4278fn patch_source_paragraph_snap_to_grid(
4279 paragraph: &ParagraphNode,
4280 source: &str,
4281 state: &ImageSerializationState<'_>,
4282) -> String {
4283 let Some(style) = paragraph.style.as_ref() else {
4284 return source.to_string();
4285 };
4286 if style.snap_to_grid == style.source_snap_to_grid
4287 && (style.source_has_snap_to_grid.is_none() || style.source_snap_to_grid.is_none())
4288 {
4289 return source.to_string();
4290 }
4291 let range = direct_property_children(source)
4292 .into_iter()
4293 .find(|(name, _)| name == "w:pPr")
4294 .map(|(_, range)| range);
4295 let properties = range
4296 .as_ref()
4297 .map(|range| &source[range.start..range.end])
4298 .unwrap_or("<w:pPr/>");
4299 let desired = serialized_paragraph_snap_to_grid(style, state);
4300 let current = crate::parse::direct_table_property(properties, "w:snapToGrid")
4301 .and_then(|tag| crate::xml::parse_on_off_attribute(tag, "snapToGrid"));
4302 if current == desired {
4303 return source.to_string();
4304 }
4305 let attributes = desired.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
4306 let updated = patch_direct_table_property(
4307 properties,
4308 "w:pPr",
4309 "w:snapToGrid",
4310 attributes.as_ref().map(|values| values.as_slice()),
4311 &[
4312 "w:snapToGrid",
4313 "w:spacing",
4314 "w:ind",
4315 "w:contextualSpacing",
4316 "w:mirrorIndents",
4317 "w:suppressOverlap",
4318 "w:jc",
4319 "w:textDirection",
4320 "w:textAlignment",
4321 "w:textboxTightWrap",
4322 "w:outlineLvl",
4323 "w:divId",
4324 "w:cnfStyle",
4325 "w:rPr",
4326 "w:sectPr",
4327 "w:pPrChange",
4328 ],
4329 );
4330 let mut source = source.to_string();
4331 if let Some(range) = range {
4332 source.replace_range(range.start..range.end, &updated);
4333 } else if desired.is_some() {
4334 let insert_at = opening_tag_end(&source, 0).unwrap_or(0);
4335 source.insert_str(insert_at, &updated);
4336 }
4337 source
4338}
4339
4340fn patch_source_paragraph_auto_spacing(paragraph: &ParagraphNode, source: &str) -> String {
4341 let Some(style) = paragraph.style.as_ref() else {
4342 return source.to_string();
4343 };
4344 let edits = [
4345 (
4346 "w:beforeAutospacing",
4347 style.spacing.as_ref().and_then(|s| s.before_auto_spacing),
4348 style.source_before_auto_spacing,
4349 ),
4350 (
4351 "w:afterAutospacing",
4352 style.spacing.as_ref().and_then(|s| s.after_auto_spacing),
4353 style.source_after_auto_spacing,
4354 ),
4355 ];
4356 if edits
4357 .iter()
4358 .all(|(_, desired, original)| desired == original)
4359 {
4360 return source.to_string();
4361 }
4362 let range = direct_property_children(source)
4363 .into_iter()
4364 .find(|(name, _)| name == "w:pPr")
4365 .map(|(_, range)| range);
4366 let properties = range
4367 .as_ref()
4368 .map(|range| &source[range.start..range.end])
4369 .unwrap_or("<w:pPr/>");
4370 let spacing_range = direct_property_children(properties)
4371 .into_iter()
4372 .find(|(name, _)| name == "w:spacing")
4373 .map(|(_, range)| range);
4374 let mut spacing = spacing_range
4375 .as_ref()
4376 .map(|range| properties[range.start..range.end].to_string())
4377 .unwrap_or_else(|| "<w:spacing/>".to_string());
4378 for (name, desired, original) in edits {
4379 if desired == original {
4380 continue;
4381 }
4382 let opening_end = opening_tag_end(&spacing, 0).unwrap_or(spacing.len());
4383 let opening = &spacing[..opening_end];
4384 let updated = match desired {
4385 Some(value) => set_xml_attribute(opening, name, if value { "1" } else { "0" }),
4386 None => remove_xml_attribute(opening, name),
4387 };
4388 spacing.replace_range(..opening_end, &updated);
4389 }
4390 let mut updated = properties.to_string();
4391 if let Some(spacing_range) = spacing_range {
4392 updated.replace_range(spacing_range.start..spacing_range.end, &spacing);
4393 } else {
4394 updated = insert_property_child(
4395 properties,
4396 "w:pPr",
4397 "w:spacing",
4398 &spacing,
4399 &[
4400 "w:spacing",
4401 "w:ind",
4402 "w:contextualSpacing",
4403 "w:mirrorIndents",
4404 "w:suppressOverlap",
4405 "w:jc",
4406 "w:textDirection",
4407 "w:textAlignment",
4408 "w:textboxTightWrap",
4409 "w:outlineLvl",
4410 "w:divId",
4411 "w:cnfStyle",
4412 "w:rPr",
4413 "w:sectPr",
4414 "w:pPrChange",
4415 ],
4416 );
4417 }
4418 let mut source = source.to_string();
4419 if let Some(range) = range {
4420 source.replace_range(range.start..range.end, &updated);
4421 } else {
4422 let insert_at = opening_tag_end(&source, 0).unwrap_or(0);
4423 source.insert_str(insert_at, &updated);
4424 }
4425 source
4426}
4427
4428fn patch_source_paragraph_properties(
4429 paragraph: &ParagraphNode,
4430 source: &str,
4431 state: &ImageSerializationState<'_>,
4432) -> String {
4433 let marked = patch_source_paragraph_mark(paragraph, source, state);
4434 let aligned = patch_source_paragraph_text_alignment(paragraph, &marked, state);
4435 let grid = patch_source_paragraph_snap_to_grid(paragraph, &aligned, state);
4436 patch_source_paragraph_auto_spacing(paragraph, &grid)
4437}
4438
4439fn paragraph_xml(
4440 paragraph: &ParagraphNode,
4441 state: &mut ImageSerializationState<'_>,
4442 run_id_ref: &mut i64,
4443) -> String {
4444 if !paragraph_has_stale_form_field_preview(paragraph) {
4445 if let Some(source_xml) = ¶graph.source_xml {
4446 if let Some(patch_plan) = ¶graph.source_text_patch {
4447 if let Some(patched) = patch_source_paragraph_text(paragraph, source_xml, patch_plan) {
4448 return patch_source_paragraph_properties(paragraph, &patched, state);
4449 }
4450 } else {
4451 return patch_source_paragraph_properties(paragraph, source_xml, state);
4452 }
4453 }
4454 }
4455
4456 let runs = paragraph
4457 .children
4458 .iter()
4459 .filter_map(|child| {
4460 let xml = match child {
4461 ParagraphChildNode::Text(text_run) => {
4462 let has_note_reference = text_run.note_reference.is_some();
4463 let note_reference_xml = text_run
4464 .note_reference
4465 .as_ref()
4466 .map(|reference| match reference.kind {
4467 NoteReferenceKind::Footnote => {
4468 format!(r#"<w:footnoteReference w:id="{}"/>"#, reference.id)
4469 }
4470 NoteReferenceKind::Endnote => {
4471 format!(r#"<w:endnoteReference w:id="{}"/>"#, reference.id)
4472 }
4473 })
4474 .unwrap_or_default();
4475 let text_xml = if has_note_reference && text_run.text.is_empty() {
4476 String::new()
4477 } else {
4478 render_text_tokens(&text_run.text)
4479 };
4480 let run_xml = format!(
4481 "<w:r>{}{}{}</w:r>",
4482 run_properties_xml(text_run.style.as_deref(), state),
4483 note_reference_xml,
4484 text_xml
4485 );
4486 let run_xml = if let Some(instruction) = &text_run.field_instruction {
4487 format!(
4488 "<w:fldSimple w:instr=\"{}\">{}</w:fldSimple>",
4489 escape_xml(instruction),
4490 run_xml
4491 )
4492 } else {
4493 run_xml
4494 };
4495 wrap_with_hyperlink_xml(&run_xml, text_run.link.as_deref(), state)
4496 }
4497 ParagraphChildNode::FormField(form_field) => form_field_run_xml(form_field, state),
4498 ParagraphChildNode::Image(image) => {
4499 *run_id_ref += 1;
4500 drawing_run_xml(image, state, *run_id_ref)
4501 }
4502 };
4503 if xml.is_empty() {
4504 None
4505 } else {
4506 Some(xml)
4507 }
4508 })
4509 .collect::<String>();
4510
4511 let paragraph_runs = if runs.is_empty() {
4512 "<w:r><w:t/></w:r>".to_string()
4513 } else {
4514 runs
4515 };
4516 format!(
4517 "<w:p>{}{}</w:p>",
4518 paragraph_properties_with_mark_xml(paragraph, state),
4519 paragraph_runs
4520 )
4521}
4522
4523fn table_cell_xml_content(
4524 nodes: &[TableCellContentNode],
4525 state: &mut ImageSerializationState<'_>,
4526 run_id_ref: &mut i64,
4527) -> String {
4528 nodes
4529 .iter()
4530 .map(|node| match node {
4531 TableCellContentNode::Paragraph(paragraph) => {
4532 paragraph_xml(paragraph, state, run_id_ref)
4533 }
4534 TableCellContentNode::Table(table) => table_xml(table, state, run_id_ref),
4535 })
4536 .collect()
4537}
4538
4539fn table_layout_str(layout: TableLayout) -> &'static str {
4540 match layout {
4541 TableLayout::Fixed => "fixed",
4542 TableLayout::Autofit => "autofit",
4543 }
4544}
4545
4546fn table_row_height_rule_str(rule: TableRowHeightRule) -> &'static str {
4547 match rule {
4548 TableRowHeightRule::Auto => "auto",
4549 TableRowHeightRule::AtLeast => "atLeast",
4550 TableRowHeightRule::Exact => "exact",
4551 }
4552}
4553
4554fn table_cell_vertical_align_str(align: TableCellVerticalAlign) -> &'static str {
4555 match align {
4556 TableCellVerticalAlign::Top => "top",
4557 TableCellVerticalAlign::Center => "center",
4558 TableCellVerticalAlign::Bottom => "bottom",
4559 }
4560}
4561
4562fn patch_table_source_text(
4563 table: &TableNode,
4564 source_xml: &str,
4565 state: &mut ImageSerializationState<'_>,
4566 run_id_ref: &mut i64,
4567) -> Option<String> {
4568 let patches = table.source_text_patches.as_deref()?;
4569 if patches.is_empty() {
4570 return Some(source_xml.to_string());
4571 }
4572
4573 let mut patched = source_xml.to_string();
4574 for patch in patches {
4575 if patch.source_paragraph_xml.is_empty() {
4576 return None;
4577 }
4578 let mut matches = patched.match_indices(&patch.source_paragraph_xml);
4579 let (start, _) = matches.next()?;
4580 if matches.next().is_some() {
4581 return None;
4582 }
4583 let end = start + patch.source_paragraph_xml.len();
4584 let replacement = paragraph_xml(&patch.paragraph, state, run_id_ref);
4585 patched.replace_range(start..end, &replacement);
4586 }
4587 Some(patched)
4588}
4589
4590const TABLE_PROPERTIES_ORDER: &[&str] = &[
4591 "w:tblStyle",
4592 "w:tblpPr",
4593 "w:tblOverlap",
4594 "w:bidiVisual",
4595 "w:tblStyleRowBandSize",
4596 "w:tblStyleColBandSize",
4597 "w:tblW",
4598 "w:jc",
4599 "w:tblCellSpacing",
4600 "w:tblInd",
4601 "w:tblBorders",
4602 "w:shd",
4603 "w:tblLayout",
4604 "w:tblCellMar",
4605 "w:tblLook",
4606 "w:tblCaption",
4607 "w:tblDescription",
4608 "w:tblPrChange",
4609];
4610const CELL_PROPERTIES_ORDER: &[&str] = &[
4611 "w:cnfStyle",
4612 "w:tcW",
4613 "w:gridSpan",
4614 "w:hMerge",
4615 "w:vMerge",
4616 "w:tcBorders",
4617 "w:shd",
4618 "w:noWrap",
4619 "w:tcMar",
4620 "w:textDirection",
4621 "w:tcFitText",
4622 "w:vAlign",
4623 "w:hideMark",
4624 "w:headers",
4625 "w:cellIns",
4626 "w:cellDel",
4627 "w:cellMerge",
4628 "w:tcPrChange",
4629];
4630
4631fn effective_table_width(
4632 preferred: Option<&TablePreferredWidth>,
4633 legacy: Option<i64>,
4634 source: Option<&TableWidthSource>,
4635) -> Option<TablePreferredWidth> {
4636 if let Some(source) = source {
4637 if preferred != source.preferred_width.as_ref() {
4638 return preferred
4639 .cloned()
4640 .or_else(|| source.inherited_preferred_width.clone());
4641 }
4642 if legacy != source.width_twips {
4643 return legacy
4644 .map(|value| TablePreferredWidth::Dxa {
4645 value: value as f64,
4646 })
4647 .or_else(|| source.inherited_preferred_width.clone());
4648 }
4649 }
4650 preferred.cloned().or_else(|| {
4651 legacy.map(|value| TablePreferredWidth::Dxa {
4652 value: value as f64,
4653 })
4654 })
4655}
4656
4657fn table_width_edit(
4658 preferred: Option<&TablePreferredWidth>,
4659 legacy: Option<i64>,
4660 source: Option<&TableWidthSource>,
4661) -> Option<Option<TablePreferredWidth>> {
4662 if let Some(source) = source {
4663 if preferred != source.preferred_width.as_ref() {
4664 return Some(preferred.cloned());
4665 }
4666 if legacy != source.width_twips {
4667 return Some(legacy.map(|value| TablePreferredWidth::Dxa {
4668 value: value as f64,
4669 }));
4670 }
4671 None
4672 } else {
4673 effective_table_width(preferred, legacy, None).map(Some)
4674 }
4675}
4676
4677fn table_width_attributes(width: &TablePreferredWidth) -> Option<[(&'static str, String); 2]> {
4678 let (kind, value) = match width {
4679 TablePreferredWidth::Dxa { value } if value.is_finite() && *value >= 0.0 => (
4680 "dxa",
4681 if value.fract() == 0.0 {
4682 format!("{value:.0}")
4683 } else {
4684 format!("{}pt", value / 20.0)
4685 },
4686 ),
4687 TablePreferredWidth::Pct { value } if value.is_finite() && *value >= 0.0 => {
4688 let fiftieths = value * 50.0;
4689 (
4690 "pct",
4691 if fiftieths.fract() == 0.0 {
4692 format!("{fiftieths:.0}")
4693 } else {
4694 format!("{value}%")
4695 },
4696 )
4697 }
4698 TablePreferredWidth::Auto => ("auto", "0".to_string()),
4699 TablePreferredWidth::Nil => ("nil", "0".to_string()),
4700 _ => return None,
4701 };
4702 Some([("w:w", value), ("w:type", kind.to_string())])
4703}
4704
4705fn table_width_xml(name: &str, width: Option<&TablePreferredWidth>) -> Option<String> {
4706 let attributes = table_width_attributes(width?)?;
4707 Some(format!(
4708 r#"<{name} w:w="{}" w:type="{}"/>"#,
4709 attributes[0].1, attributes[1].1
4710 ))
4711}
4712
4713fn table_alignment_str(value: TableAlignment) -> &'static str {
4714 match value {
4715 TableAlignment::Left => "left",
4716 TableAlignment::Center => "center",
4717 TableAlignment::Right => "right",
4718 }
4719}
4720
4721fn patch_direct_table_property(
4722 xml: &str,
4723 container: &str,
4724 name: &str,
4725 attributes: Option<&[(&str, String)]>,
4726 order: &[&str],
4727) -> String {
4728 let ranges: Vec<_> = direct_property_children(xml)
4729 .into_iter()
4730 .filter(|(tag, _)| tag == name)
4731 .map(|(_, range)| range)
4732 .collect();
4733 let Some(attributes) = attributes else {
4734 return replace_xml_ranges(
4735 xml,
4736 ranges
4737 .into_iter()
4738 .map(|range| (range.start, range.end, String::new()))
4739 .collect(),
4740 );
4741 };
4742 if let Some(first) = ranges.first() {
4743 let existing = &xml[first.start..first.end];
4744 let end = opening_tag_end(existing, 0).unwrap_or(existing.len());
4745 let mut tag = existing[..end].to_string();
4746 for (attribute, value) in attributes {
4747 tag = set_xml_attribute(&tag, attribute, value);
4748 }
4749 tag.push_str(&existing[end..]);
4750 let mut replacements = vec![(first.start, first.end, tag)];
4751 replacements.extend(
4752 ranges
4753 .into_iter()
4754 .skip(1)
4755 .map(|range| (range.start, range.end, String::new())),
4756 );
4757 return replace_xml_ranges(xml, replacements);
4758 }
4759 let mut child = format!("<{name}/>");
4760 for (attribute, value) in attributes {
4761 child = set_xml_attribute(&child, attribute, value);
4762 }
4763 insert_property_child(xml, container, name, &child, order)
4764}
4765
4766fn patch_table_properties(
4767 xml: &str,
4768 container: &str,
4769 width_name: &str,
4770 width: Option<Option<TablePreferredWidth>>,
4771 alignment: Option<Option<TableAlignment>>,
4772 bidi: Option<Option<bool>>,
4773 order: &[&str],
4774) -> String {
4775 if width.is_none() && alignment.is_none() && bidi.is_none() {
4776 return xml.to_string();
4777 }
4778 let range = direct_property_children(xml)
4779 .into_iter()
4780 .find(|(tag, _)| tag == container)
4781 .map(|(_, range)| range);
4782 let original = range.as_ref().map(|range| &xml[range.start..range.end]);
4783 let mut properties = original
4784 .map(str::to_string)
4785 .unwrap_or_else(|| format!("<{container}/>"));
4786 if let Some(width) = width {
4787 let current = crate::parse::direct_table_property(&properties, width_name)
4788 .and_then(crate::parse::parse_table_preferred_width);
4789 if current != width {
4790 let attributes = width.as_ref().and_then(table_width_attributes);
4791 properties = patch_direct_table_property(
4792 &properties,
4793 container,
4794 width_name,
4795 attributes.as_ref().map(|values| values.as_slice()),
4796 order,
4797 );
4798 }
4799 }
4800 if let Some(alignment) = alignment {
4801 let current = crate::parse::direct_table_property(&properties, "w:jc")
4802 .and_then(|tag| get_attribute(tag, "w:val"));
4803 let desired = alignment.map(|value| table_alignment_str(value).to_string());
4804 if current != desired {
4805 let attributes = desired.map(|value| [("w:val", value)]);
4806 properties = patch_direct_table_property(
4807 &properties,
4808 container,
4809 "w:jc",
4810 attributes.as_ref().map(|values| values.as_slice()),
4811 order,
4812 );
4813 }
4814 }
4815 if let Some(bidi) = bidi {
4816 let current = crate::parse::direct_table_property(&properties, "w:bidiVisual")
4817 .and_then(|tag| crate::xml::parse_on_off_attribute(tag, "bidiVisual"));
4818 if current != bidi {
4819 let attributes =
4820 bidi.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
4821 properties = patch_direct_table_property(
4822 &properties,
4823 container,
4824 "w:bidiVisual",
4825 attributes.as_ref().map(|values| values.as_slice()),
4826 order,
4827 );
4828 }
4829 }
4830 if let Some(range) = range {
4831 let mut patched = xml.to_string();
4832 patched.replace_range(range.start..range.end, &properties);
4833 patched
4834 } else if !direct_property_children(&properties).is_empty() {
4835 let mut patched = xml.to_string();
4836 let end = opening_tag_end(xml, 0).unwrap_or(xml.len());
4837 patched.insert_str(end, &properties);
4838 patched
4839 } else {
4840 xml.to_string()
4841 }
4842}
4843
4844const ROW_PROPERTIES_ORDER: &[&str] = &[
4845 "w:cnfStyle",
4846 "w:divId",
4847 "w:gridBefore",
4848 "w:gridAfter",
4849 "w:wBefore",
4850 "w:wAfter",
4851 "w:cantSplit",
4852 "w:trHeight",
4853 "w:tblHeader",
4854 "w:tblCellSpacing",
4855 "w:jc",
4856 "w:hidden",
4857 "w:ins",
4858 "w:del",
4859 "w:trPrChange",
4860];
4861
4862fn row_has_source_geometry_edits(row: &TableRowNode) -> bool {
4863 let Some(style) = row.style.as_ref() else {
4864 return false;
4865 };
4866 if let Some(source) = style.source_row_geometry.as_ref() {
4867 style.grid_before != source.grid_before
4868 || style.grid_after != source.grid_after
4869 || style.width_before != source.width_before
4870 || style.width_after != source.width_after
4871 } else {
4872 style.grid_before.is_some()
4873 || style.grid_after.is_some()
4874 || style.width_before.is_some()
4875 || style.width_after.is_some()
4876 }
4877}
4878
4879fn patch_row_source_geometry(row: &TableRowNode, xml: &str) -> String {
4880 if !row_has_source_geometry_edits(row) {
4881 return xml.to_string();
4882 }
4883 let style = row.style.as_ref().unwrap();
4884 let source = style.source_row_geometry.as_ref();
4885 let range = direct_property_children(xml)
4886 .into_iter()
4887 .find(|(name, _)| name == "w:trPr")
4888 .map(|(_, range)| range);
4889 let mut properties = range
4890 .as_ref()
4891 .map(|range| xml[range.start..range.end].to_string())
4892 .unwrap_or_else(|| "<w:trPr/>".to_string());
4893 for (name, current, baseline) in [
4894 (
4895 "w:gridBefore",
4896 style.grid_before,
4897 source.and_then(|source| source.grid_before),
4898 ),
4899 (
4900 "w:gridAfter",
4901 style.grid_after,
4902 source.and_then(|source| source.grid_after),
4903 ),
4904 ] {
4905 if (source.is_none() && current.is_none())
4906 || (source.is_some() && current == baseline)
4907 || current.is_some_and(|value| value < 0)
4908 {
4909 continue;
4910 }
4911 let attributes = current.map(|value| [("w:val", value.to_string())]);
4912 properties = patch_direct_table_property(
4913 &properties,
4914 "w:trPr",
4915 name,
4916 attributes.as_ref().map(|values| values.as_slice()),
4917 ROW_PROPERTIES_ORDER,
4918 );
4919 }
4920 for (name, current, baseline) in [
4921 (
4922 "w:wBefore",
4923 style.width_before.as_ref(),
4924 source.and_then(|source| source.width_before.as_ref()),
4925 ),
4926 (
4927 "w:wAfter",
4928 style.width_after.as_ref(),
4929 source.and_then(|source| source.width_after.as_ref()),
4930 ),
4931 ] {
4932 if (source.is_none() && current.is_none()) || (source.is_some() && current == baseline) {
4933 continue;
4934 }
4935 let actual = crate::parse::direct_table_property(&properties, name)
4936 .and_then(crate::parse::parse_table_preferred_width);
4937 if actual.as_ref() == current {
4938 continue;
4939 }
4940 let attributes = current.and_then(table_width_attributes);
4941 properties = patch_direct_table_property(
4942 &properties,
4943 "w:trPr",
4944 name,
4945 attributes.as_ref().map(|values| values.as_slice()),
4946 ROW_PROPERTIES_ORDER,
4947 );
4948 }
4949 let mut updated = xml.to_string();
4950 if let Some(range) = range {
4951 updated.replace_range(range.start..range.end, &properties);
4952 } else if !direct_property_children(&properties).is_empty() {
4953 let end = direct_property_children(xml)
4954 .into_iter()
4955 .find(|(name, _)| name == "w:tblPrEx")
4956 .map(|(_, range)| range.end)
4957 .unwrap_or_else(|| opening_tag_end(xml, 0).unwrap_or(0));
4958 updated.insert_str(end, &properties);
4959 }
4960 updated
4961}
4962
4963fn row_has_source_property_edits(row: &TableRowNode) -> bool {
4964 row_has_source_geometry_edits(row) || row.cells.iter().any(cell_has_source_property_edits)
4965}
4966
4967fn paragraph_has_source_property_edits(paragraph: &ParagraphNode) -> bool {
4968 paragraph.paragraph_mark_style != paragraph.source_paragraph_mark_style
4969 || paragraph.style.as_ref().is_some_and(|style| {
4970 style.text_alignment != style.source_text_alignment
4971 || style.snap_to_grid != style.source_snap_to_grid
4972 || style.spacing.as_ref().and_then(|s| s.before_auto_spacing)
4973 != style.source_before_auto_spacing
4974 || style.spacing.as_ref().and_then(|s| s.after_auto_spacing)
4975 != style.source_after_auto_spacing
4976 })
4977}
4978
4979fn cell_has_source_property_edits(cell: &TableCellNode) -> bool {
4980 cell.style.as_ref().is_some_and(|style| {
4981 style.text_direction != style.source_text_direction
4982 || style.no_wrap != style.source_no_wrap
4983 || table_width_edit(
4984 style.preferred_width.as_ref(),
4985 style.width_twips,
4986 style.source_width.as_ref(),
4987 )
4988 .is_some()
4989 }) || cell.nodes.iter().any(|node| match node {
4990 TableCellContentNode::Paragraph(paragraph) => {
4991 paragraph_has_source_property_edits(paragraph)
4992 }
4993 TableCellContentNode::Table(table) => table_has_source_property_edits(table),
4994 })
4995}
4996
4997fn table_has_source_property_edits(table: &TableNode) -> bool {
4998 table.style.as_ref().is_some_and(|style| {
4999 table_width_edit(
5000 style.preferred_width.as_ref(),
5001 style.width_twips,
5002 style.source_width.as_ref(),
5003 )
5004 .is_some()
5005 || style.style_id != style.source_style_id
5006 || style.alignment != style.source_alignment
5007 || style.bidi_visual != style.source_bidi_visual
5008 }) || table.rows.iter().any(row_has_source_property_edits)
5009}
5010
5011fn patch_table_source_properties(
5012 table: &TableNode,
5013 source_xml: &str,
5014 state: &mut ImageSerializationState<'_>,
5015) -> Option<String> {
5016 let previous = state.current_table_text_alignment;
5017 let previous_snap = state.current_table_snap_to_grid;
5018 let result = patch_table_source_properties_inner(table, source_xml, state);
5019 state.current_table_text_alignment = previous;
5020 state.current_table_snap_to_grid = previous_snap;
5021 result
5022}
5023
5024fn patch_table_source_properties_inner(
5025 table: &TableNode,
5026 source_xml: &str,
5027 state: &mut ImageSerializationState<'_>,
5028) -> Option<String> {
5029 let identity_changed = table
5030 .style
5031 .as_ref()
5032 .is_some_and(|style| style.style_id != style.source_style_id);
5033 if !table_has_source_property_edits(table)
5034 && !table_alignment_context_changed(table, state, Some(source_xml))
5035 {
5036 return Some(source_xml.to_string());
5037 }
5038 let width = table.style.as_ref().and_then(|style| {
5039 table_width_edit(
5040 style.preferred_width.as_ref(),
5041 style.width_twips,
5042 style.source_width.as_ref(),
5043 )
5044 });
5045 let alignment = table
5046 .style
5047 .as_ref()
5048 .filter(|style| style.alignment != style.source_alignment)
5049 .map(|style| style.alignment);
5050 let bidi = table
5051 .style
5052 .as_ref()
5053 .filter(|style| style.bidi_visual != style.source_bidi_visual)
5054 .map(|style| style.bidi_visual);
5055 let mut source = patch_table_properties(
5056 source_xml,
5057 "w:tblPr",
5058 "w:tblW",
5059 width,
5060 alignment,
5061 bidi,
5062 TABLE_PROPERTIES_ORDER,
5063 );
5064 if let Some(style) = table
5065 .style
5066 .as_ref()
5067 .filter(|style| style.style_id != style.source_style_id)
5068 {
5069 let range = direct_property_children(&source)
5070 .into_iter()
5071 .find(|(name, _)| name == "w:tblPr")
5072 .map(|(_, range)| range);
5073 let properties = range
5074 .as_ref()
5075 .map(|range| &source[range.start..range.end])
5076 .unwrap_or("<w:tblPr/>");
5077 let attributes = style
5078 .style_id
5079 .as_ref()
5080 .map(|value| [("w:val", value.clone())]);
5081 let properties = patch_direct_table_property(
5082 properties,
5083 "w:tblPr",
5084 "w:tblStyle",
5085 attributes.as_ref().map(|values| values.as_slice()),
5086 TABLE_PROPERTIES_ORDER,
5087 );
5088 if let Some(range) = range {
5089 source.replace_range(range.start..range.end, &properties);
5090 } else if style.style_id.is_some() {
5091 let end = opening_tag_end(&source, 0).unwrap_or(0);
5092 source.insert_str(end, &properties);
5093 }
5094 }
5095 if !identity_changed
5096 && !table.rows.iter().any(row_has_source_property_edits)
5097 && !table_alignment_context_changed(table, state, Some(&source))
5098 {
5099 return Some(source);
5100 }
5101 let context = resolve_table_alignment_context(table, state, Some(&source));
5102 let rows = extract_balanced_tag_ranges(&source, "w:tr");
5103 if rows.len() != table.rows.len() {
5104 return None;
5105 }
5106 let mut row_replacements = Vec::new();
5107 for (row_index, (range, row)) in rows.into_iter().zip(&table.rows).enumerate() {
5108 let patched_row = patch_row_source_geometry(row, &source[range.start..range.end]);
5109 let row_xml = patched_row.as_str();
5110 let cells = extract_balanced_tag_ranges(row_xml, "w:tc");
5111 if cells.len() != row.cells.len() {
5112 return None;
5113 }
5114 let mut cell_replacements = Vec::new();
5115 let bound = context.grid_bound;
5116 let mut column = crate::parse::resolve_table_grid_skip_count(
5117 row.style.as_ref().and_then(|style| style.grid_before),
5118 bound,
5119 );
5120 for (cell_range, cell) in cells.into_iter().zip(&row.cells) {
5121 let span = cell
5122 .style
5123 .as_ref()
5124 .and_then(|style| style.grid_span)
5125 .unwrap_or(1)
5126 .max(1);
5127 state.current_table_text_alignment =
5128 table_cell_text_alignment(&context, row_index, column, span, state);
5129 state.current_table_snap_to_grid =
5130 table_cell_snap_to_grid(&context, row_index, column, span, state);
5131 column += span;
5132 let cell_xml = &row_xml[cell_range.start..cell_range.end];
5133 let width = cell.style.as_ref().and_then(|style| {
5134 table_width_edit(
5135 style.preferred_width.as_ref(),
5136 style.width_twips,
5137 style.source_width.as_ref(),
5138 )
5139 });
5140 let mut patched_cell = patch_table_properties(
5141 cell_xml,
5142 "w:tcPr",
5143 "w:tcW",
5144 width,
5145 None,
5146 None,
5147 CELL_PROPERTIES_ORDER,
5148 );
5149 if let Some(style) = cell
5150 .style
5151 .as_ref()
5152 .filter(|style| style.text_direction != style.source_text_direction)
5153 {
5154 let range = direct_property_children(&patched_cell)
5155 .into_iter()
5156 .find(|(name, _)| name == "w:tcPr")
5157 .map(|(_, range)| range);
5158 let properties = range
5159 .as_ref()
5160 .map(|range| &patched_cell[range.start..range.end])
5161 .unwrap_or("<w:tcPr/>");
5162 let attributes = style
5163 .text_direction
5164 .as_ref()
5165 .map(|value| [("w:val", value.clone())]);
5166 let properties = patch_direct_table_property(
5167 properties,
5168 "w:tcPr",
5169 "w:textDirection",
5170 attributes.as_ref().map(|values| values.as_slice()),
5171 CELL_PROPERTIES_ORDER,
5172 );
5173 if let Some(range) = range {
5174 patched_cell.replace_range(range.start..range.end, &properties);
5175 } else if style.text_direction.is_some() {
5176 let end = opening_tag_end(&patched_cell, 0).unwrap_or(0);
5177 patched_cell.insert_str(end, &properties);
5178 }
5179 }
5180 if let Some(style) = cell
5181 .style
5182 .as_ref()
5183 .filter(|style| style.no_wrap != style.source_no_wrap)
5184 {
5185 let range = direct_property_children(&patched_cell)
5186 .into_iter()
5187 .find(|(name, _)| name == "w:tcPr")
5188 .map(|(_, range)| range);
5189 let properties = range
5190 .as_ref()
5191 .map(|range| &patched_cell[range.start..range.end])
5192 .unwrap_or("<w:tcPr/>");
5193 let attributes = style
5194 .no_wrap
5195 .map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
5196 let properties = patch_direct_table_property(
5197 properties,
5198 "w:tcPr",
5199 "w:noWrap",
5200 attributes.as_ref().map(|values| values.as_slice()),
5201 CELL_PROPERTIES_ORDER,
5202 );
5203 if let Some(range) = range {
5204 patched_cell.replace_range(range.start..range.end, &properties);
5205 } else if style.no_wrap.is_some() {
5206 let end = opening_tag_end(&patched_cell, 0).unwrap_or(0);
5207 patched_cell.insert_str(end, &properties);
5208 }
5209 }
5210 if identity_changed
5211 || cell_alignment_context_changed(cell, state)
5212 || cell.nodes.iter().any(|node| match node {
5213 TableCellContentNode::Paragraph(paragraph) => {
5214 paragraph_has_source_property_edits(paragraph)
5215 }
5216 TableCellContentNode::Table(table) => table_has_source_property_edits(table),
5217 })
5218 {
5219 let blocks = crate::xml::extract_balanced_tag_blocks_in_order(
5220 &patched_cell,
5221 &["w:p", "w:tbl"],
5222 );
5223 if blocks.len() != cell.nodes.len() {
5224 return None;
5225 }
5226 let mut replacements = Vec::new();
5227 for (range, node) in blocks.into_iter().zip(&cell.nodes) {
5228 let source = &patched_cell[range.start..range.end];
5229 let replacement = match node {
5230 TableCellContentNode::Paragraph(paragraph)
5231 if range.tag_name.eq_ignore_ascii_case("w:p") =>
5232 {
5233 patch_source_paragraph_properties(paragraph, source, state)
5234 }
5235 TableCellContentNode::Table(table)
5236 if range.tag_name.eq_ignore_ascii_case("w:tbl") =>
5237 {
5238 patch_table_source_properties(table, source, state)?
5239 }
5240 _ => return None,
5241 };
5242 replacements.push((range.start, range.end, replacement));
5243 }
5244 patched_cell = replace_xml_ranges(&patched_cell, replacements);
5245 }
5246 cell_replacements.push((cell_range.start, cell_range.end, patched_cell));
5247 }
5248 row_replacements.push((
5249 range.start,
5250 range.end,
5251 replace_xml_ranges(row_xml, cell_replacements),
5252 ));
5253 }
5254 Some(replace_xml_ranges(&source, row_replacements))
5255}
5256
5257fn table_xml(
5258 table: &TableNode,
5259 state: &mut ImageSerializationState<'_>,
5260 run_id_ref: &mut i64,
5261) -> String {
5262 if !table_has_stale_form_field_preview(table) {
5263 if let Some(source_xml) = &table.source_xml {
5264 let source = if table
5265 .source_text_patches
5266 .as_ref()
5267 .is_some_and(|patches| !patches.is_empty())
5268 {
5269 patch_table_source_text(table, source_xml, state, run_id_ref)
5270 } else {
5271 Some(source_xml.clone())
5272 };
5273 if let Some(patched) =
5274 source.and_then(|source| patch_table_source_properties(table, &source, state))
5275 {
5276 return patched;
5277 }
5278 }
5279 }
5280
5281 let physical_table = match crate::table_grid::materialize_collapsed_table_grid(table) {
5282 Ok(table) => table,
5283 Err(error) => {
5284 state
5285 .pkg
5286 .warnings
5287 .push(format!("Invalid collapsed table grid: {error}"));
5288 None
5289 }
5290 };
5291 let table = physical_table.as_ref().unwrap_or(table);
5292
5293 let mut table_props: Vec<String> = Vec::new();
5294 if let Some(style_id) = table
5295 .style
5296 .as_ref()
5297 .and_then(|style| style.style_id.as_ref())
5298 {
5299 table_props.push(format!(r#"<w:tblStyle w:val="{}"/>"#, escape_xml(style_id)));
5300 }
5301
5302 if let Some(floating) = table.style.as_ref().and_then(|s| s.floating.as_ref()) {
5304 let mut attrs = String::new();
5305 if let Some(value) = floating.left_from_text_twips {
5306 attrs.push_str(&format!(r#" w:leftFromText="{value}""#));
5307 }
5308 if let Some(value) = floating.right_from_text_twips {
5309 attrs.push_str(&format!(r#" w:rightFromText="{value}""#));
5310 }
5311 if let Some(value) = floating.top_from_text_twips {
5312 attrs.push_str(&format!(r#" w:topFromText="{value}""#));
5313 }
5314 if let Some(value) = floating.bottom_from_text_twips {
5315 attrs.push_str(&format!(r#" w:bottomFromText="{value}""#));
5316 }
5317 if let Some(value) = floating.vertical_anchor.as_ref() {
5318 attrs.push_str(&format!(r#" w:vertAnchor="{}""#, escape_xml(value)));
5319 }
5320 if let Some(value) = floating.horizontal_anchor.as_ref() {
5321 attrs.push_str(&format!(r#" w:horzAnchor="{}""#, escape_xml(value)));
5322 }
5323 if let Some(align) = floating.horizontal_align {
5324 attrs.push_str(&format!(
5325 r#" w:tblpXSpec="{}""#,
5326 image_horizontal_align_str(align)
5327 ));
5328 }
5329 if let Some(value) = floating.x_twips {
5330 attrs.push_str(&format!(r#" w:tblpX="{value}""#));
5331 }
5332 if let Some(align) = floating.vertical_align {
5333 attrs.push_str(&format!(
5334 r#" w:tblpYSpec="{}""#,
5335 image_vertical_align_str(align)
5336 ));
5337 }
5338 if let Some(value) = floating.y_twips {
5339 attrs.push_str(&format!(r#" w:tblpY="{value}""#));
5340 }
5341 if !attrs.is_empty() {
5342 table_props.push(format!("<w:tblpPr{attrs}/>"));
5343 }
5344 }
5345 let preferred_width = table.style.as_ref().and_then(|style| {
5346 effective_table_width(
5347 style.preferred_width.as_ref(),
5348 style.width_twips,
5349 style.source_width.as_ref(),
5350 )
5351 });
5352 if let Some(xml) = table_width_xml("w:tblW", preferred_width.as_ref()) {
5353 table_props.push(xml);
5354 }
5355 if let Some(alignment) = table
5356 .style
5357 .as_ref()
5358 .and_then(|style| style.alignment.or(style.source_inherited_alignment))
5359 {
5360 table_props.push(format!(
5361 r#"<w:jc w:val="{}"/>"#,
5362 table_alignment_str(alignment)
5363 ));
5364 }
5365 if let Some(bidi_visual) = table
5366 .style
5367 .as_ref()
5368 .and_then(|style| style.bidi_visual.or(style.source_inherited_bidi_visual))
5369 {
5370 table_props.push(format!(
5371 r#"<w:bidiVisual w:val="{}"/>"#,
5372 if bidi_visual { "1" } else { "0" }
5373 ));
5374 }
5375
5376 if let Some(table_indent_twips) =
5377 twips_to_xml(table.style.as_ref().and_then(|s| s.indent_twips))
5378 {
5379 table_props.push(format!(
5380 r#"<w:tblInd w:w="{table_indent_twips}" w:type="dxa"/>"#
5381 ));
5382 }
5383
5384 if let Some(layout) = table.style.as_ref().and_then(|s| s.layout) {
5385 table_props.push(format!(
5386 r#"<w:tblLayout w:type="{}"/>"#,
5387 table_layout_str(layout)
5388 ));
5389 }
5390
5391 if let Some(table_cell_spacing_twips) =
5392 twips_to_xml_non_negative(table.style.as_ref().and_then(|s| s.cell_spacing_twips))
5393 {
5394 table_props.push(format!(
5395 r#"<w:tblCellSpacing w:w="{table_cell_spacing_twips}" w:type="dxa"/>"#
5396 ));
5397 }
5398
5399 let table_cell_margin_xml = table_box_spacing_xml(
5400 table
5401 .style
5402 .as_ref()
5403 .and_then(|s| s.cell_margin_twips.as_ref()),
5404 "w:tblCellMar",
5405 );
5406 if !table_cell_margin_xml.is_empty() {
5407 table_props.push(table_cell_margin_xml);
5408 }
5409 let table_border_xml = table_borders_xml(
5410 table.style.as_ref().and_then(|s| s.borders.as_ref()),
5411 "w:tblBorders",
5412 );
5413 if !table_border_xml.is_empty() {
5414 table_props.push(table_border_xml);
5415 }
5416
5417 table_props.sort_by_key(|xml| {
5418 let name = xml[1..]
5419 .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
5420 .next()
5421 .unwrap_or_default();
5422 TABLE_PROPERTIES_ORDER
5423 .iter()
5424 .position(|value| *value == name)
5425 .unwrap_or(usize::MAX)
5426 });
5427
5428 let table_grid_xml = table
5429 .style
5430 .as_ref()
5431 .and_then(|s| s.column_widths_twips.as_ref())
5432 .map(|widths| {
5433 let cols = widths
5434 .iter()
5435 .filter_map(|width| twips_to_xml_non_negative(Some(*width)))
5436 .map(|width| format!(r#"<w:gridCol w:w="{width}"/>"#))
5437 .collect::<String>();
5438 format!("<w:tblGrid>{cols}</w:tblGrid>")
5439 })
5440 .unwrap_or_default();
5441
5442 let context = resolve_table_alignment_context(table, state, None);
5443 let previous_table_alignment = state.current_table_text_alignment;
5444 let previous_table_snap = state.current_table_snap_to_grid;
5445 let rows = table
5446 .rows
5447 .iter()
5448 .enumerate()
5449 .map(|(row_index, row)| {
5450 let mut row_props: Vec<String> = Vec::new();
5451 for (name, count) in [
5452 (
5453 "w:gridBefore",
5454 row.style.as_ref().and_then(|style| style.grid_before),
5455 ),
5456 (
5457 "w:gridAfter",
5458 row.style.as_ref().and_then(|style| style.grid_after),
5459 ),
5460 ] {
5461 if let Some(count) = count.filter(|count| *count >= 0) {
5462 row_props.push(format!(r#"<{name} w:val="{count}"/>"#));
5463 }
5464 }
5465 for (name, width) in [
5466 (
5467 "w:wBefore",
5468 row.style
5469 .as_ref()
5470 .and_then(|style| style.width_before.as_ref()),
5471 ),
5472 (
5473 "w:wAfter",
5474 row.style
5475 .as_ref()
5476 .and_then(|style| style.width_after.as_ref()),
5477 ),
5478 ] {
5479 if let Some(xml) = table_width_xml(name, width) {
5480 row_props.push(xml);
5481 }
5482 }
5483
5484 if let Some(background_color) =
5485 row.style.as_ref().and_then(|s| s.background_color.as_ref())
5486 {
5487 let fill = background_color.replace('#', "");
5488 row_props.push(format!(
5489 r#"<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>"#
5490 ));
5491 }
5492 if let Some(row_height_twips) =
5493 twips_to_xml(row.style.as_ref().and_then(|s| s.height_twips))
5494 {
5495 let h_rule = row
5496 .style
5497 .as_ref()
5498 .and_then(|s| s.height_rule)
5499 .filter(|rule| {
5500 matches!(
5501 rule,
5502 TableRowHeightRule::Exact
5503 | TableRowHeightRule::AtLeast
5504 | TableRowHeightRule::Auto
5505 )
5506 })
5507 .map(|rule| format!(r#" w:hRule="{}""#, table_row_height_rule_str(rule)))
5508 .unwrap_or_default();
5509 row_props.push(format!(
5510 r#"<w:trHeight w:val="{row_height_twips}"{h_rule}/>"#
5511 ));
5512 }
5513 if row.style.as_ref().and_then(|s| s.cant_split) == Some(true) {
5514 row_props.push("<w:cantSplit/>".to_string());
5515 }
5516 if let Some(is_header) = row.style.as_ref().and_then(|s| s.is_header) {
5517 row_props.push(if is_header {
5518 "<w:tblHeader/>".to_string()
5519 } else {
5520 r#"<w:tblHeader w:val="0"/>"#.to_string()
5521 });
5522 }
5523
5524 row_props.sort_by_key(|xml| {
5525 let name = xml[1..]
5526 .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
5527 .next()
5528 .unwrap_or_default();
5529 ROW_PROPERTIES_ORDER
5530 .iter()
5531 .position(|value| *value == name)
5532 .unwrap_or(usize::MAX)
5533 });
5534
5535 let bound = context.grid_bound;
5536 let mut column = crate::parse::resolve_table_grid_skip_count(
5537 row.style.as_ref().and_then(|style| style.grid_before),
5538 bound,
5539 );
5540 let cells = row
5541 .cells
5542 .iter()
5543 .map(|cell| {
5544 let span = cell
5545 .style
5546 .as_ref()
5547 .and_then(|style| style.grid_span)
5548 .unwrap_or(1)
5549 .max(1);
5550 state.current_table_text_alignment =
5551 table_cell_text_alignment(&context, row_index, column, span, state);
5552 state.current_table_snap_to_grid =
5553 table_cell_snap_to_grid(&context, row_index, column, span, state);
5554 column += span;
5555 let serialized_cells = table_cell_xml_content(&cell.nodes, state, run_id_ref);
5556
5557 let mut cell_props: Vec<String> = Vec::new();
5558 let preferred_width = cell.style.as_ref().and_then(|style| {
5559 effective_table_width(
5560 style.preferred_width.as_ref(),
5561 style.width_twips,
5562 style.source_width.as_ref(),
5563 )
5564 });
5565 if let Some(xml) = table_width_xml("w:tcW", preferred_width.as_ref()) {
5566 cell_props.push(xml);
5567 }
5568 if let Some(background_color) = cell
5569 .style
5570 .as_ref()
5571 .and_then(|s| s.background_color.as_ref())
5572 {
5573 let fill = background_color.replace('#', "");
5574 cell_props.push(format!(
5575 r#"<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>"#
5576 ));
5577 }
5578 if let Some(grid_span) = cell.style.as_ref().and_then(|s| s.grid_span) {
5579 if grid_span > 1 {
5580 cell_props.push(format!(r#"<w:gridSpan w:val="{grid_span}"/>"#));
5581 }
5582 }
5583 if cell.style.as_ref().and_then(|s| s.v_merge_continuation) == Some(true) {
5584 cell_props.push("<w:vMerge/>".to_string());
5585 } else if cell.style.as_ref().and_then(|s| s.row_span).is_some() {
5586 cell_props.push(r#"<w:vMerge w:val="restart"/>"#.to_string());
5587 }
5588 let cell_margin_xml = table_box_spacing_xml(
5589 cell.style.as_ref().and_then(|s| s.margin_twips.as_ref()),
5590 "w:tcMar",
5591 );
5592 if !cell_margin_xml.is_empty() {
5593 cell_props.push(cell_margin_xml);
5594 }
5595 let cell_border_xml = table_borders_xml(
5596 cell.style.as_ref().and_then(|s| s.borders.as_ref()),
5597 "w:tcBorders",
5598 );
5599 if !cell_border_xml.is_empty() {
5600 cell_props.push(cell_border_xml);
5601 }
5602 if let Some(direction) = cell
5603 .style
5604 .as_ref()
5605 .and_then(|s| s.text_direction.as_deref())
5606 {
5607 cell_props.push(format!(
5608 r#"<w:textDirection w:val="{}"/>"#,
5609 escape_xml(direction)
5610 ));
5611 }
5612 if let Some(no_wrap) = cell.style.as_ref().and_then(|s| s.no_wrap) {
5613 cell_props.push(format!(
5614 r#"<w:noWrap w:val="{}"/>"#,
5615 if no_wrap { "1" } else { "0" }
5616 ));
5617 }
5618 if let Some(vertical_align) = cell.style.as_ref().and_then(|s| s.vertical_align)
5619 {
5620 if matches!(
5621 vertical_align,
5622 TableCellVerticalAlign::Top
5623 | TableCellVerticalAlign::Center
5624 | TableCellVerticalAlign::Bottom
5625 ) {
5626 cell_props.push(format!(
5627 r#"<w:vAlign w:val="{}"/>"#,
5628 table_cell_vertical_align_str(vertical_align)
5629 ));
5630 }
5631 }
5632
5633 let tc_pr = if cell_props.is_empty() {
5634 String::new()
5635 } else {
5636 format!("<w:tcPr>{}</w:tcPr>", cell_props.join(""))
5637 };
5638 format!(
5639 "<w:tc>{}{}</w:tc>",
5640 tc_pr,
5641 if serialized_cells.is_empty() {
5642 "<w:p><w:r><w:t/></w:r></w:p>"
5643 } else {
5644 &serialized_cells
5645 }
5646 )
5647 })
5648 .collect::<String>();
5649
5650 let tr_pr = if row_props.is_empty() {
5651 String::new()
5652 } else {
5653 format!("<w:trPr>{}</w:trPr>", row_props.join(""))
5654 };
5655 format!("<w:tr>{tr_pr}{cells}</w:tr>")
5656 })
5657 .collect::<String>();
5658
5659 state.current_table_text_alignment = previous_table_alignment;
5660 state.current_table_snap_to_grid = previous_table_snap;
5661 format!(
5662 "<w:tbl><w:tblPr>{}</w:tblPr>{table_grid_xml}{rows}</w:tbl>",
5663 table_props.join("")
5664 )
5665}
5666
5667const EMPTY_RELATIONSHIPS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
5668
5669fn create_image_serialization_state<'a>(
5670 base_package: &'a mut OoxmlPackage,
5671 owner_part_name: &str,
5672) -> ImageSerializationState<'a> {
5673 let relationship_part_name = relationship_part_name_for_word_part(owner_part_name);
5674 let relationships_xml = base_package
5675 .parts
5676 .get(&relationship_part_name)
5677 .map(|part| part.content.as_str())
5678 .unwrap_or(EMPTY_RELATIONSHIPS_XML);
5679
5680 let relationships = parse_relationships_xml(relationships_xml);
5681 let relationship_by_target = relationships
5682 .iter()
5683 .map(|relationship| (relationship.target.clone(), relationship.clone()))
5684 .collect::<HashMap<_, _>>();
5685
5686 let used_image_numbers = base_package
5687 .binary_assets
5688 .keys()
5689 .filter_map(|part_name| {
5690 let lower = part_name.to_ascii_lowercase();
5691 let prefix = "word/media/image";
5692 if !lower.starts_with(prefix) {
5693 return None;
5694 }
5695 let rest = &lower[prefix.len()..];
5696 let dot = rest.find('.')?;
5697 rest[..dot].parse::<i64>().ok()
5698 })
5699 .collect::<Vec<_>>();
5700
5701 let next_image_index = used_image_numbers.iter().copied().max().unwrap_or(0).max(0) + 1;
5702
5703 let styles = crate::parse::parse_style_sheet(base_package);
5704 let paragraph_style_grid_owners = styles
5705 .paragraph_style_by_id
5706 .iter()
5707 .filter(|(_, style)| style.source_has_snap_to_grid == Some(true))
5708 .filter_map(|(id, style)| style.snap_to_grid.map(|value| (id.clone(), value)))
5709 .collect();
5710 let paragraph_snap_to_grid_by_style_id = styles
5711 .paragraph_style_by_id
5712 .iter()
5713 .filter_map(|(id, style)| style.snap_to_grid.map(|value| (id.clone(), value)))
5714 .collect();
5715 let default_paragraph_snap_to_grid = styles
5716 .default_paragraph_style
5717 .as_ref()
5718 .and_then(|style| style.snap_to_grid);
5719 let paragraph_style_alignment_owners = styles
5720 .paragraph_style_by_id
5721 .iter()
5722 .filter(|(_, style)| style.source_has_text_alignment == Some(true))
5723 .filter_map(|(id, style)| style.text_alignment.map(|value| (id.clone(), value)))
5724 .collect();
5725 let table_styles = styles.table_style_by_id;
5726 let paragraph_text_alignment_by_style_id = styles
5727 .paragraph_style_by_id
5728 .into_iter()
5729 .filter_map(|(id, style)| style.text_alignment.map(|value| (id, value)))
5730 .collect();
5731 let default_paragraph_text_alignment = styles
5732 .default_paragraph_style
5733 .as_ref()
5734 .and_then(|style| style.text_alignment)
5735 .or(styles.default_paragraph_text_alignment);
5736 ImageSerializationState {
5737 owner_part_name: owner_part_name.to_string(),
5738 paragraph_style_grid_owners,
5739 paragraph_snap_to_grid_by_style_id,
5740 default_paragraph_snap_to_grid,
5741 current_table_snap_to_grid: None,
5742 paragraph_style_alignment_owners,
5743 table_styles,
5744 current_table_text_alignment: None,
5745 paragraph_text_alignment_by_style_id,
5746 default_paragraph_text_alignment,
5747 next_image_index: next_image_index.max(1),
5748 next_relationship_index: next_relationship_index(&relationships),
5749 relationships,
5750 relationship_by_target,
5751 pkg: base_package,
5752 theme_fonts: OnceCell::new(),
5753 }
5754}
5755
5756fn has_xmlns_prefix(open_tag: &str, prefix: &str) -> bool {
5757 let patterns = [format!(" xmlns:{prefix}=\""), format!(" xmlns:{prefix}='")];
5758 patterns.iter().any(|pattern| {
5759 open_tag
5760 .as_bytes()
5761 .windows(pattern.len())
5762 .any(|window| window.eq_ignore_ascii_case(pattern.as_bytes()))
5763 })
5764}
5765
5766fn ensure_namespace(open_tag: &str, prefix: &str, namespace: &str) -> String {
5767 if has_xmlns_prefix(open_tag, prefix) {
5768 return open_tag.to_string();
5769 }
5770
5771 if open_tag.ends_with('>') {
5772 format!(
5773 "{} xmlns:{prefix}=\"{namespace}\">",
5774 &open_tag[..open_tag.len() - 1]
5775 )
5776 } else {
5777 open_tag.to_string()
5778 }
5779}
5780
5781fn ensure_ignorable_prefix(open_tag: &str, prefix: &str) -> String {
5782 let lower = open_tag.to_ascii_lowercase();
5783 let marker = " mc:ignorable=";
5784 if let Some(start) = lower.find(marker) {
5785 let quote_byte = open_tag.as_bytes()[start + marker.len()];
5786 let quote = quote_byte as char;
5787 let value_start = start + marker.len() + 1;
5788 let value_end = open_tag[value_start..]
5789 .find(quote)
5790 .map(|index| value_start + index)
5791 .unwrap_or(open_tag.len());
5792 let current_value = &open_tag[value_start..value_end];
5793 let tokens: Vec<&str> = current_value
5794 .split_whitespace()
5795 .map(str::trim)
5796 .filter(|token| !token.is_empty())
5797 .collect();
5798 if tokens.iter().any(|token| *token == prefix) {
5799 return open_tag.to_string();
5800 }
5801 let updated_value = if tokens.is_empty() {
5802 prefix.to_string()
5803 } else {
5804 format!("{} {}", tokens.join(" "), prefix)
5805 };
5806 return format!(
5807 "{} mc:Ignorable={}{}{}{}",
5808 &open_tag[..start],
5809 quote,
5810 updated_value,
5811 quote,
5812 &open_tag[value_end + 1..]
5813 );
5814 }
5815
5816 if open_tag.ends_with('>') {
5817 format!(
5818 "{} mc:Ignorable=\"{prefix}\">",
5819 &open_tag[..open_tag.len() - 1]
5820 )
5821 } else {
5822 open_tag.to_string()
5823 }
5824}
5825
5826fn ensure_word_section_part_open_tag(
5827 existing_part_content: Option<&str>,
5828 root_tag_name: &str,
5829) -> String {
5830 let existing_open_tag = existing_part_content.and_then(|content| {
5831 let pattern = format!("<{root_tag_name}");
5832 let start = content.find(&pattern)?;
5833 let end = content[start..].find('>')?;
5834 Some(content[start..start + end + 1].to_string())
5835 });
5836
5837 let mut open_tag = existing_open_tag.unwrap_or_else(|| {
5838 format!(r#"<{root_tag_name} xmlns:w="{WORD_MAIN_NS}" xmlns:r="{OFFICE_REL_NS}">"#)
5839 });
5840
5841 open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
5842 open_tag = ensure_namespace(&open_tag, "r", OFFICE_REL_NS);
5843 open_tag = ensure_namespace(&open_tag, "a", DRAWING_MAIN_NS);
5844 open_tag = ensure_namespace(&open_tag, "wp", DRAWING_WORD_NS);
5845 open_tag = ensure_namespace(&open_tag, "pic", DRAWING_PICTURE_NS);
5846 open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
5847 open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
5848 open_tag = ensure_ignorable_prefix(&open_tag, "w14");
5849 open_tag
5850}
5851
5852fn serialize_section_part_xml(
5853 section_nodes: &[DocNode],
5854 root_tag_name: &str,
5855 part_name: &str,
5856 pkg: &mut OoxmlPackage,
5857) -> (String, Vec<Relationship>) {
5858 let existing_content = pkg.parts.get(part_name).map(|part| part.content.clone());
5859 let (body_xml, relationships) = {
5860 let mut state = create_image_serialization_state(pkg, part_name);
5861 let mut run_id_ref = 1i64;
5862 let body_xml = section_nodes
5863 .iter()
5864 .map(|node| match node {
5865 DocNode::Paragraph(paragraph) => {
5866 paragraph_xml(paragraph, &mut state, &mut run_id_ref)
5867 }
5868 DocNode::Table(table) => table_xml(table, &mut state, &mut run_id_ref),
5869 })
5870 .collect::<String>();
5871 (body_xml, state.relationships)
5872 };
5873 let open_tag = ensure_word_section_part_open_tag(existing_content.as_deref(), root_tag_name);
5874 let xml = format!(
5875 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>{open_tag}{body_xml}</{root_tag_name}>"#
5876 );
5877 (xml, relationships)
5878}
5879
5880fn section_root_tag_name_for_part_name(part_name: &str) -> &'static str {
5881 if part_name.to_ascii_lowercase().contains("header") {
5882 "w:hdr"
5883 } else {
5884 "w:ftr"
5885 }
5886}
5887
5888fn serialize_header_footer_parts(model: &DocModel, pkg: &mut OoxmlPackage) {
5889 let mut sections_by_part_name: HashMap<String, Vec<DocNode>> = HashMap::new();
5890
5891 let mut add_part_sections = |sections: &[(String, Vec<DocNode>)]| {
5892 for (part_name, nodes) in sections {
5893 let normalized_part_name = normalize_word_part_name(part_name);
5894 if normalized_part_name.is_empty()
5895 || sections_by_part_name.contains_key(&normalized_part_name)
5896 {
5897 continue;
5898 }
5899 sections_by_part_name.insert(normalized_part_name, nodes.clone());
5900 }
5901 };
5902
5903 add_part_sections(
5904 &model
5905 .metadata
5906 .header_sections
5907 .iter()
5908 .map(|section| (section.part_name.clone(), section.nodes.clone()))
5909 .collect::<Vec<_>>(),
5910 );
5911 add_part_sections(
5912 &model
5913 .metadata
5914 .footer_sections
5915 .iter()
5916 .map(|section| (section.part_name.clone(), section.nodes.clone()))
5917 .collect::<Vec<_>>(),
5918 );
5919 if let Some(sections) = &model.metadata.sections {
5920 for section in sections {
5921 add_part_sections(
5922 §ion
5923 .header_sections
5924 .iter()
5925 .map(|header| (header.part_name.clone(), header.nodes.clone()))
5926 .collect::<Vec<_>>(),
5927 );
5928 add_part_sections(
5929 §ion
5930 .footer_sections
5931 .iter()
5932 .map(|footer| (footer.part_name.clone(), footer.nodes.clone()))
5933 .collect::<Vec<_>>(),
5934 );
5935 }
5936 }
5937
5938 let part_names: Vec<String> = sections_by_part_name.keys().cloned().collect();
5939 for part_name in part_names {
5940 let nodes = sections_by_part_name.remove(&part_name).unwrap_or_default();
5941 let root_tag_name = section_root_tag_name_for_part_name(&part_name);
5942 let (xml, relationships) =
5943 serialize_section_part_xml(&nodes, root_tag_name, &part_name, pkg);
5944 pkg.parts.insert(
5945 part_name.clone(),
5946 OoxmlPart {
5947 name: part_name.clone(),
5948 content: xml,
5949 },
5950 );
5951 let relationship_part_name = relationship_part_name_for_word_part(&part_name);
5952 pkg.parts.insert(
5953 relationship_part_name.clone(),
5954 OoxmlPart {
5955 name: relationship_part_name,
5956 content: render_relationships_xml(&relationships),
5957 },
5958 );
5959 }
5960}
5961
5962fn opening_tag_end(xml: &str, start: usize) -> Option<usize> {
5963 let mut quote: Option<u8> = None;
5964 for (offset, byte) in xml.as_bytes().iter().copied().enumerate().skip(start) {
5965 if let Some(active_quote) = quote {
5966 if byte == active_quote {
5967 quote = None;
5968 }
5969 continue;
5970 }
5971 if byte == b'\'' || byte == b'"' {
5972 quote = Some(byte);
5973 continue;
5974 }
5975 if byte == b'>' {
5976 return Some(offset + 1);
5977 }
5978 }
5979 None
5980}
5981
5982fn attribute_value_range(open_tag: &str, attribute_name: &str) -> Option<(usize, usize)> {
5983 let bytes = open_tag.as_bytes();
5984 let name = attribute_name.as_bytes();
5985 let mut index = 1usize;
5986 while index + name.len() <= bytes.len() {
5987 let matches_name = bytes[index..index + name.len()]
5988 .iter()
5989 .zip(name.iter())
5990 .all(|(left, right)| left.eq_ignore_ascii_case(right));
5991 let before_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
5992 if !matches_name || !before_boundary {
5993 index += 1;
5994 continue;
5995 }
5996 let mut cursor = index + name.len();
5997 while bytes
5998 .get(cursor)
5999 .is_some_and(|byte| byte.is_ascii_whitespace())
6000 {
6001 cursor += 1;
6002 }
6003 if bytes.get(cursor) != Some(&b'=') {
6004 index += 1;
6005 continue;
6006 }
6007 cursor += 1;
6008 while bytes
6009 .get(cursor)
6010 .is_some_and(|byte| byte.is_ascii_whitespace())
6011 {
6012 cursor += 1;
6013 }
6014 let quote = *bytes.get(cursor)?;
6015 if quote != b'\'' && quote != b'"' {
6016 index += 1;
6017 continue;
6018 }
6019 let value_start = cursor + 1;
6020 let value_end = bytes[value_start..]
6021 .iter()
6022 .position(|byte| *byte == quote)
6023 .map(|offset| value_start + offset)?;
6024 return Some((value_start, value_end));
6025 }
6026 None
6027}
6028
6029fn set_xml_attribute(open_tag: &str, name: &str, value: &str) -> String {
6030 let escaped = escape_xml(value);
6031 if let Some((value_start, value_end)) = attribute_value_range(open_tag, name) {
6032 let mut updated = open_tag.to_string();
6033 updated.replace_range(value_start..value_end, &escaped);
6034 return updated;
6035 }
6036
6037 let mut insert_at = open_tag.len().saturating_sub(1);
6038 while insert_at > 0 && open_tag.as_bytes()[insert_at - 1].is_ascii_whitespace() {
6039 insert_at -= 1;
6040 }
6041 if insert_at > 0 && open_tag.as_bytes()[insert_at - 1] == b'/' {
6042 insert_at -= 1;
6043 }
6044 let mut updated = open_tag.to_string();
6045 updated.insert_str(insert_at, &format!(r#" {name}="{escaped}""#));
6046 updated
6047}
6048
6049fn deterministic_comment_paragraph_id(comment_id: i64) -> String {
6050 let normalized = comment_id.max(0) as u64;
6051 format!("{:08X}", (0xC000_0000u64 + normalized) & 0xFFFF_FFFF)
6052}
6053
6054fn source_comment_paragraph_id(source_xml: &str) -> Option<String> {
6055 let range = extract_balanced_tag_ranges(source_xml, "w:p")
6056 .into_iter()
6057 .last()?;
6058 let open_end = opening_tag_end(source_xml, range.start)?;
6059 get_attribute(&source_xml[range.start..open_end], "w14:paraId")
6060}
6061
6062fn ensure_comment_paragraph_id(source_xml: &str, paragraph_id: &str) -> String {
6063 let Some(range) = extract_balanced_tag_ranges(source_xml, "w:p")
6064 .into_iter()
6065 .last()
6066 else {
6067 return source_xml.to_string();
6068 };
6069 let Some(open_end) = opening_tag_end(source_xml, range.start) else {
6070 return source_xml.to_string();
6071 };
6072 let open_tag = &source_xml[range.start..open_end];
6073 let updated_open_tag = set_xml_attribute(open_tag, "w14:paraId", paragraph_id);
6074 format!(
6075 "{}{}{}",
6076 &source_xml[..range.start],
6077 updated_open_tag,
6078 &source_xml[open_end..]
6079 )
6080}
6081
6082fn comment_paragraph_id(comment: &DocumentCommentDefinition) -> String {
6083 comment
6084 .extended_paragraph_id
6085 .clone()
6086 .or_else(|| {
6087 comment
6088 .source_xml
6089 .as_deref()
6090 .and_then(source_comment_paragraph_id)
6091 })
6092 .unwrap_or_else(|| deterministic_comment_paragraph_id(comment.id))
6093}
6094
6095fn render_comment_block(
6096 comment: &DocumentCommentDefinition,
6097 ensure_paragraph_id: bool,
6098) -> (String, String) {
6099 let paragraph_id = comment_paragraph_id(comment);
6100 if let Some(source_xml) = &comment.source_xml {
6101 let rendered = if ensure_paragraph_id {
6102 ensure_comment_paragraph_id(source_xml, ¶graph_id)
6103 } else {
6104 source_xml.clone()
6105 };
6106 return (rendered, paragraph_id);
6107 }
6108
6109 let mut attributes = vec![format!(r#"w:id="{}""#, comment.id)];
6110 if let Some(author) = &comment.author {
6111 attributes.push(format!(r#"w:author="{}""#, escape_xml(author)));
6112 }
6113 if let Some(initials) = &comment.initials {
6114 attributes.push(format!(r#"w:initials="{}""#, escape_xml(initials)));
6115 }
6116 if let Some(date) = &comment.date {
6117 attributes.push(format!(r#"w:date="{}""#, escape_xml(date)));
6118 }
6119 let preserve = if should_preserve_whitespace(&comment.text) {
6120 r#" xml:space="preserve""#
6121 } else {
6122 ""
6123 };
6124 (
6125 format!(
6126 r#"<w:comment {}><w:p w14:paraId="{}"><w:r><w:t{}>{}</w:t></w:r></w:p></w:comment>"#,
6127 attributes.join(" "),
6128 escape_xml(¶graph_id),
6129 preserve,
6130 escape_xml(&comment.text)
6131 ),
6132 paragraph_id,
6133 )
6134}
6135
6136fn comment_id_from_block(block: &str) -> Option<i64> {
6137 let open_end = opening_tag_end(block, 0)?;
6138 get_attribute(&block[..open_end], "w:id")?.parse().ok()
6139}
6140
6141fn replace_xml_ranges(source: &str, mut replacements: Vec<(usize, usize, String)>) -> String {
6142 replacements.sort_by(|left, right| right.0.cmp(&left.0));
6143 let mut updated = source.to_string();
6144 for (start, end, value) in replacements {
6145 updated.replace_range(start..end, &value);
6146 }
6147 updated
6148}
6149
6150fn ensure_comments_root_namespaces(xml: &str) -> String {
6151 let Some(root) = extract_balanced_tag_ranges(xml, "w:comments")
6152 .into_iter()
6153 .next()
6154 else {
6155 return xml.to_string();
6156 };
6157 let Some(open_end) = opening_tag_end(xml, root.start) else {
6158 return xml.to_string();
6159 };
6160 let mut open_tag = xml[root.start..open_end].to_string();
6161 open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
6162 open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
6163 open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
6164 open_tag = ensure_ignorable_prefix(&open_tag, "w14");
6165 format!("{}{}{}", &xml[..root.start], open_tag, &xml[open_end..])
6166}
6167
6168fn merge_comments_xml(existing: Option<&str>, rendered_by_id: &HashMap<i64, String>) -> String {
6169 if let Some(existing) = existing {
6170 let ranges = extract_balanced_tag_ranges(existing, "w:comment");
6171 let mut seen = std::collections::HashSet::new();
6172 let replacements = ranges
6173 .into_iter()
6174 .filter_map(|range| {
6175 let block = &existing[range.start..range.end];
6176 let id = comment_id_from_block(block)?;
6177 let rendered = rendered_by_id.get(&id)?;
6178 seen.insert(id);
6179 Some((range.start, range.end, rendered.clone()))
6180 })
6181 .collect::<Vec<_>>();
6182 let mut merged = replace_xml_ranges(existing, replacements);
6183 let mut missing = rendered_by_id
6184 .iter()
6185 .filter(|(id, _)| !seen.contains(id))
6186 .collect::<Vec<_>>();
6187 missing.sort_by_key(|(id, _)| **id);
6188 if !missing.is_empty() {
6189 let appended = missing
6190 .into_iter()
6191 .map(|(_, block)| block.as_str())
6192 .collect::<String>();
6193 if let Some(close_start) = merged.rfind("</w:comments>") {
6194 merged.insert_str(close_start, &appended);
6195 }
6196 }
6197 return ensure_comments_root_namespaces(&merged);
6198 }
6199
6200 let mut rendered = rendered_by_id.iter().collect::<Vec<_>>();
6201 rendered.sort_by_key(|(id, _)| **id);
6202 let blocks = rendered
6203 .into_iter()
6204 .map(|(_, block)| block.as_str())
6205 .collect::<String>();
6206 format!(
6207 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>"#
6208 )
6209}
6210
6211#[derive(Clone)]
6212struct CommentExtendedState {
6213 done: Option<bool>,
6214 parent_paragraph_id: Option<String>,
6215 patch_done: bool,
6216 patch_parent: bool,
6217}
6218
6219fn patch_comment_extended_block(block: &str, state: &CommentExtendedState, force: bool) -> String {
6220 let Some(open_end) = opening_tag_end(block, 0) else {
6221 return block.to_string();
6222 };
6223 let mut open_tag = block[..open_end].to_string();
6224 if (force || state.patch_done) && state.done.is_some() {
6225 let done = state.done.unwrap_or(false);
6226 open_tag = set_xml_attribute(&open_tag, "w15:done", if done { "1" } else { "0" });
6227 }
6228 if (force || state.patch_parent) && state.parent_paragraph_id.is_some() {
6229 let parent_id = state.parent_paragraph_id.as_deref().unwrap_or_default();
6230 open_tag = set_xml_attribute(&open_tag, "w15:paraIdParent", parent_id);
6231 }
6232 format!("{}{}", open_tag, &block[open_end..])
6233}
6234
6235fn merge_comments_extended_xml(
6236 existing: Option<&str>,
6237 desired: &HashMap<String, CommentExtendedState>,
6238) -> Option<String> {
6239 if desired.is_empty() {
6240 return existing.map(str::to_string);
6241 }
6242 if let Some(existing) = existing {
6243 let ranges = extract_balanced_tag_ranges(existing, "w15:commentEx");
6244 let mut seen = std::collections::HashSet::new();
6245 let replacements = ranges
6246 .into_iter()
6247 .filter_map(|range| {
6248 let block = &existing[range.start..range.end];
6249 let open_end = opening_tag_end(block, 0)?;
6250 let paragraph_id = get_attribute(&block[..open_end], "w15:paraId")?;
6251 let state = desired.get(¶graph_id)?;
6252 seen.insert(paragraph_id);
6253 Some((
6254 range.start,
6255 range.end,
6256 patch_comment_extended_block(block, state, false),
6257 ))
6258 })
6259 .collect::<Vec<_>>();
6260 let mut merged = replace_xml_ranges(existing, replacements);
6261 let mut missing = desired
6262 .iter()
6263 .filter(|(paragraph_id, _)| !seen.contains(*paragraph_id))
6264 .collect::<Vec<_>>();
6265 missing.sort_by_key(|(paragraph_id, _)| *paragraph_id);
6266 let appended = missing
6267 .into_iter()
6268 .map(|(paragraph_id, state)| {
6269 let mut tag = format!(
6270 r#"<w15:commentEx w15:paraId="{}"/>"#,
6271 escape_xml(paragraph_id)
6272 );
6273 tag = patch_comment_extended_block(&tag, state, true);
6274 tag
6275 })
6276 .collect::<String>();
6277 if let Some(close_start) = merged.rfind("</w15:commentsEx>") {
6278 merged.insert_str(close_start, &appended);
6279 }
6280 return Some(merged);
6281 }
6282
6283 let mut states = desired.iter().collect::<Vec<_>>();
6284 states.sort_by_key(|(paragraph_id, _)| *paragraph_id);
6285 let entries = states
6286 .into_iter()
6287 .map(|(paragraph_id, state)| {
6288 let tag = format!(
6289 r#"<w15:commentEx w15:paraId="{}"/>"#,
6290 escape_xml(paragraph_id)
6291 );
6292 patch_comment_extended_block(&tag, state, true)
6293 })
6294 .collect::<String>();
6295 Some(format!(
6296 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:commentsEx xmlns:w15="{WORD_2012_NS}">{entries}</w15:commentsEx>"#
6297 ))
6298}
6299
6300fn ensure_document_relationship(pkg: &mut OoxmlPackage, relationship_type: &str, target: &str) {
6301 let resolved_part_name =
6302 relationship_part_name_for_word_part(&crate::package::main_document_part_name(pkg));
6303 let part_name = resolved_part_name.as_str();
6304 let existing = pkg
6305 .parts
6306 .get(part_name)
6307 .map(|part| part.content.clone())
6308 .unwrap_or_else(|| EMPTY_RELATIONSHIPS_XML.to_string());
6309 let relationships = parse_relationships_xml(&existing);
6310 if relationships.iter().any(|relationship| {
6311 relationship.r#type == relationship_type
6312 && crate::parse::resolve_part_path(
6313 &crate::package::main_document_part_name(pkg),
6314 &relationship.target,
6315 ) == crate::parse::resolve_part_path(
6316 &crate::package::main_document_part_name(pkg),
6317 target,
6318 )
6319 }) {
6320 return;
6321 }
6322 let relationship = format!(
6323 r#"<Relationship Id="rId{}" Type="{}" Target="{}"/>"#,
6324 next_relationship_index(&relationships),
6325 escape_xml(relationship_type),
6326 escape_xml(target)
6327 );
6328 let updated = if let Some(close_start) = existing.rfind("</Relationships>") {
6329 format!(
6330 "{}{}{}",
6331 &existing[..close_start],
6332 relationship,
6333 &existing[close_start..]
6334 )
6335 } else if let Some(root_start) = existing.find("<Relationships") {
6336 if let Some(root_end) = opening_tag_end(&existing, root_start) {
6337 let root_tag = &existing[root_start..root_end];
6338 if root_tag.trim_end().ends_with("/>") {
6339 let slash = root_tag
6340 .rfind('/')
6341 .unwrap_or(root_tag.len().saturating_sub(1));
6342 format!(
6343 "{}{}>{}</Relationships>{}",
6344 &existing[..root_start],
6345 &root_tag[..slash],
6346 relationship,
6347 &existing[root_end..]
6348 )
6349 } else {
6350 existing
6351 }
6352 } else {
6353 existing
6354 }
6355 } else {
6356 existing
6357 };
6358 pkg.parts.insert(
6359 part_name.to_string(),
6360 OoxmlPart {
6361 name: part_name.to_string(),
6362 content: updated,
6363 },
6364 );
6365}
6366
6367fn serialize_comment_parts(model: &DocModel, pkg: &mut OoxmlPackage) {
6368 let source_part_name = crate::package::related_document_part_name(pkg, "comments");
6369 let Some(comments) = model
6370 .metadata
6371 .comments
6372 .as_ref()
6373 .filter(|comments| !comments.is_empty())
6374 else {
6375 return;
6376 };
6377
6378 let extended_comment_ids = comments
6379 .iter()
6380 .filter(|comment| {
6381 comment.resolved.is_some()
6382 || comment.source_resolved.is_some()
6383 || comment.resolution_dirty == Some(true)
6384 || comment.is_new == Some(true)
6385 || comment.parent_id.is_some()
6386 })
6387 .map(|comment| comment.id)
6388 .chain(comments.iter().filter_map(|comment| comment.parent_id))
6389 .collect::<std::collections::HashSet<_>>();
6390 let paragraph_id_by_comment_id = comments
6391 .iter()
6392 .map(|comment| (comment.id, comment_paragraph_id(comment)))
6393 .collect::<HashMap<_, _>>();
6394 let rendered_by_id = comments
6395 .iter()
6396 .map(|comment| {
6397 let (rendered, _) =
6398 render_comment_block(comment, extended_comment_ids.contains(&comment.id));
6399 (comment.id, rendered)
6400 })
6401 .collect::<HashMap<_, _>>();
6402
6403 let comments_xml = merge_comments_xml(
6404 pkg.parts
6405 .get(&source_part_name)
6406 .map(|part| part.content.as_str()),
6407 &rendered_by_id,
6408 );
6409 pkg.parts.insert(
6410 source_part_name.clone(),
6411 OoxmlPart {
6412 name: source_part_name.clone(),
6413 content: comments_xml,
6414 },
6415 );
6416
6417 let desired_extended = comments
6418 .iter()
6419 .filter(|comment| extended_comment_ids.contains(&comment.id))
6420 .filter_map(|comment| {
6421 let paragraph_id = paragraph_id_by_comment_id.get(&comment.id)?.clone();
6422 let parent_paragraph_id = comment
6423 .parent_id
6424 .and_then(|parent_id| paragraph_id_by_comment_id.get(&parent_id).cloned());
6425 Some((
6426 paragraph_id,
6427 CommentExtendedState {
6428 done: comment.resolved.or(comment.source_resolved),
6429 parent_paragraph_id,
6430 patch_done: comment.resolution_dirty == Some(true)
6431 || comment.is_new == Some(true),
6432 patch_parent: comment.is_new == Some(true),
6433 },
6434 ))
6435 })
6436 .collect::<HashMap<_, _>>();
6437 if let Some(comments_extended_xml) = merge_comments_extended_xml(
6438 pkg.parts
6439 .get("word/commentsExtended.xml")
6440 .map(|part| part.content.as_str()),
6441 &desired_extended,
6442 ) {
6443 pkg.parts.insert(
6444 "word/commentsExtended.xml".to_string(),
6445 OoxmlPart {
6446 name: "word/commentsExtended.xml".to_string(),
6447 content: comments_extended_xml,
6448 },
6449 );
6450 }
6451
6452 ensure_content_type_override(pkg, &source_part_name, COMMENTS_CONTENT_TYPE);
6453 ensure_document_relationship(pkg, REL_TYPE_COMMENTS, &format!("/{source_part_name}"));
6454 if pkg.parts.contains_key("word/commentsExtended.xml") {
6455 ensure_content_type_override(
6456 pkg,
6457 "word/commentsExtended.xml",
6458 COMMENTS_EXTENDED_CONTENT_TYPE,
6459 );
6460 ensure_document_relationship(pkg, REL_TYPE_COMMENTS_EXTENDED, "commentsExtended.xml");
6461 }
6462}
6463
6464fn upsert_settings_element(
6465 xml: &str,
6466 container_tag: &str,
6467 tag_name: &str,
6468 attributes: &[(&str, &str)],
6469 matches: impl Fn(&str) -> bool,
6470) -> String {
6471 for range in extract_balanced_tag_ranges(xml, tag_name) {
6472 let Some(open_end) = opening_tag_end(xml, range.start) else {
6473 continue;
6474 };
6475 let opening_tag = &xml[range.start..open_end];
6476 if !matches(opening_tag) {
6477 continue;
6478 }
6479 let updated_tag = attributes
6480 .iter()
6481 .fold(opening_tag.to_string(), |tag, (name, value)| {
6482 set_xml_attribute(&tag, name, value)
6483 });
6484 let mut updated = xml.to_string();
6485 updated.replace_range(range.start..open_end, &updated_tag);
6486 return updated;
6487 }
6488
6489 let attributes_xml = attributes
6490 .iter()
6491 .map(|(name, value)| format!(r#" {name}="{}""#, escape_xml(value)))
6492 .collect::<String>();
6493 let child_xml = format!("<{tag_name}{attributes_xml}/>");
6494 let Some(container_range) = extract_balanced_tag_ranges(xml, container_tag)
6495 .into_iter()
6496 .next()
6497 else {
6498 return xml.to_string();
6499 };
6500 let Some(open_end) = opening_tag_end(xml, container_range.start) else {
6501 return xml.to_string();
6502 };
6503 let opening_tag = &xml[container_range.start..open_end];
6504 let mut updated = xml.to_string();
6505 if opening_tag.trim_end().ends_with("/>") {
6506 let expanded = format!(
6507 "{}>{child_xml}</{container_tag}>",
6508 opening_tag
6509 .trim_end()
6510 .trim_end_matches('>')
6511 .trim_end()
6512 .trim_end_matches('/')
6513 .trim_end(),
6514 );
6515 updated.replace_range(container_range.start..container_range.end, &expanded);
6516 return updated;
6517 }
6518 let closing_tag = format!("</{container_tag}>");
6519 let Some(close_start) = xml[container_range.start..container_range.end].rfind(&closing_tag)
6520 else {
6521 return xml.to_string();
6522 };
6523 let mut insert_at = container_range.start + close_start;
6524 if container_tag == "w:settings" {
6525 if let Some(index) = SETTINGS_TAIL_ORDER
6526 .iter()
6527 .position(|name| *name == tag_name)
6528 {
6529 for following_tag in &SETTINGS_TAIL_ORDER[index + 1..] {
6530 if let Some(range) = extract_balanced_tag_ranges(xml, following_tag)
6531 .into_iter()
6532 .next()
6533 {
6534 if range.start >= open_end && range.start < insert_at {
6535 insert_at = range.start;
6536 }
6537 }
6538 }
6539 }
6540 }
6541 updated.insert_str(insert_at, &child_xml);
6542 updated
6543}
6544
6545fn remove_settings_element(xml: &str, tag_name: &str, matches: impl Fn(&str) -> bool) -> String {
6546 let mut updated = xml.to_string();
6547 for range in extract_balanced_tag_ranges(xml, tag_name).into_iter().rev() {
6548 let Some(open_end) = opening_tag_end(xml, range.start) else {
6549 continue;
6550 };
6551 if matches(&xml[range.start..open_end]) {
6552 updated.replace_range(range.start..range.end, "");
6553 }
6554 }
6555 updated
6556}
6557
6558const COMPAT_PROPERTIES_ORDER: &[&str] = &[
6559 "w:useSingleBorderforContiguousCells",
6560 "w:wpJustification",
6561 "w:noTabHangInd",
6562 "w:noLeading",
6563 "w:spaceForUL",
6564 "w:noColumnBalance",
6565 "w:balanceSingleByteDoubleByteWidth",
6566 "w:noExtraLineSpacing",
6567 "w:doNotLeaveBackslashAlone",
6568 "w:ulTrailSpace",
6569 "w:doNotExpandShiftReturn",
6570 "w:spacingInWholePoints",
6571 "w:lineWrapLikeWord6",
6572 "w:printBodyTextBeforeHeader",
6573 "w:printColBlack",
6574 "w:wpSpaceWidth",
6575 "w:showBreaksInFrames",
6576 "w:subFontBySize",
6577 "w:suppressBottomSpacing",
6578 "w:suppressTopSpacing",
6579 "w:suppressSpacingAtTopOfPage",
6580 "w:suppressTopSpacingWP",
6581 "w:suppressSpBfAfterPgBrk",
6582 "w:swapBordersFacingPages",
6583 "w:convMailMergeEsc",
6584 "w:truncateFontHeightsLikeWP6",
6585 "w:mwSmallCaps",
6586 "w:usePrinterMetrics",
6587 "w:doNotSuppressParagraphBorders",
6588 "w:wrapTrailSpaces",
6589 "w:footnoteLayoutLikeWW8",
6590 "w:shapeLayoutLikeWW8",
6591 "w:alignTablesRowByRow",
6592 "w:forgetLastTabAlignment",
6593 "w:adjustLineHeightInTable",
6594 "w:autoSpaceLikeWord95",
6595 "w:noSpaceRaiseLower",
6596 "w:doNotUseHTMLParagraphAutoSpacing",
6597 "w:layoutRawTableWidth",
6598 "w:layoutTableRowsApart",
6599 "w:useWord97LineBreakRules",
6600 "w:doNotBreakWrappedTables",
6601 "w:doNotSnapToGridInCell",
6602 "w:selectFldWithFirstOrLastChar",
6603 "w:applyBreakingRules",
6604 "w:doNotWrapTextWithPunct",
6605 "w:doNotUseEastAsianBreakRules",
6606 "w:useWord2002TableStyleRules",
6607 "w:growAutofit",
6608 "w:useFELayout",
6609 "w:useNormalStyleForList",
6610 "w:doNotUseIndentAsNumberingTabStop",
6611 "w:useAltKinsokuLineBreakRules",
6612 "w:allowSpaceOfSameStyleInTable",
6613 "w:doNotSuppressIndentation",
6614 "w:doNotAutofitConstrainedTables",
6615 "w:autofitToFirstFixedWidthCell",
6616 "w:underlineTabInNumList",
6617 "w:displayHangulFixedWidth",
6618 "w:splitPgBreakAndParaMark",
6619 "w:doNotVertAlignCellWithSp",
6620 "w:doNotBreakConstrainedForcedTable",
6621 "w:doNotVertAlignInTxbx",
6622 "w:useAnsiKerningPairs",
6623 "w:cachedColBalance",
6624 "w:compatSetting",
6625];
6626
6627fn serialize_document_settings(model: &DocModel, pkg: &mut OoxmlPackage) {
6628 let source_part_name = crate::package::related_document_part_name(pkg, "settings");
6629 let original_compatibility = crate::parse::parse_document_compatibility_settings(pkg);
6630 let original_tab_stop = crate::parse::parse_document_default_tab_stop_twips(pkg);
6631 let compatibility = model.metadata.compatibility.as_ref();
6632 let raw_compatibility_mode = compatibility.and_then(|settings| settings.compatibility_mode);
6633 let compatibility_mode = raw_compatibility_mode.filter(|mode| u32::try_from(*mode).is_ok());
6634 let enable_open_type_features =
6635 compatibility.and_then(|settings| settings.enable_open_type_features);
6636 let even_and_odd_headers = compatibility.and_then(|settings| settings.even_and_odd_headers);
6637 let default_tab_stop = model
6638 .metadata
6639 .default_tab_stop_twips
6640 .filter(|value| *value >= 0);
6641 let imported_settings = model.metadata.document_settings_imported == Some(true);
6642 let mode_changed = (compatibility_mode.is_some()
6643 || (imported_settings && raw_compatibility_mode.is_none()))
6644 && compatibility_mode
6645 != original_compatibility
6646 .as_ref()
6647 .and_then(|settings| settings.compatibility_mode);
6648 let headers_changed = (even_and_odd_headers.is_some() || imported_settings)
6649 && even_and_odd_headers
6650 != original_compatibility
6651 .as_ref()
6652 .and_then(|settings| settings.even_and_odd_headers);
6653 let open_type_changed = (enable_open_type_features.is_some() || imported_settings)
6654 && enable_open_type_features
6655 != original_compatibility
6656 .as_ref()
6657 .and_then(|settings| settings.enable_open_type_features);
6658 let tabs_changed = (default_tab_stop.is_some()
6659 || (imported_settings && model.metadata.default_tab_stop_twips.is_none()))
6660 && default_tab_stop != original_tab_stop;
6661 let imported_line_settings = model.metadata.line_spacing_compatibility_imported == Some(true);
6662 let line_settings = [
6663 (
6664 "w:adjustLineHeightInTable",
6665 compatibility.and_then(|value| value.adjust_line_height_in_table),
6666 original_compatibility
6667 .as_ref()
6668 .and_then(|value| value.adjust_line_height_in_table),
6669 ),
6670 (
6671 "w:noLeading",
6672 compatibility.and_then(|value| value.no_leading),
6673 original_compatibility
6674 .as_ref()
6675 .and_then(|value| value.no_leading),
6676 ),
6677 (
6678 "w:spaceForUL",
6679 compatibility.and_then(|value| value.space_for_ul),
6680 original_compatibility
6681 .as_ref()
6682 .and_then(|value| value.space_for_ul),
6683 ),
6684 (
6685 "w:noExtraLineSpacing",
6686 compatibility.and_then(|value| value.no_extra_line_spacing),
6687 original_compatibility
6688 .as_ref()
6689 .and_then(|value| value.no_extra_line_spacing),
6690 ),
6691 (
6692 "w:suppressBottomSpacing",
6693 compatibility.and_then(|value| value.suppress_bottom_spacing),
6694 original_compatibility
6695 .as_ref()
6696 .and_then(|value| value.suppress_bottom_spacing),
6697 ),
6698 (
6699 "w:suppressTopSpacingWP",
6700 compatibility.and_then(|value| value.suppress_top_spacing_wp),
6701 original_compatibility
6702 .as_ref()
6703 .and_then(|value| value.suppress_top_spacing_wp),
6704 ),
6705 (
6706 "w:truncateFontHeightsLikeWP6",
6707 compatibility.and_then(|value| value.truncate_font_heights_like_wp6),
6708 original_compatibility
6709 .as_ref()
6710 .and_then(|value| value.truncate_font_heights_like_wp6),
6711 ),
6712 ];
6713 let line_settings_changed = line_settings.iter().any(|(_, current, original)| {
6714 (current.is_some() || imported_line_settings) && current != original
6715 });
6716 if !mode_changed
6717 && !open_type_changed
6718 && !headers_changed
6719 && !tabs_changed
6720 && !line_settings_changed
6721 {
6722 return;
6723 }
6724
6725 let mut settings_xml = pkg.parts.get(&source_part_name)
6726 .map(|part| part.content.clone())
6727 .unwrap_or_else(|| format!(
6728 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="{WORD_MAIN_NS}"/>"#,
6729 ));
6730 if tabs_changed {
6731 settings_xml = if let Some(default_tab_stop) = default_tab_stop {
6732 let value = default_tab_stop.to_string();
6733 upsert_settings_element(
6734 &settings_xml,
6735 "w:settings",
6736 "w:defaultTabStop",
6737 &[("w:val", &value)],
6738 |_| true,
6739 )
6740 } else {
6741 remove_settings_element(&settings_xml, "w:defaultTabStop", |_| true)
6742 };
6743 }
6744 if headers_changed {
6745 settings_xml = if let Some(even_and_odd_headers) = even_and_odd_headers {
6746 let value = if even_and_odd_headers { "1" } else { "0" };
6747 upsert_settings_element(
6748 &settings_xml,
6749 "w:settings",
6750 "w:evenAndOddHeaders",
6751 &[("w:val", value)],
6752 |_| true,
6753 )
6754 } else {
6755 remove_settings_element(&settings_xml, "w:evenAndOddHeaders", |_| true)
6756 };
6757 }
6758 for (changed, name, value) in [
6759 (
6760 mode_changed,
6761 "compatibilityMode",
6762 compatibility_mode.map(|value| value.to_string()),
6763 ),
6764 (
6765 open_type_changed,
6766 "enableOpenTypeFeatures",
6767 enable_open_type_features.map(|value| if value { "1" } else { "0" }.to_string()),
6768 ),
6769 ] {
6770 if !changed {
6771 continue;
6772 }
6773 let compat_range = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6774 .into_iter()
6775 .next();
6776 let compat_xml = compat_range
6777 .as_ref()
6778 .map(|range| &settings_xml[range.start..range.end])
6779 .unwrap_or("<w:compat/>");
6780 let matches_setting = |tag: &str| {
6781 get_attribute(tag, "w:name").as_deref() == Some(name)
6782 && get_attribute(tag, "w:uri").as_deref()
6783 == Some("http://schemas.microsoft.com/office/word")
6784 };
6785 let updated_compat = if let Some(value) = value {
6786 upsert_settings_element(
6787 compat_xml,
6788 "w:compat",
6789 "w:compatSetting",
6790 &[
6791 ("w:name", name),
6792 ("w:uri", "http://schemas.microsoft.com/office/word"),
6793 ("w:val", &value),
6794 ],
6795 matches_setting,
6796 )
6797 } else {
6798 remove_settings_element(compat_xml, "w:compatSetting", matches_setting)
6799 };
6800 if let Some(range) = compat_range {
6801 settings_xml.replace_range(range.start..range.end, &updated_compat);
6802 } else {
6803 settings_xml =
6804 upsert_settings_element(&settings_xml, "w:settings", "w:compat", &[], |_| true);
6805 if let Some(range) = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6806 .into_iter()
6807 .next()
6808 {
6809 settings_xml.replace_range(range.start..range.end, &updated_compat);
6810 }
6811 }
6812 }
6813 if line_settings_changed {
6814 let range = extract_balanced_tag_ranges(&settings_xml, "w:settings")
6815 .into_iter()
6816 .next()
6817 .and_then(|settings| {
6818 direct_property_children(&settings_xml[settings.start..settings.end])
6819 .into_iter()
6820 .find(|(name, _)| name == "w:compat")
6821 .map(|(_, child)| crate::xml::TagRange {
6822 start: settings.start + child.start,
6823 end: settings.start + child.end,
6824 })
6825 });
6826 let mut compat = range
6827 .as_ref()
6828 .map(|range| settings_xml[range.start..range.end].to_string())
6829 .unwrap_or_else(|| "<w:compat/>".to_string());
6830 for (name, current, original) in line_settings {
6831 if (current.is_none() && !imported_line_settings) || current == original {
6832 continue;
6833 }
6834 let attributes =
6835 current.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
6836 compat = patch_direct_table_property(
6837 &compat,
6838 "w:compat",
6839 name,
6840 attributes.as_ref().map(|values| values.as_slice()),
6841 COMPAT_PROPERTIES_ORDER,
6842 );
6843 }
6844 if let Some(range) = range {
6845 settings_xml.replace_range(range.start..range.end, &compat);
6846 } else if !direct_property_children(&compat).is_empty() {
6847 settings_xml =
6848 upsert_settings_element(&settings_xml, "w:settings", "w:compat", &[], |_| true);
6849 if let Some(range) = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6850 .into_iter()
6851 .next()
6852 {
6853 settings_xml.replace_range(range.start..range.end, &compat);
6854 }
6855 }
6856 }
6857 pkg.parts.insert(
6858 source_part_name.clone(),
6859 OoxmlPart {
6860 name: source_part_name.clone(),
6861 content: settings_xml,
6862 },
6863 );
6864 ensure_content_type_override(pkg, &source_part_name, SETTINGS_CONTENT_TYPE);
6865 ensure_document_relationship(pkg, REL_TYPE_SETTINGS, &format!("/{source_part_name}"));
6866}
6867
6868fn ensure_document_open_tag(model: &DocModel) -> String {
6869 let mut open_tag = model.metadata.document_open_tag.clone().unwrap_or_else(|| {
6870 format!(r#"<w:document xmlns:w="{WORD_MAIN_NS}" xmlns:r="{OFFICE_REL_NS}">"#)
6871 });
6872
6873 open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
6874 open_tag = ensure_namespace(&open_tag, "r", OFFICE_REL_NS);
6875 open_tag = ensure_namespace(&open_tag, "a", DRAWING_MAIN_NS);
6876 open_tag = ensure_namespace(&open_tag, "wp", DRAWING_WORD_NS);
6877 open_tag = ensure_namespace(&open_tag, "pic", DRAWING_PICTURE_NS);
6878 open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
6879 open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
6880 open_tag = ensure_ignorable_prefix(&open_tag, "w14");
6881 open_tag
6882}
6883
6884fn model_to_document_xml_with_pkg(model: &DocModel, pkg: &mut OoxmlPackage) -> String {
6885 let resolved_document_part = crate::package::main_document_part_name(pkg);
6886 let document_part_name = resolved_document_part.as_str();
6887 let (body_xml, relationships) = {
6888 let mut state = create_image_serialization_state(pkg, document_part_name);
6889 let mut run_id_ref = 1i64;
6890 let body_xml = model
6891 .nodes
6892 .iter()
6893 .map(|node| match node {
6894 DocNode::Paragraph(paragraph) => {
6895 paragraph_xml(paragraph, &mut state, &mut run_id_ref)
6896 }
6897 DocNode::Table(table) => table_xml(table, &mut state, &mut run_id_ref),
6898 })
6899 .collect::<String>();
6900 (body_xml, state.relationships)
6901 };
6902
6903 let document_relationship_part_name = relationship_part_name_for_word_part(document_part_name);
6904 pkg.parts.insert(
6905 document_relationship_part_name.clone(),
6906 OoxmlPart {
6907 name: document_relationship_part_name,
6908 content: render_relationships_xml(&relationships),
6909 },
6910 );
6911
6912 let document_open_tag = ensure_document_open_tag(model);
6913 let section_properties_xml = model
6914 .metadata
6915 .section_properties_xml
6916 .as_deref()
6917 .unwrap_or(DEFAULT_SECTION_PROPERTIES_XML);
6918
6919 format!(
6920 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>{document_open_tag}<w:body>{body_xml}{section_properties_xml}</w:body></w:document>"#
6921 )
6922}
6923
6924pub fn can_preserve_image_paint_source(
6925 image: &ImageRunNode,
6926 base_package: Option<&OoxmlPackage>,
6927 owner_part_name: &str,
6928) -> bool {
6929 if image.paint_bounds.is_none() || !valid_image_paint_geometry(image) {
6930 return false;
6931 }
6932 let default_package;
6933 let package = match base_package {
6934 Some(package) => package,
6935 None => {
6936 default_package = create_minimal_docx_package(None);
6937 &default_package
6938 }
6939 };
6940 let owner = owner_part_name;
6941 if owner.is_empty() {
6942 return false;
6943 }
6944 let relationships = package
6945 .parts
6946 .get(&relationship_part_name_for_word_part(owner))
6947 .map(|part| parse_relationships_xml(&part.content))
6948 .unwrap_or_default();
6949 preserved_picture_run_xml(image, owner, &relationships, package).is_some()
6950 || (valid_image_paint_bounds(image)
6951 && edited_vml_textbox_source_plan(image, owner, &relationships, package).is_some())
6952}
6953
6954pub fn validate_image_paint_bounds(
6955 model: &DocModel,
6956 base_package: Option<&OoxmlPackage>,
6957) -> Result<(), String> {
6958 fn paragraph<'a>(
6959 value: &'a ParagraphNode,
6960 owner: &str,
6961 images: &mut Vec<(&'a ImageRunNode, String)>,
6962 ) {
6963 for child in &value.children {
6964 if let ParagraphChildNode::Image(image) = child {
6965 if image.paint_bounds.is_some() {
6966 images.push((image, owner.to_owned()));
6967 }
6968 if let Some(content) = &image.content_nodes {
6969 nodes(content, owner, images);
6970 }
6971 }
6972 }
6973 }
6974 fn table<'a>(value: &'a TableNode, owner: &str, images: &mut Vec<(&'a ImageRunNode, String)>) {
6975 for row in &value.rows {
6976 for cell in &row.cells {
6977 for child in &cell.nodes {
6978 match child {
6979 TableCellContentNode::Paragraph(value) => paragraph(value, owner, images),
6980 TableCellContentNode::Table(value) => table(value, owner, images),
6981 }
6982 }
6983 }
6984 }
6985 if let Some(patches) = &value.source_text_patches {
6986 for patch in patches {
6987 paragraph(&patch.paragraph, owner, images);
6988 }
6989 }
6990 }
6991 fn nodes<'a>(values: &'a [DocNode], owner: &str, images: &mut Vec<(&'a ImageRunNode, String)>) {
6992 for value in values {
6993 match value {
6994 DocNode::Paragraph(value) => paragraph(value, owner, images),
6995 DocNode::Table(value) => table(value, owner, images),
6996 }
6997 }
6998 }
6999 let default_package;
7000 let package = match base_package {
7001 Some(package) => package,
7002 None => {
7003 default_package = create_minimal_docx_package(None);
7004 &default_package
7005 }
7006 };
7007 let mut images = Vec::new();
7008 nodes(
7009 &model.nodes,
7010 &crate::package::main_document_part_name(package),
7011 &mut images,
7012 );
7013 let mut seen_parts = std::collections::HashSet::new();
7014 let mut add_part = |part_name: &str, values| {
7015 let owner = normalize_word_part_name(part_name);
7016 if !owner.is_empty() && seen_parts.insert(owner.clone()) {
7017 nodes(values, &owner, &mut images);
7018 }
7019 };
7020 for section in &model.metadata.header_sections {
7021 add_part(§ion.part_name, §ion.nodes);
7022 }
7023 for section in &model.metadata.footer_sections {
7024 add_part(§ion.part_name, §ion.nodes);
7025 }
7026 if let Some(sections) = &model.metadata.sections {
7027 for section in sections {
7028 for header in §ion.header_sections {
7029 add_part(&header.part_name, &header.nodes);
7030 }
7031 for footer in §ion.footer_sections {
7032 add_part(&footer.part_name, &footer.nodes);
7033 }
7034 }
7035 }
7036 for (image, owner) in images {
7037 if can_preserve_image_paint_source(image, Some(package), &owner) {
7038 continue;
7039 }
7040 if !valid_image_paint_bounds(image) {
7041 return Err("Inline image paint bounds require finite positive geometry and an inline layout frame".into());
7042 }
7043 if paint_png_bytes(image).is_none() {
7044 return Err("Inline image paint cannot be preserved from source; a current full-viewport PNG is required before export".into());
7045 }
7046 }
7047 Ok(())
7048}
7049
7050pub fn model_to_document_xml(model: &DocModel, base_package: Option<&OoxmlPackage>) -> String {
7052 validate_image_paint_bounds(model, base_package)
7053 .expect("Unsupported inline image paint; validate before XML serialization");
7054 let mut pkg = base_package.cloned().unwrap_or_else(|| {
7055 create_minimal_docx_package(Some(
7056 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>"#,
7057 ))
7058 });
7059 model_to_document_xml_with_pkg(model, &mut pkg)
7060}
7061
7062pub fn serialize_doc_model(model: &DocModel, base_package: Option<&OoxmlPackage>) -> OoxmlPackage {
7064 try_serialize_doc_model(model, base_package)
7065 .expect("Unsupported inline image paint; use try_serialize_doc_model")
7066}
7067
7068pub fn try_serialize_doc_model(
7069 model: &DocModel,
7070 base_package: Option<&OoxmlPackage>,
7071) -> Result<OoxmlPackage, String> {
7072 validate_image_paint_bounds(model, base_package)?;
7073 let mut seed = base_package
7074 .cloned()
7075 .unwrap_or_else(|| create_minimal_docx_package(None));
7076 let document_part_name = crate::package::main_document_part_name(&seed);
7077 let document_xml = model_to_document_xml_with_pkg(model, &mut seed);
7078 let with_document = with_part(
7079 &seed,
7080 OoxmlPart {
7081 name: document_part_name,
7082 content: document_xml,
7083 },
7084 );
7085 let mut result = with_document;
7086 serialize_header_footer_parts(model, &mut result);
7087 serialize_comment_parts(model, &mut result);
7088 serialize_document_settings(model, &mut result);
7089 Ok(result)
7090}
7091
7092pub fn serialize_docx(
7093 model: &DocModel,
7094 base_package: Option<&OoxmlPackage>,
7095) -> Result<Vec<u8>, String> {
7096 crate::table_grid::validate_collapsed_table_grids(model)?;
7097 package_to_bytes(&try_serialize_doc_model(model, base_package)?)
7098}
7099
7100#[cfg(test)]
7101#[path = "serialize/tests.rs"]
7102mod fidelity_tests;