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