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