Skip to main content

rust_docx/
serialize.rs

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