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