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