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                    wrap_with_hyperlink_xml(&run_xml, text_run.link.as_deref(), state)
4486                }
4487                ParagraphChildNode::FormField(form_field) => form_field_run_xml(form_field, state),
4488                ParagraphChildNode::Image(image) => {
4489                    *run_id_ref += 1;
4490                    drawing_run_xml(image, state, *run_id_ref)
4491                }
4492            };
4493            if xml.is_empty() {
4494                None
4495            } else {
4496                Some(xml)
4497            }
4498        })
4499        .collect::<String>();
4500
4501    let paragraph_runs = if runs.is_empty() {
4502        "<w:r><w:t/></w:r>".to_string()
4503    } else {
4504        runs
4505    };
4506    format!(
4507        "<w:p>{}{}</w:p>",
4508        paragraph_properties_with_mark_xml(paragraph, state),
4509        paragraph_runs
4510    )
4511}
4512
4513fn table_cell_xml_content(
4514    nodes: &[TableCellContentNode],
4515    state: &mut ImageSerializationState<'_>,
4516    run_id_ref: &mut i64,
4517) -> String {
4518    nodes
4519        .iter()
4520        .map(|node| match node {
4521            TableCellContentNode::Paragraph(paragraph) => {
4522                paragraph_xml(paragraph, state, run_id_ref)
4523            }
4524            TableCellContentNode::Table(table) => table_xml(table, state, run_id_ref),
4525        })
4526        .collect()
4527}
4528
4529fn table_layout_str(layout: TableLayout) -> &'static str {
4530    match layout {
4531        TableLayout::Fixed => "fixed",
4532        TableLayout::Autofit => "autofit",
4533    }
4534}
4535
4536fn table_row_height_rule_str(rule: TableRowHeightRule) -> &'static str {
4537    match rule {
4538        TableRowHeightRule::Auto => "auto",
4539        TableRowHeightRule::AtLeast => "atLeast",
4540        TableRowHeightRule::Exact => "exact",
4541    }
4542}
4543
4544fn table_cell_vertical_align_str(align: TableCellVerticalAlign) -> &'static str {
4545    match align {
4546        TableCellVerticalAlign::Top => "top",
4547        TableCellVerticalAlign::Center => "center",
4548        TableCellVerticalAlign::Bottom => "bottom",
4549    }
4550}
4551
4552fn patch_table_source_text(
4553    table: &TableNode,
4554    source_xml: &str,
4555    state: &mut ImageSerializationState<'_>,
4556    run_id_ref: &mut i64,
4557) -> Option<String> {
4558    let patches = table.source_text_patches.as_deref()?;
4559    if patches.is_empty() {
4560        return Some(source_xml.to_string());
4561    }
4562
4563    let mut patched = source_xml.to_string();
4564    for patch in patches {
4565        if patch.source_paragraph_xml.is_empty() {
4566            return None;
4567        }
4568        let mut matches = patched.match_indices(&patch.source_paragraph_xml);
4569        let (start, _) = matches.next()?;
4570        if matches.next().is_some() {
4571            return None;
4572        }
4573        let end = start + patch.source_paragraph_xml.len();
4574        let replacement = paragraph_xml(&patch.paragraph, state, run_id_ref);
4575        patched.replace_range(start..end, &replacement);
4576    }
4577    Some(patched)
4578}
4579
4580const TABLE_PROPERTIES_ORDER: &[&str] = &[
4581    "w:tblStyle",
4582    "w:tblpPr",
4583    "w:tblOverlap",
4584    "w:bidiVisual",
4585    "w:tblStyleRowBandSize",
4586    "w:tblStyleColBandSize",
4587    "w:tblW",
4588    "w:jc",
4589    "w:tblCellSpacing",
4590    "w:tblInd",
4591    "w:tblBorders",
4592    "w:shd",
4593    "w:tblLayout",
4594    "w:tblCellMar",
4595    "w:tblLook",
4596    "w:tblCaption",
4597    "w:tblDescription",
4598    "w:tblPrChange",
4599];
4600const CELL_PROPERTIES_ORDER: &[&str] = &[
4601    "w:cnfStyle",
4602    "w:tcW",
4603    "w:gridSpan",
4604    "w:hMerge",
4605    "w:vMerge",
4606    "w:tcBorders",
4607    "w:shd",
4608    "w:noWrap",
4609    "w:tcMar",
4610    "w:textDirection",
4611    "w:tcFitText",
4612    "w:vAlign",
4613    "w:hideMark",
4614    "w:headers",
4615    "w:cellIns",
4616    "w:cellDel",
4617    "w:cellMerge",
4618    "w:tcPrChange",
4619];
4620
4621fn effective_table_width(
4622    preferred: Option<&TablePreferredWidth>,
4623    legacy: Option<i64>,
4624    source: Option<&TableWidthSource>,
4625) -> Option<TablePreferredWidth> {
4626    if let Some(source) = source {
4627        if preferred != source.preferred_width.as_ref() {
4628            return preferred
4629                .cloned()
4630                .or_else(|| source.inherited_preferred_width.clone());
4631        }
4632        if legacy != source.width_twips {
4633            return legacy
4634                .map(|value| TablePreferredWidth::Dxa {
4635                    value: value as f64,
4636                })
4637                .or_else(|| source.inherited_preferred_width.clone());
4638        }
4639    }
4640    preferred.cloned().or_else(|| {
4641        legacy.map(|value| TablePreferredWidth::Dxa {
4642            value: value as f64,
4643        })
4644    })
4645}
4646
4647fn table_width_edit(
4648    preferred: Option<&TablePreferredWidth>,
4649    legacy: Option<i64>,
4650    source: Option<&TableWidthSource>,
4651) -> Option<Option<TablePreferredWidth>> {
4652    if let Some(source) = source {
4653        if preferred != source.preferred_width.as_ref() {
4654            return Some(preferred.cloned());
4655        }
4656        if legacy != source.width_twips {
4657            return Some(legacy.map(|value| TablePreferredWidth::Dxa {
4658                value: value as f64,
4659            }));
4660        }
4661        None
4662    } else {
4663        effective_table_width(preferred, legacy, None).map(Some)
4664    }
4665}
4666
4667fn table_width_attributes(width: &TablePreferredWidth) -> Option<[(&'static str, String); 2]> {
4668    let (kind, value) = match width {
4669        TablePreferredWidth::Dxa { value } if value.is_finite() && *value >= 0.0 => (
4670            "dxa",
4671            if value.fract() == 0.0 {
4672                format!("{value:.0}")
4673            } else {
4674                format!("{}pt", value / 20.0)
4675            },
4676        ),
4677        TablePreferredWidth::Pct { value } if value.is_finite() && *value >= 0.0 => {
4678            let fiftieths = value * 50.0;
4679            (
4680                "pct",
4681                if fiftieths.fract() == 0.0 {
4682                    format!("{fiftieths:.0}")
4683                } else {
4684                    format!("{value}%")
4685                },
4686            )
4687        }
4688        TablePreferredWidth::Auto => ("auto", "0".to_string()),
4689        TablePreferredWidth::Nil => ("nil", "0".to_string()),
4690        _ => return None,
4691    };
4692    Some([("w:w", value), ("w:type", kind.to_string())])
4693}
4694
4695fn table_width_xml(name: &str, width: Option<&TablePreferredWidth>) -> Option<String> {
4696    let attributes = table_width_attributes(width?)?;
4697    Some(format!(
4698        r#"<{name} w:w="{}" w:type="{}"/>"#,
4699        attributes[0].1, attributes[1].1
4700    ))
4701}
4702
4703fn table_alignment_str(value: TableAlignment) -> &'static str {
4704    match value {
4705        TableAlignment::Left => "left",
4706        TableAlignment::Center => "center",
4707        TableAlignment::Right => "right",
4708    }
4709}
4710
4711fn patch_direct_table_property(
4712    xml: &str,
4713    container: &str,
4714    name: &str,
4715    attributes: Option<&[(&str, String)]>,
4716    order: &[&str],
4717) -> String {
4718    let ranges: Vec<_> = direct_property_children(xml)
4719        .into_iter()
4720        .filter(|(tag, _)| tag == name)
4721        .map(|(_, range)| range)
4722        .collect();
4723    let Some(attributes) = attributes else {
4724        return replace_xml_ranges(
4725            xml,
4726            ranges
4727                .into_iter()
4728                .map(|range| (range.start, range.end, String::new()))
4729                .collect(),
4730        );
4731    };
4732    if let Some(first) = ranges.first() {
4733        let existing = &xml[first.start..first.end];
4734        let end = opening_tag_end(existing, 0).unwrap_or(existing.len());
4735        let mut tag = existing[..end].to_string();
4736        for (attribute, value) in attributes {
4737            tag = set_xml_attribute(&tag, attribute, value);
4738        }
4739        tag.push_str(&existing[end..]);
4740        let mut replacements = vec![(first.start, first.end, tag)];
4741        replacements.extend(
4742            ranges
4743                .into_iter()
4744                .skip(1)
4745                .map(|range| (range.start, range.end, String::new())),
4746        );
4747        return replace_xml_ranges(xml, replacements);
4748    }
4749    let mut child = format!("<{name}/>");
4750    for (attribute, value) in attributes {
4751        child = set_xml_attribute(&child, attribute, value);
4752    }
4753    insert_property_child(xml, container, name, &child, order)
4754}
4755
4756fn patch_table_properties(
4757    xml: &str,
4758    container: &str,
4759    width_name: &str,
4760    width: Option<Option<TablePreferredWidth>>,
4761    alignment: Option<Option<TableAlignment>>,
4762    bidi: Option<Option<bool>>,
4763    order: &[&str],
4764) -> String {
4765    if width.is_none() && alignment.is_none() && bidi.is_none() {
4766        return xml.to_string();
4767    }
4768    let range = direct_property_children(xml)
4769        .into_iter()
4770        .find(|(tag, _)| tag == container)
4771        .map(|(_, range)| range);
4772    let original = range.as_ref().map(|range| &xml[range.start..range.end]);
4773    let mut properties = original
4774        .map(str::to_string)
4775        .unwrap_or_else(|| format!("<{container}/>"));
4776    if let Some(width) = width {
4777        let current = crate::parse::direct_table_property(&properties, width_name)
4778            .and_then(crate::parse::parse_table_preferred_width);
4779        if current != width {
4780            let attributes = width.as_ref().and_then(table_width_attributes);
4781            properties = patch_direct_table_property(
4782                &properties,
4783                container,
4784                width_name,
4785                attributes.as_ref().map(|values| values.as_slice()),
4786                order,
4787            );
4788        }
4789    }
4790    if let Some(alignment) = alignment {
4791        let current = crate::parse::direct_table_property(&properties, "w:jc")
4792            .and_then(|tag| get_attribute(tag, "w:val"));
4793        let desired = alignment.map(|value| table_alignment_str(value).to_string());
4794        if current != desired {
4795            let attributes = desired.map(|value| [("w:val", value)]);
4796            properties = patch_direct_table_property(
4797                &properties,
4798                container,
4799                "w:jc",
4800                attributes.as_ref().map(|values| values.as_slice()),
4801                order,
4802            );
4803        }
4804    }
4805    if let Some(bidi) = bidi {
4806        let current = crate::parse::direct_table_property(&properties, "w:bidiVisual")
4807            .and_then(|tag| crate::xml::parse_on_off_attribute(tag, "bidiVisual"));
4808        if current != bidi {
4809            let attributes =
4810                bidi.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
4811            properties = patch_direct_table_property(
4812                &properties,
4813                container,
4814                "w:bidiVisual",
4815                attributes.as_ref().map(|values| values.as_slice()),
4816                order,
4817            );
4818        }
4819    }
4820    if let Some(range) = range {
4821        let mut patched = xml.to_string();
4822        patched.replace_range(range.start..range.end, &properties);
4823        patched
4824    } else if !direct_property_children(&properties).is_empty() {
4825        let mut patched = xml.to_string();
4826        let end = opening_tag_end(xml, 0).unwrap_or(xml.len());
4827        patched.insert_str(end, &properties);
4828        patched
4829    } else {
4830        xml.to_string()
4831    }
4832}
4833
4834const ROW_PROPERTIES_ORDER: &[&str] = &[
4835    "w:cnfStyle",
4836    "w:divId",
4837    "w:gridBefore",
4838    "w:gridAfter",
4839    "w:wBefore",
4840    "w:wAfter",
4841    "w:cantSplit",
4842    "w:trHeight",
4843    "w:tblHeader",
4844    "w:tblCellSpacing",
4845    "w:jc",
4846    "w:hidden",
4847    "w:ins",
4848    "w:del",
4849    "w:trPrChange",
4850];
4851
4852fn row_has_source_geometry_edits(row: &TableRowNode) -> bool {
4853    let Some(style) = row.style.as_ref() else {
4854        return false;
4855    };
4856    if let Some(source) = style.source_row_geometry.as_ref() {
4857        style.grid_before != source.grid_before
4858            || style.grid_after != source.grid_after
4859            || style.width_before != source.width_before
4860            || style.width_after != source.width_after
4861    } else {
4862        style.grid_before.is_some()
4863            || style.grid_after.is_some()
4864            || style.width_before.is_some()
4865            || style.width_after.is_some()
4866    }
4867}
4868
4869fn patch_row_source_geometry(row: &TableRowNode, xml: &str) -> String {
4870    if !row_has_source_geometry_edits(row) {
4871        return xml.to_string();
4872    }
4873    let style = row.style.as_ref().unwrap();
4874    let source = style.source_row_geometry.as_ref();
4875    let range = direct_property_children(xml)
4876        .into_iter()
4877        .find(|(name, _)| name == "w:trPr")
4878        .map(|(_, range)| range);
4879    let mut properties = range
4880        .as_ref()
4881        .map(|range| xml[range.start..range.end].to_string())
4882        .unwrap_or_else(|| "<w:trPr/>".to_string());
4883    for (name, current, baseline) in [
4884        (
4885            "w:gridBefore",
4886            style.grid_before,
4887            source.and_then(|source| source.grid_before),
4888        ),
4889        (
4890            "w:gridAfter",
4891            style.grid_after,
4892            source.and_then(|source| source.grid_after),
4893        ),
4894    ] {
4895        if (source.is_none() && current.is_none())
4896            || (source.is_some() && current == baseline)
4897            || current.is_some_and(|value| value < 0)
4898        {
4899            continue;
4900        }
4901        let attributes = current.map(|value| [("w:val", value.to_string())]);
4902        properties = patch_direct_table_property(
4903            &properties,
4904            "w:trPr",
4905            name,
4906            attributes.as_ref().map(|values| values.as_slice()),
4907            ROW_PROPERTIES_ORDER,
4908        );
4909    }
4910    for (name, current, baseline) in [
4911        (
4912            "w:wBefore",
4913            style.width_before.as_ref(),
4914            source.and_then(|source| source.width_before.as_ref()),
4915        ),
4916        (
4917            "w:wAfter",
4918            style.width_after.as_ref(),
4919            source.and_then(|source| source.width_after.as_ref()),
4920        ),
4921    ] {
4922        if (source.is_none() && current.is_none()) || (source.is_some() && current == baseline) {
4923            continue;
4924        }
4925        let actual = crate::parse::direct_table_property(&properties, name)
4926            .and_then(crate::parse::parse_table_preferred_width);
4927        if actual.as_ref() == current {
4928            continue;
4929        }
4930        let attributes = current.and_then(table_width_attributes);
4931        properties = patch_direct_table_property(
4932            &properties,
4933            "w:trPr",
4934            name,
4935            attributes.as_ref().map(|values| values.as_slice()),
4936            ROW_PROPERTIES_ORDER,
4937        );
4938    }
4939    let mut updated = xml.to_string();
4940    if let Some(range) = range {
4941        updated.replace_range(range.start..range.end, &properties);
4942    } else if !direct_property_children(&properties).is_empty() {
4943        let end = direct_property_children(xml)
4944            .into_iter()
4945            .find(|(name, _)| name == "w:tblPrEx")
4946            .map(|(_, range)| range.end)
4947            .unwrap_or_else(|| opening_tag_end(xml, 0).unwrap_or(0));
4948        updated.insert_str(end, &properties);
4949    }
4950    updated
4951}
4952
4953fn row_has_source_property_edits(row: &TableRowNode) -> bool {
4954    row_has_source_geometry_edits(row) || row.cells.iter().any(cell_has_source_property_edits)
4955}
4956
4957fn paragraph_has_source_property_edits(paragraph: &ParagraphNode) -> bool {
4958    paragraph.paragraph_mark_style != paragraph.source_paragraph_mark_style
4959        || paragraph.style.as_ref().is_some_and(|style| {
4960            style.text_alignment != style.source_text_alignment
4961                || style.snap_to_grid != style.source_snap_to_grid
4962                || style.spacing.as_ref().and_then(|s| s.before_auto_spacing)
4963                    != style.source_before_auto_spacing
4964                || style.spacing.as_ref().and_then(|s| s.after_auto_spacing)
4965                    != style.source_after_auto_spacing
4966        })
4967}
4968
4969fn cell_has_source_property_edits(cell: &TableCellNode) -> bool {
4970    cell.style.as_ref().is_some_and(|style| {
4971        style.text_direction != style.source_text_direction
4972            || style.no_wrap != style.source_no_wrap
4973            || table_width_edit(
4974                style.preferred_width.as_ref(),
4975                style.width_twips,
4976                style.source_width.as_ref(),
4977            )
4978            .is_some()
4979    }) || cell.nodes.iter().any(|node| match node {
4980        TableCellContentNode::Paragraph(paragraph) => {
4981            paragraph_has_source_property_edits(paragraph)
4982        }
4983        TableCellContentNode::Table(table) => table_has_source_property_edits(table),
4984    })
4985}
4986
4987fn table_has_source_property_edits(table: &TableNode) -> bool {
4988    table.style.as_ref().is_some_and(|style| {
4989        table_width_edit(
4990            style.preferred_width.as_ref(),
4991            style.width_twips,
4992            style.source_width.as_ref(),
4993        )
4994        .is_some()
4995            || style.style_id != style.source_style_id
4996            || style.alignment != style.source_alignment
4997            || style.bidi_visual != style.source_bidi_visual
4998    }) || table.rows.iter().any(row_has_source_property_edits)
4999}
5000
5001fn patch_table_source_properties(
5002    table: &TableNode,
5003    source_xml: &str,
5004    state: &mut ImageSerializationState<'_>,
5005) -> Option<String> {
5006    let previous = state.current_table_text_alignment;
5007    let previous_snap = state.current_table_snap_to_grid;
5008    let result = patch_table_source_properties_inner(table, source_xml, state);
5009    state.current_table_text_alignment = previous;
5010    state.current_table_snap_to_grid = previous_snap;
5011    result
5012}
5013
5014fn patch_table_source_properties_inner(
5015    table: &TableNode,
5016    source_xml: &str,
5017    state: &mut ImageSerializationState<'_>,
5018) -> Option<String> {
5019    let identity_changed = table
5020        .style
5021        .as_ref()
5022        .is_some_and(|style| style.style_id != style.source_style_id);
5023    if !table_has_source_property_edits(table)
5024        && !table_alignment_context_changed(table, state, Some(source_xml))
5025    {
5026        return Some(source_xml.to_string());
5027    }
5028    let width = table.style.as_ref().and_then(|style| {
5029        table_width_edit(
5030            style.preferred_width.as_ref(),
5031            style.width_twips,
5032            style.source_width.as_ref(),
5033        )
5034    });
5035    let alignment = table
5036        .style
5037        .as_ref()
5038        .filter(|style| style.alignment != style.source_alignment)
5039        .map(|style| style.alignment);
5040    let bidi = table
5041        .style
5042        .as_ref()
5043        .filter(|style| style.bidi_visual != style.source_bidi_visual)
5044        .map(|style| style.bidi_visual);
5045    let mut source = patch_table_properties(
5046        source_xml,
5047        "w:tblPr",
5048        "w:tblW",
5049        width,
5050        alignment,
5051        bidi,
5052        TABLE_PROPERTIES_ORDER,
5053    );
5054    if let Some(style) = table
5055        .style
5056        .as_ref()
5057        .filter(|style| style.style_id != style.source_style_id)
5058    {
5059        let range = direct_property_children(&source)
5060            .into_iter()
5061            .find(|(name, _)| name == "w:tblPr")
5062            .map(|(_, range)| range);
5063        let properties = range
5064            .as_ref()
5065            .map(|range| &source[range.start..range.end])
5066            .unwrap_or("<w:tblPr/>");
5067        let attributes = style
5068            .style_id
5069            .as_ref()
5070            .map(|value| [("w:val", value.clone())]);
5071        let properties = patch_direct_table_property(
5072            properties,
5073            "w:tblPr",
5074            "w:tblStyle",
5075            attributes.as_ref().map(|values| values.as_slice()),
5076            TABLE_PROPERTIES_ORDER,
5077        );
5078        if let Some(range) = range {
5079            source.replace_range(range.start..range.end, &properties);
5080        } else if style.style_id.is_some() {
5081            let end = opening_tag_end(&source, 0).unwrap_or(0);
5082            source.insert_str(end, &properties);
5083        }
5084    }
5085    if !identity_changed
5086        && !table.rows.iter().any(row_has_source_property_edits)
5087        && !table_alignment_context_changed(table, state, Some(&source))
5088    {
5089        return Some(source);
5090    }
5091    let context = resolve_table_alignment_context(table, state, Some(&source));
5092    let rows = extract_balanced_tag_ranges(&source, "w:tr");
5093    if rows.len() != table.rows.len() {
5094        return None;
5095    }
5096    let mut row_replacements = Vec::new();
5097    for (row_index, (range, row)) in rows.into_iter().zip(&table.rows).enumerate() {
5098        let patched_row = patch_row_source_geometry(row, &source[range.start..range.end]);
5099        let row_xml = patched_row.as_str();
5100        let cells = extract_balanced_tag_ranges(row_xml, "w:tc");
5101        if cells.len() != row.cells.len() {
5102            return None;
5103        }
5104        let mut cell_replacements = Vec::new();
5105        let bound = context.grid_bound;
5106        let mut column = crate::parse::resolve_table_grid_skip_count(
5107            row.style.as_ref().and_then(|style| style.grid_before),
5108            bound,
5109        );
5110        for (cell_range, cell) in cells.into_iter().zip(&row.cells) {
5111            let span = cell
5112                .style
5113                .as_ref()
5114                .and_then(|style| style.grid_span)
5115                .unwrap_or(1)
5116                .max(1);
5117            state.current_table_text_alignment =
5118                table_cell_text_alignment(&context, row_index, column, span, state);
5119            state.current_table_snap_to_grid =
5120                table_cell_snap_to_grid(&context, row_index, column, span, state);
5121            column += span;
5122            let cell_xml = &row_xml[cell_range.start..cell_range.end];
5123            let width = cell.style.as_ref().and_then(|style| {
5124                table_width_edit(
5125                    style.preferred_width.as_ref(),
5126                    style.width_twips,
5127                    style.source_width.as_ref(),
5128                )
5129            });
5130            let mut patched_cell = patch_table_properties(
5131                cell_xml,
5132                "w:tcPr",
5133                "w:tcW",
5134                width,
5135                None,
5136                None,
5137                CELL_PROPERTIES_ORDER,
5138            );
5139            if let Some(style) = cell
5140                .style
5141                .as_ref()
5142                .filter(|style| style.text_direction != style.source_text_direction)
5143            {
5144                let range = direct_property_children(&patched_cell)
5145                    .into_iter()
5146                    .find(|(name, _)| name == "w:tcPr")
5147                    .map(|(_, range)| range);
5148                let properties = range
5149                    .as_ref()
5150                    .map(|range| &patched_cell[range.start..range.end])
5151                    .unwrap_or("<w:tcPr/>");
5152                let attributes = style
5153                    .text_direction
5154                    .as_ref()
5155                    .map(|value| [("w:val", value.clone())]);
5156                let properties = patch_direct_table_property(
5157                    properties,
5158                    "w:tcPr",
5159                    "w:textDirection",
5160                    attributes.as_ref().map(|values| values.as_slice()),
5161                    CELL_PROPERTIES_ORDER,
5162                );
5163                if let Some(range) = range {
5164                    patched_cell.replace_range(range.start..range.end, &properties);
5165                } else if style.text_direction.is_some() {
5166                    let end = opening_tag_end(&patched_cell, 0).unwrap_or(0);
5167                    patched_cell.insert_str(end, &properties);
5168                }
5169            }
5170            if let Some(style) = cell
5171                .style
5172                .as_ref()
5173                .filter(|style| style.no_wrap != style.source_no_wrap)
5174            {
5175                let range = direct_property_children(&patched_cell)
5176                    .into_iter()
5177                    .find(|(name, _)| name == "w:tcPr")
5178                    .map(|(_, range)| range);
5179                let properties = range
5180                    .as_ref()
5181                    .map(|range| &patched_cell[range.start..range.end])
5182                    .unwrap_or("<w:tcPr/>");
5183                let attributes = style
5184                    .no_wrap
5185                    .map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
5186                let properties = patch_direct_table_property(
5187                    properties,
5188                    "w:tcPr",
5189                    "w:noWrap",
5190                    attributes.as_ref().map(|values| values.as_slice()),
5191                    CELL_PROPERTIES_ORDER,
5192                );
5193                if let Some(range) = range {
5194                    patched_cell.replace_range(range.start..range.end, &properties);
5195                } else if style.no_wrap.is_some() {
5196                    let end = opening_tag_end(&patched_cell, 0).unwrap_or(0);
5197                    patched_cell.insert_str(end, &properties);
5198                }
5199            }
5200            if identity_changed
5201                || cell_alignment_context_changed(cell, state)
5202                || cell.nodes.iter().any(|node| match node {
5203                    TableCellContentNode::Paragraph(paragraph) => {
5204                        paragraph_has_source_property_edits(paragraph)
5205                    }
5206                    TableCellContentNode::Table(table) => table_has_source_property_edits(table),
5207                })
5208            {
5209                let blocks = crate::xml::extract_balanced_tag_blocks_in_order(
5210                    &patched_cell,
5211                    &["w:p", "w:tbl"],
5212                );
5213                if blocks.len() != cell.nodes.len() {
5214                    return None;
5215                }
5216                let mut replacements = Vec::new();
5217                for (range, node) in blocks.into_iter().zip(&cell.nodes) {
5218                    let source = &patched_cell[range.start..range.end];
5219                    let replacement = match node {
5220                        TableCellContentNode::Paragraph(paragraph)
5221                            if range.tag_name.eq_ignore_ascii_case("w:p") =>
5222                        {
5223                            patch_source_paragraph_properties(paragraph, source, state)
5224                        }
5225                        TableCellContentNode::Table(table)
5226                            if range.tag_name.eq_ignore_ascii_case("w:tbl") =>
5227                        {
5228                            patch_table_source_properties(table, source, state)?
5229                        }
5230                        _ => return None,
5231                    };
5232                    replacements.push((range.start, range.end, replacement));
5233                }
5234                patched_cell = replace_xml_ranges(&patched_cell, replacements);
5235            }
5236            cell_replacements.push((cell_range.start, cell_range.end, patched_cell));
5237        }
5238        row_replacements.push((
5239            range.start,
5240            range.end,
5241            replace_xml_ranges(row_xml, cell_replacements),
5242        ));
5243    }
5244    Some(replace_xml_ranges(&source, row_replacements))
5245}
5246
5247fn table_xml(
5248    table: &TableNode,
5249    state: &mut ImageSerializationState<'_>,
5250    run_id_ref: &mut i64,
5251) -> String {
5252    if !table_has_stale_form_field_preview(table) {
5253        if let Some(source_xml) = &table.source_xml {
5254            let source = if table
5255                .source_text_patches
5256                .as_ref()
5257                .is_some_and(|patches| !patches.is_empty())
5258            {
5259                patch_table_source_text(table, source_xml, state, run_id_ref)
5260            } else {
5261                Some(source_xml.clone())
5262            };
5263            if let Some(patched) =
5264                source.and_then(|source| patch_table_source_properties(table, &source, state))
5265            {
5266                return patched;
5267            }
5268        }
5269    }
5270
5271    let physical_table = match crate::table_grid::materialize_collapsed_table_grid(table) {
5272        Ok(table) => table,
5273        Err(error) => {
5274            state
5275                .pkg
5276                .warnings
5277                .push(format!("Invalid collapsed table grid: {error}"));
5278            None
5279        }
5280    };
5281    let table = physical_table.as_ref().unwrap_or(table);
5282
5283    let mut table_props: Vec<String> = Vec::new();
5284    if let Some(style_id) = table
5285        .style
5286        .as_ref()
5287        .and_then(|style| style.style_id.as_ref())
5288    {
5289        table_props.push(format!(r#"<w:tblStyle w:val="{}"/>"#, escape_xml(style_id)));
5290    }
5291
5292    // tblpPr precedes tblW in the tblPr child sequence.
5293    if let Some(floating) = table.style.as_ref().and_then(|s| s.floating.as_ref()) {
5294        let mut attrs = String::new();
5295        if let Some(value) = floating.left_from_text_twips {
5296            attrs.push_str(&format!(r#" w:leftFromText="{value}""#));
5297        }
5298        if let Some(value) = floating.right_from_text_twips {
5299            attrs.push_str(&format!(r#" w:rightFromText="{value}""#));
5300        }
5301        if let Some(value) = floating.top_from_text_twips {
5302            attrs.push_str(&format!(r#" w:topFromText="{value}""#));
5303        }
5304        if let Some(value) = floating.bottom_from_text_twips {
5305            attrs.push_str(&format!(r#" w:bottomFromText="{value}""#));
5306        }
5307        if let Some(value) = floating.vertical_anchor.as_ref() {
5308            attrs.push_str(&format!(r#" w:vertAnchor="{}""#, escape_xml(value)));
5309        }
5310        if let Some(value) = floating.horizontal_anchor.as_ref() {
5311            attrs.push_str(&format!(r#" w:horzAnchor="{}""#, escape_xml(value)));
5312        }
5313        if let Some(align) = floating.horizontal_align {
5314            attrs.push_str(&format!(
5315                r#" w:tblpXSpec="{}""#,
5316                image_horizontal_align_str(align)
5317            ));
5318        }
5319        if let Some(value) = floating.x_twips {
5320            attrs.push_str(&format!(r#" w:tblpX="{value}""#));
5321        }
5322        if let Some(align) = floating.vertical_align {
5323            attrs.push_str(&format!(
5324                r#" w:tblpYSpec="{}""#,
5325                image_vertical_align_str(align)
5326            ));
5327        }
5328        if let Some(value) = floating.y_twips {
5329            attrs.push_str(&format!(r#" w:tblpY="{value}""#));
5330        }
5331        if !attrs.is_empty() {
5332            table_props.push(format!("<w:tblpPr{attrs}/>"));
5333        }
5334    }
5335    let preferred_width = table.style.as_ref().and_then(|style| {
5336        effective_table_width(
5337            style.preferred_width.as_ref(),
5338            style.width_twips,
5339            style.source_width.as_ref(),
5340        )
5341    });
5342    if let Some(xml) = table_width_xml("w:tblW", preferred_width.as_ref()) {
5343        table_props.push(xml);
5344    }
5345    if let Some(alignment) = table
5346        .style
5347        .as_ref()
5348        .and_then(|style| style.alignment.or(style.source_inherited_alignment))
5349    {
5350        table_props.push(format!(
5351            r#"<w:jc w:val="{}"/>"#,
5352            table_alignment_str(alignment)
5353        ));
5354    }
5355    if let Some(bidi_visual) = table
5356        .style
5357        .as_ref()
5358        .and_then(|style| style.bidi_visual.or(style.source_inherited_bidi_visual))
5359    {
5360        table_props.push(format!(
5361            r#"<w:bidiVisual w:val="{}"/>"#,
5362            if bidi_visual { "1" } else { "0" }
5363        ));
5364    }
5365
5366    if let Some(table_indent_twips) =
5367        twips_to_xml(table.style.as_ref().and_then(|s| s.indent_twips))
5368    {
5369        table_props.push(format!(
5370            r#"<w:tblInd w:w="{table_indent_twips}" w:type="dxa"/>"#
5371        ));
5372    }
5373
5374    if let Some(layout) = table.style.as_ref().and_then(|s| s.layout) {
5375        table_props.push(format!(
5376            r#"<w:tblLayout w:type="{}"/>"#,
5377            table_layout_str(layout)
5378        ));
5379    }
5380
5381    if let Some(table_cell_spacing_twips) =
5382        twips_to_xml_non_negative(table.style.as_ref().and_then(|s| s.cell_spacing_twips))
5383    {
5384        table_props.push(format!(
5385            r#"<w:tblCellSpacing w:w="{table_cell_spacing_twips}" w:type="dxa"/>"#
5386        ));
5387    }
5388
5389    let table_cell_margin_xml = table_box_spacing_xml(
5390        table
5391            .style
5392            .as_ref()
5393            .and_then(|s| s.cell_margin_twips.as_ref()),
5394        "w:tblCellMar",
5395    );
5396    if !table_cell_margin_xml.is_empty() {
5397        table_props.push(table_cell_margin_xml);
5398    }
5399    let table_border_xml = table_borders_xml(
5400        table.style.as_ref().and_then(|s| s.borders.as_ref()),
5401        "w:tblBorders",
5402    );
5403    if !table_border_xml.is_empty() {
5404        table_props.push(table_border_xml);
5405    }
5406
5407    table_props.sort_by_key(|xml| {
5408        let name = xml[1..]
5409            .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
5410            .next()
5411            .unwrap_or_default();
5412        TABLE_PROPERTIES_ORDER
5413            .iter()
5414            .position(|value| *value == name)
5415            .unwrap_or(usize::MAX)
5416    });
5417
5418    let table_grid_xml = table
5419        .style
5420        .as_ref()
5421        .and_then(|s| s.column_widths_twips.as_ref())
5422        .map(|widths| {
5423            let cols = widths
5424                .iter()
5425                .filter_map(|width| twips_to_xml_non_negative(Some(*width)))
5426                .map(|width| format!(r#"<w:gridCol w:w="{width}"/>"#))
5427                .collect::<String>();
5428            format!("<w:tblGrid>{cols}</w:tblGrid>")
5429        })
5430        .unwrap_or_default();
5431
5432    let context = resolve_table_alignment_context(table, state, None);
5433    let previous_table_alignment = state.current_table_text_alignment;
5434    let previous_table_snap = state.current_table_snap_to_grid;
5435    let rows = table
5436        .rows
5437        .iter()
5438        .enumerate()
5439        .map(|(row_index, row)| {
5440            let mut row_props: Vec<String> = Vec::new();
5441            for (name, count) in [
5442                (
5443                    "w:gridBefore",
5444                    row.style.as_ref().and_then(|style| style.grid_before),
5445                ),
5446                (
5447                    "w:gridAfter",
5448                    row.style.as_ref().and_then(|style| style.grid_after),
5449                ),
5450            ] {
5451                if let Some(count) = count.filter(|count| *count >= 0) {
5452                    row_props.push(format!(r#"<{name} w:val="{count}"/>"#));
5453                }
5454            }
5455            for (name, width) in [
5456                (
5457                    "w:wBefore",
5458                    row.style
5459                        .as_ref()
5460                        .and_then(|style| style.width_before.as_ref()),
5461                ),
5462                (
5463                    "w:wAfter",
5464                    row.style
5465                        .as_ref()
5466                        .and_then(|style| style.width_after.as_ref()),
5467                ),
5468            ] {
5469                if let Some(xml) = table_width_xml(name, width) {
5470                    row_props.push(xml);
5471                }
5472            }
5473
5474            if let Some(background_color) =
5475                row.style.as_ref().and_then(|s| s.background_color.as_ref())
5476            {
5477                let fill = background_color.replace('#', "");
5478                row_props.push(format!(
5479                    r#"<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>"#
5480                ));
5481            }
5482            if let Some(row_height_twips) =
5483                twips_to_xml(row.style.as_ref().and_then(|s| s.height_twips))
5484            {
5485                let h_rule = row
5486                    .style
5487                    .as_ref()
5488                    .and_then(|s| s.height_rule)
5489                    .filter(|rule| {
5490                        matches!(
5491                            rule,
5492                            TableRowHeightRule::Exact
5493                                | TableRowHeightRule::AtLeast
5494                                | TableRowHeightRule::Auto
5495                        )
5496                    })
5497                    .map(|rule| format!(r#" w:hRule="{}""#, table_row_height_rule_str(rule)))
5498                    .unwrap_or_default();
5499                row_props.push(format!(
5500                    r#"<w:trHeight w:val="{row_height_twips}"{h_rule}/>"#
5501                ));
5502            }
5503            if row.style.as_ref().and_then(|s| s.cant_split) == Some(true) {
5504                row_props.push("<w:cantSplit/>".to_string());
5505            }
5506            if let Some(is_header) = row.style.as_ref().and_then(|s| s.is_header) {
5507                row_props.push(if is_header {
5508                    "<w:tblHeader/>".to_string()
5509                } else {
5510                    r#"<w:tblHeader w:val="0"/>"#.to_string()
5511                });
5512            }
5513
5514            row_props.sort_by_key(|xml| {
5515                let name = xml[1..]
5516                    .split(|ch: char| ch.is_whitespace() || ch == '/' || ch == '>')
5517                    .next()
5518                    .unwrap_or_default();
5519                ROW_PROPERTIES_ORDER
5520                    .iter()
5521                    .position(|value| *value == name)
5522                    .unwrap_or(usize::MAX)
5523            });
5524
5525            let bound = context.grid_bound;
5526            let mut column = crate::parse::resolve_table_grid_skip_count(
5527                row.style.as_ref().and_then(|style| style.grid_before),
5528                bound,
5529            );
5530            let cells = row
5531                .cells
5532                .iter()
5533                .map(|cell| {
5534                    let span = cell
5535                        .style
5536                        .as_ref()
5537                        .and_then(|style| style.grid_span)
5538                        .unwrap_or(1)
5539                        .max(1);
5540                    state.current_table_text_alignment =
5541                        table_cell_text_alignment(&context, row_index, column, span, state);
5542                    state.current_table_snap_to_grid =
5543                        table_cell_snap_to_grid(&context, row_index, column, span, state);
5544                    column += span;
5545                    let serialized_cells = table_cell_xml_content(&cell.nodes, state, run_id_ref);
5546
5547                    let mut cell_props: Vec<String> = Vec::new();
5548                    let preferred_width = cell.style.as_ref().and_then(|style| {
5549                        effective_table_width(
5550                            style.preferred_width.as_ref(),
5551                            style.width_twips,
5552                            style.source_width.as_ref(),
5553                        )
5554                    });
5555                    if let Some(xml) = table_width_xml("w:tcW", preferred_width.as_ref()) {
5556                        cell_props.push(xml);
5557                    }
5558                    if let Some(background_color) = cell
5559                        .style
5560                        .as_ref()
5561                        .and_then(|s| s.background_color.as_ref())
5562                    {
5563                        let fill = background_color.replace('#', "");
5564                        cell_props.push(format!(
5565                            r#"<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>"#
5566                        ));
5567                    }
5568                    if let Some(grid_span) = cell.style.as_ref().and_then(|s| s.grid_span) {
5569                        if grid_span > 1 {
5570                            cell_props.push(format!(r#"<w:gridSpan w:val="{grid_span}"/>"#));
5571                        }
5572                    }
5573                    if cell.style.as_ref().and_then(|s| s.v_merge_continuation) == Some(true) {
5574                        cell_props.push("<w:vMerge/>".to_string());
5575                    } else if cell.style.as_ref().and_then(|s| s.row_span).is_some() {
5576                        cell_props.push(r#"<w:vMerge w:val="restart"/>"#.to_string());
5577                    }
5578                    let cell_margin_xml = table_box_spacing_xml(
5579                        cell.style.as_ref().and_then(|s| s.margin_twips.as_ref()),
5580                        "w:tcMar",
5581                    );
5582                    if !cell_margin_xml.is_empty() {
5583                        cell_props.push(cell_margin_xml);
5584                    }
5585                    let cell_border_xml = table_borders_xml(
5586                        cell.style.as_ref().and_then(|s| s.borders.as_ref()),
5587                        "w:tcBorders",
5588                    );
5589                    if !cell_border_xml.is_empty() {
5590                        cell_props.push(cell_border_xml);
5591                    }
5592                    if let Some(direction) = cell
5593                        .style
5594                        .as_ref()
5595                        .and_then(|s| s.text_direction.as_deref())
5596                    {
5597                        cell_props.push(format!(
5598                            r#"<w:textDirection w:val="{}"/>"#,
5599                            escape_xml(direction)
5600                        ));
5601                    }
5602                    if let Some(no_wrap) = cell.style.as_ref().and_then(|s| s.no_wrap) {
5603                        cell_props.push(format!(
5604                            r#"<w:noWrap w:val="{}"/>"#,
5605                            if no_wrap { "1" } else { "0" }
5606                        ));
5607                    }
5608                    if let Some(vertical_align) = cell.style.as_ref().and_then(|s| s.vertical_align)
5609                    {
5610                        if matches!(
5611                            vertical_align,
5612                            TableCellVerticalAlign::Top
5613                                | TableCellVerticalAlign::Center
5614                                | TableCellVerticalAlign::Bottom
5615                        ) {
5616                            cell_props.push(format!(
5617                                r#"<w:vAlign w:val="{}"/>"#,
5618                                table_cell_vertical_align_str(vertical_align)
5619                            ));
5620                        }
5621                    }
5622
5623                    let tc_pr = if cell_props.is_empty() {
5624                        String::new()
5625                    } else {
5626                        format!("<w:tcPr>{}</w:tcPr>", cell_props.join(""))
5627                    };
5628                    format!(
5629                        "<w:tc>{}{}</w:tc>",
5630                        tc_pr,
5631                        if serialized_cells.is_empty() {
5632                            "<w:p><w:r><w:t/></w:r></w:p>"
5633                        } else {
5634                            &serialized_cells
5635                        }
5636                    )
5637                })
5638                .collect::<String>();
5639
5640            let tr_pr = if row_props.is_empty() {
5641                String::new()
5642            } else {
5643                format!("<w:trPr>{}</w:trPr>", row_props.join(""))
5644            };
5645            format!("<w:tr>{tr_pr}{cells}</w:tr>")
5646        })
5647        .collect::<String>();
5648
5649    state.current_table_text_alignment = previous_table_alignment;
5650    state.current_table_snap_to_grid = previous_table_snap;
5651    format!(
5652        "<w:tbl><w:tblPr>{}</w:tblPr>{table_grid_xml}{rows}</w:tbl>",
5653        table_props.join("")
5654    )
5655}
5656
5657const EMPTY_RELATIONSHIPS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
5658
5659fn create_image_serialization_state<'a>(
5660    base_package: &'a mut OoxmlPackage,
5661    owner_part_name: &str,
5662) -> ImageSerializationState<'a> {
5663    let relationship_part_name = relationship_part_name_for_word_part(owner_part_name);
5664    let relationships_xml = base_package
5665        .parts
5666        .get(&relationship_part_name)
5667        .map(|part| part.content.as_str())
5668        .unwrap_or(EMPTY_RELATIONSHIPS_XML);
5669
5670    let relationships = parse_relationships_xml(relationships_xml);
5671    let relationship_by_target = relationships
5672        .iter()
5673        .map(|relationship| (relationship.target.clone(), relationship.clone()))
5674        .collect::<HashMap<_, _>>();
5675
5676    let used_image_numbers = base_package
5677        .binary_assets
5678        .keys()
5679        .filter_map(|part_name| {
5680            let lower = part_name.to_ascii_lowercase();
5681            let prefix = "word/media/image";
5682            if !lower.starts_with(prefix) {
5683                return None;
5684            }
5685            let rest = &lower[prefix.len()..];
5686            let dot = rest.find('.')?;
5687            rest[..dot].parse::<i64>().ok()
5688        })
5689        .collect::<Vec<_>>();
5690
5691    let next_image_index = used_image_numbers.iter().copied().max().unwrap_or(0).max(0) + 1;
5692
5693    let styles = crate::parse::parse_style_sheet(base_package);
5694    let paragraph_style_grid_owners = styles
5695        .paragraph_style_by_id
5696        .iter()
5697        .filter(|(_, style)| style.source_has_snap_to_grid == Some(true))
5698        .filter_map(|(id, style)| style.snap_to_grid.map(|value| (id.clone(), value)))
5699        .collect();
5700    let paragraph_snap_to_grid_by_style_id = styles
5701        .paragraph_style_by_id
5702        .iter()
5703        .filter_map(|(id, style)| style.snap_to_grid.map(|value| (id.clone(), value)))
5704        .collect();
5705    let default_paragraph_snap_to_grid = styles
5706        .default_paragraph_style
5707        .as_ref()
5708        .and_then(|style| style.snap_to_grid);
5709    let paragraph_style_alignment_owners = styles
5710        .paragraph_style_by_id
5711        .iter()
5712        .filter(|(_, style)| style.source_has_text_alignment == Some(true))
5713        .filter_map(|(id, style)| style.text_alignment.map(|value| (id.clone(), value)))
5714        .collect();
5715    let table_styles = styles.table_style_by_id;
5716    let paragraph_text_alignment_by_style_id = styles
5717        .paragraph_style_by_id
5718        .into_iter()
5719        .filter_map(|(id, style)| style.text_alignment.map(|value| (id, value)))
5720        .collect();
5721    let default_paragraph_text_alignment = styles
5722        .default_paragraph_style
5723        .as_ref()
5724        .and_then(|style| style.text_alignment)
5725        .or(styles.default_paragraph_text_alignment);
5726    ImageSerializationState {
5727        owner_part_name: owner_part_name.to_string(),
5728        paragraph_style_grid_owners,
5729        paragraph_snap_to_grid_by_style_id,
5730        default_paragraph_snap_to_grid,
5731        current_table_snap_to_grid: None,
5732        paragraph_style_alignment_owners,
5733        table_styles,
5734        current_table_text_alignment: None,
5735        paragraph_text_alignment_by_style_id,
5736        default_paragraph_text_alignment,
5737        next_image_index: next_image_index.max(1),
5738        next_relationship_index: next_relationship_index(&relationships),
5739        relationships,
5740        relationship_by_target,
5741        pkg: base_package,
5742        theme_fonts: OnceCell::new(),
5743    }
5744}
5745
5746fn has_xmlns_prefix(open_tag: &str, prefix: &str) -> bool {
5747    let patterns = [format!(" xmlns:{prefix}=\""), format!(" xmlns:{prefix}='")];
5748    patterns.iter().any(|pattern| {
5749        open_tag
5750            .as_bytes()
5751            .windows(pattern.len())
5752            .any(|window| window.eq_ignore_ascii_case(pattern.as_bytes()))
5753    })
5754}
5755
5756fn ensure_namespace(open_tag: &str, prefix: &str, namespace: &str) -> String {
5757    if has_xmlns_prefix(open_tag, prefix) {
5758        return open_tag.to_string();
5759    }
5760
5761    if open_tag.ends_with('>') {
5762        format!(
5763            "{} xmlns:{prefix}=\"{namespace}\">",
5764            &open_tag[..open_tag.len() - 1]
5765        )
5766    } else {
5767        open_tag.to_string()
5768    }
5769}
5770
5771fn ensure_ignorable_prefix(open_tag: &str, prefix: &str) -> String {
5772    let lower = open_tag.to_ascii_lowercase();
5773    let marker = " mc:ignorable=";
5774    if let Some(start) = lower.find(marker) {
5775        let quote_byte = open_tag.as_bytes()[start + marker.len()];
5776        let quote = quote_byte as char;
5777        let value_start = start + marker.len() + 1;
5778        let value_end = open_tag[value_start..]
5779            .find(quote)
5780            .map(|index| value_start + index)
5781            .unwrap_or(open_tag.len());
5782        let current_value = &open_tag[value_start..value_end];
5783        let tokens: Vec<&str> = current_value
5784            .split_whitespace()
5785            .map(str::trim)
5786            .filter(|token| !token.is_empty())
5787            .collect();
5788        if tokens.iter().any(|token| *token == prefix) {
5789            return open_tag.to_string();
5790        }
5791        let updated_value = if tokens.is_empty() {
5792            prefix.to_string()
5793        } else {
5794            format!("{} {}", tokens.join(" "), prefix)
5795        };
5796        return format!(
5797            "{} mc:Ignorable={}{}{}{}",
5798            &open_tag[..start],
5799            quote,
5800            updated_value,
5801            quote,
5802            &open_tag[value_end + 1..]
5803        );
5804    }
5805
5806    if open_tag.ends_with('>') {
5807        format!(
5808            "{} mc:Ignorable=\"{prefix}\">",
5809            &open_tag[..open_tag.len() - 1]
5810        )
5811    } else {
5812        open_tag.to_string()
5813    }
5814}
5815
5816fn ensure_word_section_part_open_tag(
5817    existing_part_content: Option<&str>,
5818    root_tag_name: &str,
5819) -> String {
5820    let existing_open_tag = existing_part_content.and_then(|content| {
5821        let pattern = format!("<{root_tag_name}");
5822        let start = content.find(&pattern)?;
5823        let end = content[start..].find('>')?;
5824        Some(content[start..start + end + 1].to_string())
5825    });
5826
5827    let mut open_tag = existing_open_tag.unwrap_or_else(|| {
5828        format!(r#"<{root_tag_name} xmlns:w="{WORD_MAIN_NS}" xmlns:r="{OFFICE_REL_NS}">"#)
5829    });
5830
5831    open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
5832    open_tag = ensure_namespace(&open_tag, "r", OFFICE_REL_NS);
5833    open_tag = ensure_namespace(&open_tag, "a", DRAWING_MAIN_NS);
5834    open_tag = ensure_namespace(&open_tag, "wp", DRAWING_WORD_NS);
5835    open_tag = ensure_namespace(&open_tag, "pic", DRAWING_PICTURE_NS);
5836    open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
5837    open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
5838    open_tag = ensure_ignorable_prefix(&open_tag, "w14");
5839    open_tag
5840}
5841
5842fn serialize_section_part_xml(
5843    section_nodes: &[DocNode],
5844    root_tag_name: &str,
5845    part_name: &str,
5846    pkg: &mut OoxmlPackage,
5847) -> (String, Vec<Relationship>) {
5848    let existing_content = pkg.parts.get(part_name).map(|part| part.content.clone());
5849    let (body_xml, relationships) = {
5850        let mut state = create_image_serialization_state(pkg, part_name);
5851        let mut run_id_ref = 1i64;
5852        let body_xml = section_nodes
5853            .iter()
5854            .map(|node| match node {
5855                DocNode::Paragraph(paragraph) => {
5856                    paragraph_xml(paragraph, &mut state, &mut run_id_ref)
5857                }
5858                DocNode::Table(table) => table_xml(table, &mut state, &mut run_id_ref),
5859            })
5860            .collect::<String>();
5861        (body_xml, state.relationships)
5862    };
5863    let open_tag = ensure_word_section_part_open_tag(existing_content.as_deref(), root_tag_name);
5864    let xml = format!(
5865        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>{open_tag}{body_xml}</{root_tag_name}>"#
5866    );
5867    (xml, relationships)
5868}
5869
5870fn section_root_tag_name_for_part_name(part_name: &str) -> &'static str {
5871    if part_name.to_ascii_lowercase().contains("header") {
5872        "w:hdr"
5873    } else {
5874        "w:ftr"
5875    }
5876}
5877
5878fn serialize_header_footer_parts(model: &DocModel, pkg: &mut OoxmlPackage) {
5879    let mut sections_by_part_name: HashMap<String, Vec<DocNode>> = HashMap::new();
5880
5881    let mut add_part_sections = |sections: &[(String, Vec<DocNode>)]| {
5882        for (part_name, nodes) in sections {
5883            let normalized_part_name = normalize_word_part_name(part_name);
5884            if normalized_part_name.is_empty()
5885                || sections_by_part_name.contains_key(&normalized_part_name)
5886            {
5887                continue;
5888            }
5889            sections_by_part_name.insert(normalized_part_name, nodes.clone());
5890        }
5891    };
5892
5893    add_part_sections(
5894        &model
5895            .metadata
5896            .header_sections
5897            .iter()
5898            .map(|section| (section.part_name.clone(), section.nodes.clone()))
5899            .collect::<Vec<_>>(),
5900    );
5901    add_part_sections(
5902        &model
5903            .metadata
5904            .footer_sections
5905            .iter()
5906            .map(|section| (section.part_name.clone(), section.nodes.clone()))
5907            .collect::<Vec<_>>(),
5908    );
5909    if let Some(sections) = &model.metadata.sections {
5910        for section in sections {
5911            add_part_sections(
5912                &section
5913                    .header_sections
5914                    .iter()
5915                    .map(|header| (header.part_name.clone(), header.nodes.clone()))
5916                    .collect::<Vec<_>>(),
5917            );
5918            add_part_sections(
5919                &section
5920                    .footer_sections
5921                    .iter()
5922                    .map(|footer| (footer.part_name.clone(), footer.nodes.clone()))
5923                    .collect::<Vec<_>>(),
5924            );
5925        }
5926    }
5927
5928    let part_names: Vec<String> = sections_by_part_name.keys().cloned().collect();
5929    for part_name in part_names {
5930        let nodes = sections_by_part_name.remove(&part_name).unwrap_or_default();
5931        let root_tag_name = section_root_tag_name_for_part_name(&part_name);
5932        let (xml, relationships) =
5933            serialize_section_part_xml(&nodes, root_tag_name, &part_name, pkg);
5934        pkg.parts.insert(
5935            part_name.clone(),
5936            OoxmlPart {
5937                name: part_name.clone(),
5938                content: xml,
5939            },
5940        );
5941        let relationship_part_name = relationship_part_name_for_word_part(&part_name);
5942        pkg.parts.insert(
5943            relationship_part_name.clone(),
5944            OoxmlPart {
5945                name: relationship_part_name,
5946                content: render_relationships_xml(&relationships),
5947            },
5948        );
5949    }
5950}
5951
5952fn opening_tag_end(xml: &str, start: usize) -> Option<usize> {
5953    let mut quote: Option<u8> = None;
5954    for (offset, byte) in xml.as_bytes().iter().copied().enumerate().skip(start) {
5955        if let Some(active_quote) = quote {
5956            if byte == active_quote {
5957                quote = None;
5958            }
5959            continue;
5960        }
5961        if byte == b'\'' || byte == b'"' {
5962            quote = Some(byte);
5963            continue;
5964        }
5965        if byte == b'>' {
5966            return Some(offset + 1);
5967        }
5968    }
5969    None
5970}
5971
5972fn attribute_value_range(open_tag: &str, attribute_name: &str) -> Option<(usize, usize)> {
5973    let bytes = open_tag.as_bytes();
5974    let name = attribute_name.as_bytes();
5975    let mut index = 1usize;
5976    while index + name.len() <= bytes.len() {
5977        let matches_name = bytes[index..index + name.len()]
5978            .iter()
5979            .zip(name.iter())
5980            .all(|(left, right)| left.eq_ignore_ascii_case(right));
5981        let before_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
5982        if !matches_name || !before_boundary {
5983            index += 1;
5984            continue;
5985        }
5986        let mut cursor = index + name.len();
5987        while bytes
5988            .get(cursor)
5989            .is_some_and(|byte| byte.is_ascii_whitespace())
5990        {
5991            cursor += 1;
5992        }
5993        if bytes.get(cursor) != Some(&b'=') {
5994            index += 1;
5995            continue;
5996        }
5997        cursor += 1;
5998        while bytes
5999            .get(cursor)
6000            .is_some_and(|byte| byte.is_ascii_whitespace())
6001        {
6002            cursor += 1;
6003        }
6004        let quote = *bytes.get(cursor)?;
6005        if quote != b'\'' && quote != b'"' {
6006            index += 1;
6007            continue;
6008        }
6009        let value_start = cursor + 1;
6010        let value_end = bytes[value_start..]
6011            .iter()
6012            .position(|byte| *byte == quote)
6013            .map(|offset| value_start + offset)?;
6014        return Some((value_start, value_end));
6015    }
6016    None
6017}
6018
6019fn set_xml_attribute(open_tag: &str, name: &str, value: &str) -> String {
6020    let escaped = escape_xml(value);
6021    if let Some((value_start, value_end)) = attribute_value_range(open_tag, name) {
6022        let mut updated = open_tag.to_string();
6023        updated.replace_range(value_start..value_end, &escaped);
6024        return updated;
6025    }
6026
6027    let mut insert_at = open_tag.len().saturating_sub(1);
6028    while insert_at > 0 && open_tag.as_bytes()[insert_at - 1].is_ascii_whitespace() {
6029        insert_at -= 1;
6030    }
6031    if insert_at > 0 && open_tag.as_bytes()[insert_at - 1] == b'/' {
6032        insert_at -= 1;
6033    }
6034    let mut updated = open_tag.to_string();
6035    updated.insert_str(insert_at, &format!(r#" {name}="{escaped}""#));
6036    updated
6037}
6038
6039fn deterministic_comment_paragraph_id(comment_id: i64) -> String {
6040    let normalized = comment_id.max(0) as u64;
6041    format!("{:08X}", (0xC000_0000u64 + normalized) & 0xFFFF_FFFF)
6042}
6043
6044fn source_comment_paragraph_id(source_xml: &str) -> Option<String> {
6045    let range = extract_balanced_tag_ranges(source_xml, "w:p")
6046        .into_iter()
6047        .last()?;
6048    let open_end = opening_tag_end(source_xml, range.start)?;
6049    get_attribute(&source_xml[range.start..open_end], "w14:paraId")
6050}
6051
6052fn ensure_comment_paragraph_id(source_xml: &str, paragraph_id: &str) -> String {
6053    let Some(range) = extract_balanced_tag_ranges(source_xml, "w:p")
6054        .into_iter()
6055        .last()
6056    else {
6057        return source_xml.to_string();
6058    };
6059    let Some(open_end) = opening_tag_end(source_xml, range.start) else {
6060        return source_xml.to_string();
6061    };
6062    let open_tag = &source_xml[range.start..open_end];
6063    let updated_open_tag = set_xml_attribute(open_tag, "w14:paraId", paragraph_id);
6064    format!(
6065        "{}{}{}",
6066        &source_xml[..range.start],
6067        updated_open_tag,
6068        &source_xml[open_end..]
6069    )
6070}
6071
6072fn comment_paragraph_id(comment: &DocumentCommentDefinition) -> String {
6073    comment
6074        .extended_paragraph_id
6075        .clone()
6076        .or_else(|| {
6077            comment
6078                .source_xml
6079                .as_deref()
6080                .and_then(source_comment_paragraph_id)
6081        })
6082        .unwrap_or_else(|| deterministic_comment_paragraph_id(comment.id))
6083}
6084
6085fn render_comment_block(
6086    comment: &DocumentCommentDefinition,
6087    ensure_paragraph_id: bool,
6088) -> (String, String) {
6089    let paragraph_id = comment_paragraph_id(comment);
6090    if let Some(source_xml) = &comment.source_xml {
6091        let rendered = if ensure_paragraph_id {
6092            ensure_comment_paragraph_id(source_xml, &paragraph_id)
6093        } else {
6094            source_xml.clone()
6095        };
6096        return (rendered, paragraph_id);
6097    }
6098
6099    let mut attributes = vec![format!(r#"w:id="{}""#, comment.id)];
6100    if let Some(author) = &comment.author {
6101        attributes.push(format!(r#"w:author="{}""#, escape_xml(author)));
6102    }
6103    if let Some(initials) = &comment.initials {
6104        attributes.push(format!(r#"w:initials="{}""#, escape_xml(initials)));
6105    }
6106    if let Some(date) = &comment.date {
6107        attributes.push(format!(r#"w:date="{}""#, escape_xml(date)));
6108    }
6109    let preserve = if should_preserve_whitespace(&comment.text) {
6110        r#" xml:space="preserve""#
6111    } else {
6112        ""
6113    };
6114    (
6115        format!(
6116            r#"<w:comment {}><w:p w14:paraId="{}"><w:r><w:t{}>{}</w:t></w:r></w:p></w:comment>"#,
6117            attributes.join(" "),
6118            escape_xml(&paragraph_id),
6119            preserve,
6120            escape_xml(&comment.text)
6121        ),
6122        paragraph_id,
6123    )
6124}
6125
6126fn comment_id_from_block(block: &str) -> Option<i64> {
6127    let open_end = opening_tag_end(block, 0)?;
6128    get_attribute(&block[..open_end], "w:id")?.parse().ok()
6129}
6130
6131fn replace_xml_ranges(source: &str, mut replacements: Vec<(usize, usize, String)>) -> String {
6132    replacements.sort_by(|left, right| right.0.cmp(&left.0));
6133    let mut updated = source.to_string();
6134    for (start, end, value) in replacements {
6135        updated.replace_range(start..end, &value);
6136    }
6137    updated
6138}
6139
6140fn ensure_comments_root_namespaces(xml: &str) -> String {
6141    let Some(root) = extract_balanced_tag_ranges(xml, "w:comments")
6142        .into_iter()
6143        .next()
6144    else {
6145        return xml.to_string();
6146    };
6147    let Some(open_end) = opening_tag_end(xml, root.start) else {
6148        return xml.to_string();
6149    };
6150    let mut open_tag = xml[root.start..open_end].to_string();
6151    open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
6152    open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
6153    open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
6154    open_tag = ensure_ignorable_prefix(&open_tag, "w14");
6155    format!("{}{}{}", &xml[..root.start], open_tag, &xml[open_end..])
6156}
6157
6158fn merge_comments_xml(existing: Option<&str>, rendered_by_id: &HashMap<i64, String>) -> String {
6159    if let Some(existing) = existing {
6160        let ranges = extract_balanced_tag_ranges(existing, "w:comment");
6161        let mut seen = std::collections::HashSet::new();
6162        let replacements = ranges
6163            .into_iter()
6164            .filter_map(|range| {
6165                let block = &existing[range.start..range.end];
6166                let id = comment_id_from_block(block)?;
6167                let rendered = rendered_by_id.get(&id)?;
6168                seen.insert(id);
6169                Some((range.start, range.end, rendered.clone()))
6170            })
6171            .collect::<Vec<_>>();
6172        let mut merged = replace_xml_ranges(existing, replacements);
6173        let mut missing = rendered_by_id
6174            .iter()
6175            .filter(|(id, _)| !seen.contains(id))
6176            .collect::<Vec<_>>();
6177        missing.sort_by_key(|(id, _)| **id);
6178        if !missing.is_empty() {
6179            let appended = missing
6180                .into_iter()
6181                .map(|(_, block)| block.as_str())
6182                .collect::<String>();
6183            if let Some(close_start) = merged.rfind("</w:comments>") {
6184                merged.insert_str(close_start, &appended);
6185            }
6186        }
6187        return ensure_comments_root_namespaces(&merged);
6188    }
6189
6190    let mut rendered = rendered_by_id.iter().collect::<Vec<_>>();
6191    rendered.sort_by_key(|(id, _)| **id);
6192    let blocks = rendered
6193        .into_iter()
6194        .map(|(_, block)| block.as_str())
6195        .collect::<String>();
6196    format!(
6197        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>"#
6198    )
6199}
6200
6201#[derive(Clone)]
6202struct CommentExtendedState {
6203    done: Option<bool>,
6204    parent_paragraph_id: Option<String>,
6205    patch_done: bool,
6206    patch_parent: bool,
6207}
6208
6209fn patch_comment_extended_block(block: &str, state: &CommentExtendedState, force: bool) -> String {
6210    let Some(open_end) = opening_tag_end(block, 0) else {
6211        return block.to_string();
6212    };
6213    let mut open_tag = block[..open_end].to_string();
6214    if (force || state.patch_done) && state.done.is_some() {
6215        let done = state.done.unwrap_or(false);
6216        open_tag = set_xml_attribute(&open_tag, "w15:done", if done { "1" } else { "0" });
6217    }
6218    if (force || state.patch_parent) && state.parent_paragraph_id.is_some() {
6219        let parent_id = state.parent_paragraph_id.as_deref().unwrap_or_default();
6220        open_tag = set_xml_attribute(&open_tag, "w15:paraIdParent", parent_id);
6221    }
6222    format!("{}{}", open_tag, &block[open_end..])
6223}
6224
6225fn merge_comments_extended_xml(
6226    existing: Option<&str>,
6227    desired: &HashMap<String, CommentExtendedState>,
6228) -> Option<String> {
6229    if desired.is_empty() {
6230        return existing.map(str::to_string);
6231    }
6232    if let Some(existing) = existing {
6233        let ranges = extract_balanced_tag_ranges(existing, "w15:commentEx");
6234        let mut seen = std::collections::HashSet::new();
6235        let replacements = ranges
6236            .into_iter()
6237            .filter_map(|range| {
6238                let block = &existing[range.start..range.end];
6239                let open_end = opening_tag_end(block, 0)?;
6240                let paragraph_id = get_attribute(&block[..open_end], "w15:paraId")?;
6241                let state = desired.get(&paragraph_id)?;
6242                seen.insert(paragraph_id);
6243                Some((
6244                    range.start,
6245                    range.end,
6246                    patch_comment_extended_block(block, state, false),
6247                ))
6248            })
6249            .collect::<Vec<_>>();
6250        let mut merged = replace_xml_ranges(existing, replacements);
6251        let mut missing = desired
6252            .iter()
6253            .filter(|(paragraph_id, _)| !seen.contains(*paragraph_id))
6254            .collect::<Vec<_>>();
6255        missing.sort_by_key(|(paragraph_id, _)| *paragraph_id);
6256        let appended = missing
6257            .into_iter()
6258            .map(|(paragraph_id, state)| {
6259                let mut tag = format!(
6260                    r#"<w15:commentEx w15:paraId="{}"/>"#,
6261                    escape_xml(paragraph_id)
6262                );
6263                tag = patch_comment_extended_block(&tag, state, true);
6264                tag
6265            })
6266            .collect::<String>();
6267        if let Some(close_start) = merged.rfind("</w15:commentsEx>") {
6268            merged.insert_str(close_start, &appended);
6269        }
6270        return Some(merged);
6271    }
6272
6273    let mut states = desired.iter().collect::<Vec<_>>();
6274    states.sort_by_key(|(paragraph_id, _)| *paragraph_id);
6275    let entries = states
6276        .into_iter()
6277        .map(|(paragraph_id, state)| {
6278            let tag = format!(
6279                r#"<w15:commentEx w15:paraId="{}"/>"#,
6280                escape_xml(paragraph_id)
6281            );
6282            patch_comment_extended_block(&tag, state, true)
6283        })
6284        .collect::<String>();
6285    Some(format!(
6286        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:commentsEx xmlns:w15="{WORD_2012_NS}">{entries}</w15:commentsEx>"#
6287    ))
6288}
6289
6290fn ensure_document_relationship(pkg: &mut OoxmlPackage, relationship_type: &str, target: &str) {
6291    let resolved_part_name =
6292        relationship_part_name_for_word_part(&crate::package::main_document_part_name(pkg));
6293    let part_name = resolved_part_name.as_str();
6294    let existing = pkg
6295        .parts
6296        .get(part_name)
6297        .map(|part| part.content.clone())
6298        .unwrap_or_else(|| EMPTY_RELATIONSHIPS_XML.to_string());
6299    let relationships = parse_relationships_xml(&existing);
6300    if relationships.iter().any(|relationship| {
6301        relationship.r#type == relationship_type
6302            && crate::parse::resolve_part_path(
6303                &crate::package::main_document_part_name(pkg),
6304                &relationship.target,
6305            ) == crate::parse::resolve_part_path(
6306                &crate::package::main_document_part_name(pkg),
6307                target,
6308            )
6309    }) {
6310        return;
6311    }
6312    let relationship = format!(
6313        r#"<Relationship Id="rId{}" Type="{}" Target="{}"/>"#,
6314        next_relationship_index(&relationships),
6315        escape_xml(relationship_type),
6316        escape_xml(target)
6317    );
6318    let updated = if let Some(close_start) = existing.rfind("</Relationships>") {
6319        format!(
6320            "{}{}{}",
6321            &existing[..close_start],
6322            relationship,
6323            &existing[close_start..]
6324        )
6325    } else if let Some(root_start) = existing.find("<Relationships") {
6326        if let Some(root_end) = opening_tag_end(&existing, root_start) {
6327            let root_tag = &existing[root_start..root_end];
6328            if root_tag.trim_end().ends_with("/>") {
6329                let slash = root_tag
6330                    .rfind('/')
6331                    .unwrap_or(root_tag.len().saturating_sub(1));
6332                format!(
6333                    "{}{}>{}</Relationships>{}",
6334                    &existing[..root_start],
6335                    &root_tag[..slash],
6336                    relationship,
6337                    &existing[root_end..]
6338                )
6339            } else {
6340                existing
6341            }
6342        } else {
6343            existing
6344        }
6345    } else {
6346        existing
6347    };
6348    pkg.parts.insert(
6349        part_name.to_string(),
6350        OoxmlPart {
6351            name: part_name.to_string(),
6352            content: updated,
6353        },
6354    );
6355}
6356
6357fn serialize_comment_parts(model: &DocModel, pkg: &mut OoxmlPackage) {
6358    let source_part_name = crate::package::related_document_part_name(pkg, "comments");
6359    let Some(comments) = model
6360        .metadata
6361        .comments
6362        .as_ref()
6363        .filter(|comments| !comments.is_empty())
6364    else {
6365        return;
6366    };
6367
6368    let extended_comment_ids = comments
6369        .iter()
6370        .filter(|comment| {
6371            comment.resolved.is_some()
6372                || comment.source_resolved.is_some()
6373                || comment.resolution_dirty == Some(true)
6374                || comment.is_new == Some(true)
6375                || comment.parent_id.is_some()
6376        })
6377        .map(|comment| comment.id)
6378        .chain(comments.iter().filter_map(|comment| comment.parent_id))
6379        .collect::<std::collections::HashSet<_>>();
6380    let paragraph_id_by_comment_id = comments
6381        .iter()
6382        .map(|comment| (comment.id, comment_paragraph_id(comment)))
6383        .collect::<HashMap<_, _>>();
6384    let rendered_by_id = comments
6385        .iter()
6386        .map(|comment| {
6387            let (rendered, _) =
6388                render_comment_block(comment, extended_comment_ids.contains(&comment.id));
6389            (comment.id, rendered)
6390        })
6391        .collect::<HashMap<_, _>>();
6392
6393    let comments_xml = merge_comments_xml(
6394        pkg.parts
6395            .get(&source_part_name)
6396            .map(|part| part.content.as_str()),
6397        &rendered_by_id,
6398    );
6399    pkg.parts.insert(
6400        source_part_name.clone(),
6401        OoxmlPart {
6402            name: source_part_name.clone(),
6403            content: comments_xml,
6404        },
6405    );
6406
6407    let desired_extended = comments
6408        .iter()
6409        .filter(|comment| extended_comment_ids.contains(&comment.id))
6410        .filter_map(|comment| {
6411            let paragraph_id = paragraph_id_by_comment_id.get(&comment.id)?.clone();
6412            let parent_paragraph_id = comment
6413                .parent_id
6414                .and_then(|parent_id| paragraph_id_by_comment_id.get(&parent_id).cloned());
6415            Some((
6416                paragraph_id,
6417                CommentExtendedState {
6418                    done: comment.resolved.or(comment.source_resolved),
6419                    parent_paragraph_id,
6420                    patch_done: comment.resolution_dirty == Some(true)
6421                        || comment.is_new == Some(true),
6422                    patch_parent: comment.is_new == Some(true),
6423                },
6424            ))
6425        })
6426        .collect::<HashMap<_, _>>();
6427    if let Some(comments_extended_xml) = merge_comments_extended_xml(
6428        pkg.parts
6429            .get("word/commentsExtended.xml")
6430            .map(|part| part.content.as_str()),
6431        &desired_extended,
6432    ) {
6433        pkg.parts.insert(
6434            "word/commentsExtended.xml".to_string(),
6435            OoxmlPart {
6436                name: "word/commentsExtended.xml".to_string(),
6437                content: comments_extended_xml,
6438            },
6439        );
6440    }
6441
6442    ensure_content_type_override(pkg, &source_part_name, COMMENTS_CONTENT_TYPE);
6443    ensure_document_relationship(pkg, REL_TYPE_COMMENTS, &format!("/{source_part_name}"));
6444    if pkg.parts.contains_key("word/commentsExtended.xml") {
6445        ensure_content_type_override(
6446            pkg,
6447            "word/commentsExtended.xml",
6448            COMMENTS_EXTENDED_CONTENT_TYPE,
6449        );
6450        ensure_document_relationship(pkg, REL_TYPE_COMMENTS_EXTENDED, "commentsExtended.xml");
6451    }
6452}
6453
6454fn upsert_settings_element(
6455    xml: &str,
6456    container_tag: &str,
6457    tag_name: &str,
6458    attributes: &[(&str, &str)],
6459    matches: impl Fn(&str) -> bool,
6460) -> String {
6461    for range in extract_balanced_tag_ranges(xml, tag_name) {
6462        let Some(open_end) = opening_tag_end(xml, range.start) else {
6463            continue;
6464        };
6465        let opening_tag = &xml[range.start..open_end];
6466        if !matches(opening_tag) {
6467            continue;
6468        }
6469        let updated_tag = attributes
6470            .iter()
6471            .fold(opening_tag.to_string(), |tag, (name, value)| {
6472                set_xml_attribute(&tag, name, value)
6473            });
6474        let mut updated = xml.to_string();
6475        updated.replace_range(range.start..open_end, &updated_tag);
6476        return updated;
6477    }
6478
6479    let attributes_xml = attributes
6480        .iter()
6481        .map(|(name, value)| format!(r#" {name}="{}""#, escape_xml(value)))
6482        .collect::<String>();
6483    let child_xml = format!("<{tag_name}{attributes_xml}/>");
6484    let Some(container_range) = extract_balanced_tag_ranges(xml, container_tag)
6485        .into_iter()
6486        .next()
6487    else {
6488        return xml.to_string();
6489    };
6490    let Some(open_end) = opening_tag_end(xml, container_range.start) else {
6491        return xml.to_string();
6492    };
6493    let opening_tag = &xml[container_range.start..open_end];
6494    let mut updated = xml.to_string();
6495    if opening_tag.trim_end().ends_with("/>") {
6496        let expanded = format!(
6497            "{}>{child_xml}</{container_tag}>",
6498            opening_tag
6499                .trim_end()
6500                .trim_end_matches('>')
6501                .trim_end()
6502                .trim_end_matches('/')
6503                .trim_end(),
6504        );
6505        updated.replace_range(container_range.start..container_range.end, &expanded);
6506        return updated;
6507    }
6508    let closing_tag = format!("</{container_tag}>");
6509    let Some(close_start) = xml[container_range.start..container_range.end].rfind(&closing_tag)
6510    else {
6511        return xml.to_string();
6512    };
6513    let mut insert_at = container_range.start + close_start;
6514    if container_tag == "w:settings" {
6515        if let Some(index) = SETTINGS_TAIL_ORDER
6516            .iter()
6517            .position(|name| *name == tag_name)
6518        {
6519            for following_tag in &SETTINGS_TAIL_ORDER[index + 1..] {
6520                if let Some(range) = extract_balanced_tag_ranges(xml, following_tag)
6521                    .into_iter()
6522                    .next()
6523                {
6524                    if range.start >= open_end && range.start < insert_at {
6525                        insert_at = range.start;
6526                    }
6527                }
6528            }
6529        }
6530    }
6531    updated.insert_str(insert_at, &child_xml);
6532    updated
6533}
6534
6535fn remove_settings_element(xml: &str, tag_name: &str, matches: impl Fn(&str) -> bool) -> String {
6536    let mut updated = xml.to_string();
6537    for range in extract_balanced_tag_ranges(xml, tag_name).into_iter().rev() {
6538        let Some(open_end) = opening_tag_end(xml, range.start) else {
6539            continue;
6540        };
6541        if matches(&xml[range.start..open_end]) {
6542            updated.replace_range(range.start..range.end, "");
6543        }
6544    }
6545    updated
6546}
6547
6548const COMPAT_PROPERTIES_ORDER: &[&str] = &[
6549    "w:useSingleBorderforContiguousCells",
6550    "w:wpJustification",
6551    "w:noTabHangInd",
6552    "w:noLeading",
6553    "w:spaceForUL",
6554    "w:noColumnBalance",
6555    "w:balanceSingleByteDoubleByteWidth",
6556    "w:noExtraLineSpacing",
6557    "w:doNotLeaveBackslashAlone",
6558    "w:ulTrailSpace",
6559    "w:doNotExpandShiftReturn",
6560    "w:spacingInWholePoints",
6561    "w:lineWrapLikeWord6",
6562    "w:printBodyTextBeforeHeader",
6563    "w:printColBlack",
6564    "w:wpSpaceWidth",
6565    "w:showBreaksInFrames",
6566    "w:subFontBySize",
6567    "w:suppressBottomSpacing",
6568    "w:suppressTopSpacing",
6569    "w:suppressSpacingAtTopOfPage",
6570    "w:suppressTopSpacingWP",
6571    "w:suppressSpBfAfterPgBrk",
6572    "w:swapBordersFacingPages",
6573    "w:convMailMergeEsc",
6574    "w:truncateFontHeightsLikeWP6",
6575    "w:mwSmallCaps",
6576    "w:usePrinterMetrics",
6577    "w:doNotSuppressParagraphBorders",
6578    "w:wrapTrailSpaces",
6579    "w:footnoteLayoutLikeWW8",
6580    "w:shapeLayoutLikeWW8",
6581    "w:alignTablesRowByRow",
6582    "w:forgetLastTabAlignment",
6583    "w:adjustLineHeightInTable",
6584    "w:autoSpaceLikeWord95",
6585    "w:noSpaceRaiseLower",
6586    "w:doNotUseHTMLParagraphAutoSpacing",
6587    "w:layoutRawTableWidth",
6588    "w:layoutTableRowsApart",
6589    "w:useWord97LineBreakRules",
6590    "w:doNotBreakWrappedTables",
6591    "w:doNotSnapToGridInCell",
6592    "w:selectFldWithFirstOrLastChar",
6593    "w:applyBreakingRules",
6594    "w:doNotWrapTextWithPunct",
6595    "w:doNotUseEastAsianBreakRules",
6596    "w:useWord2002TableStyleRules",
6597    "w:growAutofit",
6598    "w:useFELayout",
6599    "w:useNormalStyleForList",
6600    "w:doNotUseIndentAsNumberingTabStop",
6601    "w:useAltKinsokuLineBreakRules",
6602    "w:allowSpaceOfSameStyleInTable",
6603    "w:doNotSuppressIndentation",
6604    "w:doNotAutofitConstrainedTables",
6605    "w:autofitToFirstFixedWidthCell",
6606    "w:underlineTabInNumList",
6607    "w:displayHangulFixedWidth",
6608    "w:splitPgBreakAndParaMark",
6609    "w:doNotVertAlignCellWithSp",
6610    "w:doNotBreakConstrainedForcedTable",
6611    "w:doNotVertAlignInTxbx",
6612    "w:useAnsiKerningPairs",
6613    "w:cachedColBalance",
6614    "w:compatSetting",
6615];
6616
6617fn serialize_document_settings(model: &DocModel, pkg: &mut OoxmlPackage) {
6618    let source_part_name = crate::package::related_document_part_name(pkg, "settings");
6619    let original_compatibility = crate::parse::parse_document_compatibility_settings(pkg);
6620    let original_tab_stop = crate::parse::parse_document_default_tab_stop_twips(pkg);
6621    let compatibility = model.metadata.compatibility.as_ref();
6622    let raw_compatibility_mode = compatibility.and_then(|settings| settings.compatibility_mode);
6623    let compatibility_mode = raw_compatibility_mode.filter(|mode| u32::try_from(*mode).is_ok());
6624    let enable_open_type_features =
6625        compatibility.and_then(|settings| settings.enable_open_type_features);
6626    let even_and_odd_headers = compatibility.and_then(|settings| settings.even_and_odd_headers);
6627    let default_tab_stop = model
6628        .metadata
6629        .default_tab_stop_twips
6630        .filter(|value| *value >= 0);
6631    let imported_settings = model.metadata.document_settings_imported == Some(true);
6632    let mode_changed = (compatibility_mode.is_some()
6633        || (imported_settings && raw_compatibility_mode.is_none()))
6634        && compatibility_mode
6635            != original_compatibility
6636                .as_ref()
6637                .and_then(|settings| settings.compatibility_mode);
6638    let headers_changed = (even_and_odd_headers.is_some() || imported_settings)
6639        && even_and_odd_headers
6640            != original_compatibility
6641                .as_ref()
6642                .and_then(|settings| settings.even_and_odd_headers);
6643    let open_type_changed = (enable_open_type_features.is_some() || imported_settings)
6644        && enable_open_type_features
6645            != original_compatibility
6646                .as_ref()
6647                .and_then(|settings| settings.enable_open_type_features);
6648    let tabs_changed = (default_tab_stop.is_some()
6649        || (imported_settings && model.metadata.default_tab_stop_twips.is_none()))
6650        && default_tab_stop != original_tab_stop;
6651    let imported_line_settings = model.metadata.line_spacing_compatibility_imported == Some(true);
6652    let line_settings = [
6653        (
6654            "w:adjustLineHeightInTable",
6655            compatibility.and_then(|value| value.adjust_line_height_in_table),
6656            original_compatibility
6657                .as_ref()
6658                .and_then(|value| value.adjust_line_height_in_table),
6659        ),
6660        (
6661            "w:noLeading",
6662            compatibility.and_then(|value| value.no_leading),
6663            original_compatibility
6664                .as_ref()
6665                .and_then(|value| value.no_leading),
6666        ),
6667        (
6668            "w:spaceForUL",
6669            compatibility.and_then(|value| value.space_for_ul),
6670            original_compatibility
6671                .as_ref()
6672                .and_then(|value| value.space_for_ul),
6673        ),
6674        (
6675            "w:noExtraLineSpacing",
6676            compatibility.and_then(|value| value.no_extra_line_spacing),
6677            original_compatibility
6678                .as_ref()
6679                .and_then(|value| value.no_extra_line_spacing),
6680        ),
6681        (
6682            "w:suppressBottomSpacing",
6683            compatibility.and_then(|value| value.suppress_bottom_spacing),
6684            original_compatibility
6685                .as_ref()
6686                .and_then(|value| value.suppress_bottom_spacing),
6687        ),
6688        (
6689            "w:suppressTopSpacingWP",
6690            compatibility.and_then(|value| value.suppress_top_spacing_wp),
6691            original_compatibility
6692                .as_ref()
6693                .and_then(|value| value.suppress_top_spacing_wp),
6694        ),
6695        (
6696            "w:truncateFontHeightsLikeWP6",
6697            compatibility.and_then(|value| value.truncate_font_heights_like_wp6),
6698            original_compatibility
6699                .as_ref()
6700                .and_then(|value| value.truncate_font_heights_like_wp6),
6701        ),
6702    ];
6703    let line_settings_changed = line_settings.iter().any(|(_, current, original)| {
6704        (current.is_some() || imported_line_settings) && current != original
6705    });
6706    if !mode_changed
6707        && !open_type_changed
6708        && !headers_changed
6709        && !tabs_changed
6710        && !line_settings_changed
6711    {
6712        return;
6713    }
6714
6715    let mut settings_xml = pkg.parts.get(&source_part_name)
6716        .map(|part| part.content.clone())
6717        .unwrap_or_else(|| format!(
6718            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="{WORD_MAIN_NS}"/>"#,
6719        ));
6720    if tabs_changed {
6721        settings_xml = if let Some(default_tab_stop) = default_tab_stop {
6722            let value = default_tab_stop.to_string();
6723            upsert_settings_element(
6724                &settings_xml,
6725                "w:settings",
6726                "w:defaultTabStop",
6727                &[("w:val", &value)],
6728                |_| true,
6729            )
6730        } else {
6731            remove_settings_element(&settings_xml, "w:defaultTabStop", |_| true)
6732        };
6733    }
6734    if headers_changed {
6735        settings_xml = if let Some(even_and_odd_headers) = even_and_odd_headers {
6736            let value = if even_and_odd_headers { "1" } else { "0" };
6737            upsert_settings_element(
6738                &settings_xml,
6739                "w:settings",
6740                "w:evenAndOddHeaders",
6741                &[("w:val", value)],
6742                |_| true,
6743            )
6744        } else {
6745            remove_settings_element(&settings_xml, "w:evenAndOddHeaders", |_| true)
6746        };
6747    }
6748    for (changed, name, value) in [
6749        (
6750            mode_changed,
6751            "compatibilityMode",
6752            compatibility_mode.map(|value| value.to_string()),
6753        ),
6754        (
6755            open_type_changed,
6756            "enableOpenTypeFeatures",
6757            enable_open_type_features.map(|value| if value { "1" } else { "0" }.to_string()),
6758        ),
6759    ] {
6760        if !changed {
6761            continue;
6762        }
6763        let compat_range = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6764            .into_iter()
6765            .next();
6766        let compat_xml = compat_range
6767            .as_ref()
6768            .map(|range| &settings_xml[range.start..range.end])
6769            .unwrap_or("<w:compat/>");
6770        let matches_setting = |tag: &str| {
6771            get_attribute(tag, "w:name").as_deref() == Some(name)
6772                && get_attribute(tag, "w:uri").as_deref()
6773                    == Some("http://schemas.microsoft.com/office/word")
6774        };
6775        let updated_compat = if let Some(value) = value {
6776            upsert_settings_element(
6777                compat_xml,
6778                "w:compat",
6779                "w:compatSetting",
6780                &[
6781                    ("w:name", name),
6782                    ("w:uri", "http://schemas.microsoft.com/office/word"),
6783                    ("w:val", &value),
6784                ],
6785                matches_setting,
6786            )
6787        } else {
6788            remove_settings_element(compat_xml, "w:compatSetting", matches_setting)
6789        };
6790        if let Some(range) = compat_range {
6791            settings_xml.replace_range(range.start..range.end, &updated_compat);
6792        } else {
6793            settings_xml =
6794                upsert_settings_element(&settings_xml, "w:settings", "w:compat", &[], |_| true);
6795            if let Some(range) = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6796                .into_iter()
6797                .next()
6798            {
6799                settings_xml.replace_range(range.start..range.end, &updated_compat);
6800            }
6801        }
6802    }
6803    if line_settings_changed {
6804        let range = extract_balanced_tag_ranges(&settings_xml, "w:settings")
6805            .into_iter()
6806            .next()
6807            .and_then(|settings| {
6808                direct_property_children(&settings_xml[settings.start..settings.end])
6809                    .into_iter()
6810                    .find(|(name, _)| name == "w:compat")
6811                    .map(|(_, child)| crate::xml::TagRange {
6812                        start: settings.start + child.start,
6813                        end: settings.start + child.end,
6814                    })
6815            });
6816        let mut compat = range
6817            .as_ref()
6818            .map(|range| settings_xml[range.start..range.end].to_string())
6819            .unwrap_or_else(|| "<w:compat/>".to_string());
6820        for (name, current, original) in line_settings {
6821            if (current.is_none() && !imported_line_settings) || current == original {
6822                continue;
6823            }
6824            let attributes =
6825                current.map(|value| [("w:val", if value { "1" } else { "0" }.to_string())]);
6826            compat = patch_direct_table_property(
6827                &compat,
6828                "w:compat",
6829                name,
6830                attributes.as_ref().map(|values| values.as_slice()),
6831                COMPAT_PROPERTIES_ORDER,
6832            );
6833        }
6834        if let Some(range) = range {
6835            settings_xml.replace_range(range.start..range.end, &compat);
6836        } else if !direct_property_children(&compat).is_empty() {
6837            settings_xml =
6838                upsert_settings_element(&settings_xml, "w:settings", "w:compat", &[], |_| true);
6839            if let Some(range) = extract_balanced_tag_ranges(&settings_xml, "w:compat")
6840                .into_iter()
6841                .next()
6842            {
6843                settings_xml.replace_range(range.start..range.end, &compat);
6844            }
6845        }
6846    }
6847    pkg.parts.insert(
6848        source_part_name.clone(),
6849        OoxmlPart {
6850            name: source_part_name.clone(),
6851            content: settings_xml,
6852        },
6853    );
6854    ensure_content_type_override(pkg, &source_part_name, SETTINGS_CONTENT_TYPE);
6855    ensure_document_relationship(pkg, REL_TYPE_SETTINGS, &format!("/{source_part_name}"));
6856}
6857
6858fn ensure_document_open_tag(model: &DocModel) -> String {
6859    let mut open_tag = model.metadata.document_open_tag.clone().unwrap_or_else(|| {
6860        format!(r#"<w:document xmlns:w="{WORD_MAIN_NS}" xmlns:r="{OFFICE_REL_NS}">"#)
6861    });
6862
6863    open_tag = ensure_namespace(&open_tag, "w", WORD_MAIN_NS);
6864    open_tag = ensure_namespace(&open_tag, "r", OFFICE_REL_NS);
6865    open_tag = ensure_namespace(&open_tag, "a", DRAWING_MAIN_NS);
6866    open_tag = ensure_namespace(&open_tag, "wp", DRAWING_WORD_NS);
6867    open_tag = ensure_namespace(&open_tag, "pic", DRAWING_PICTURE_NS);
6868    open_tag = ensure_namespace(&open_tag, "w14", WORD_2010_NS);
6869    open_tag = ensure_namespace(&open_tag, "mc", MARKUP_COMPATIBILITY_NS);
6870    open_tag = ensure_ignorable_prefix(&open_tag, "w14");
6871    open_tag
6872}
6873
6874fn model_to_document_xml_with_pkg(model: &DocModel, pkg: &mut OoxmlPackage) -> String {
6875    let resolved_document_part = crate::package::main_document_part_name(pkg);
6876    let document_part_name = resolved_document_part.as_str();
6877    let (body_xml, relationships) = {
6878        let mut state = create_image_serialization_state(pkg, document_part_name);
6879        let mut run_id_ref = 1i64;
6880        let body_xml = model
6881            .nodes
6882            .iter()
6883            .map(|node| match node {
6884                DocNode::Paragraph(paragraph) => {
6885                    paragraph_xml(paragraph, &mut state, &mut run_id_ref)
6886                }
6887                DocNode::Table(table) => table_xml(table, &mut state, &mut run_id_ref),
6888            })
6889            .collect::<String>();
6890        (body_xml, state.relationships)
6891    };
6892
6893    let document_relationship_part_name = relationship_part_name_for_word_part(document_part_name);
6894    pkg.parts.insert(
6895        document_relationship_part_name.clone(),
6896        OoxmlPart {
6897            name: document_relationship_part_name,
6898            content: render_relationships_xml(&relationships),
6899        },
6900    );
6901
6902    let document_open_tag = ensure_document_open_tag(model);
6903    let section_properties_xml = model
6904        .metadata
6905        .section_properties_xml
6906        .as_deref()
6907        .unwrap_or(DEFAULT_SECTION_PROPERTIES_XML);
6908
6909    format!(
6910        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>{document_open_tag}<w:body>{body_xml}{section_properties_xml}</w:body></w:document>"#
6911    )
6912}
6913
6914pub fn can_preserve_image_paint_source(
6915    image: &ImageRunNode,
6916    base_package: Option<&OoxmlPackage>,
6917    owner_part_name: &str,
6918) -> bool {
6919    if image.paint_bounds.is_none() || !valid_image_paint_geometry(image) {
6920        return false;
6921    }
6922    let default_package;
6923    let package = match base_package {
6924        Some(package) => package,
6925        None => {
6926            default_package = create_minimal_docx_package(None);
6927            &default_package
6928        }
6929    };
6930    let owner = owner_part_name;
6931    if owner.is_empty() {
6932        return false;
6933    }
6934    let relationships = package
6935        .parts
6936        .get(&relationship_part_name_for_word_part(owner))
6937        .map(|part| parse_relationships_xml(&part.content))
6938        .unwrap_or_default();
6939    preserved_picture_run_xml(image, owner, &relationships, package).is_some()
6940        || (valid_image_paint_bounds(image)
6941            && edited_vml_textbox_source_plan(image, owner, &relationships, package).is_some())
6942}
6943
6944pub fn validate_image_paint_bounds(
6945    model: &DocModel,
6946    base_package: Option<&OoxmlPackage>,
6947) -> Result<(), String> {
6948    fn paragraph<'a>(
6949        value: &'a ParagraphNode,
6950        owner: &str,
6951        images: &mut Vec<(&'a ImageRunNode, String)>,
6952    ) {
6953        for child in &value.children {
6954            if let ParagraphChildNode::Image(image) = child {
6955                if image.paint_bounds.is_some() {
6956                    images.push((image, owner.to_owned()));
6957                }
6958                if let Some(content) = &image.content_nodes {
6959                    nodes(content, owner, images);
6960                }
6961            }
6962        }
6963    }
6964    fn table<'a>(value: &'a TableNode, owner: &str, images: &mut Vec<(&'a ImageRunNode, String)>) {
6965        for row in &value.rows {
6966            for cell in &row.cells {
6967                for child in &cell.nodes {
6968                    match child {
6969                        TableCellContentNode::Paragraph(value) => paragraph(value, owner, images),
6970                        TableCellContentNode::Table(value) => table(value, owner, images),
6971                    }
6972                }
6973            }
6974        }
6975        if let Some(patches) = &value.source_text_patches {
6976            for patch in patches {
6977                paragraph(&patch.paragraph, owner, images);
6978            }
6979        }
6980    }
6981    fn nodes<'a>(values: &'a [DocNode], owner: &str, images: &mut Vec<(&'a ImageRunNode, String)>) {
6982        for value in values {
6983            match value {
6984                DocNode::Paragraph(value) => paragraph(value, owner, images),
6985                DocNode::Table(value) => table(value, owner, images),
6986            }
6987        }
6988    }
6989    let default_package;
6990    let package = match base_package {
6991        Some(package) => package,
6992        None => {
6993            default_package = create_minimal_docx_package(None);
6994            &default_package
6995        }
6996    };
6997    let mut images = Vec::new();
6998    nodes(
6999        &model.nodes,
7000        &crate::package::main_document_part_name(package),
7001        &mut images,
7002    );
7003    let mut seen_parts = std::collections::HashSet::new();
7004    let mut add_part = |part_name: &str, values| {
7005        let owner = normalize_word_part_name(part_name);
7006        if !owner.is_empty() && seen_parts.insert(owner.clone()) {
7007            nodes(values, &owner, &mut images);
7008        }
7009    };
7010    for section in &model.metadata.header_sections {
7011        add_part(&section.part_name, &section.nodes);
7012    }
7013    for section in &model.metadata.footer_sections {
7014        add_part(&section.part_name, &section.nodes);
7015    }
7016    if let Some(sections) = &model.metadata.sections {
7017        for section in sections {
7018            for header in &section.header_sections {
7019                add_part(&header.part_name, &header.nodes);
7020            }
7021            for footer in &section.footer_sections {
7022                add_part(&footer.part_name, &footer.nodes);
7023            }
7024        }
7025    }
7026    for (image, owner) in images {
7027        if can_preserve_image_paint_source(image, Some(package), &owner) {
7028            continue;
7029        }
7030        if !valid_image_paint_bounds(image) {
7031            return Err("Inline image paint bounds require finite positive geometry and an inline layout frame".into());
7032        }
7033        if paint_png_bytes(image).is_none() {
7034            return Err("Inline image paint cannot be preserved from source; a current full-viewport PNG is required before export".into());
7035        }
7036    }
7037    Ok(())
7038}
7039
7040/// Panics for unsupported inline paint. Use validation before infallible XML output.
7041pub fn model_to_document_xml(model: &DocModel, base_package: Option<&OoxmlPackage>) -> String {
7042    validate_image_paint_bounds(model, base_package)
7043        .expect("Unsupported inline image paint; validate before XML serialization");
7044    let mut pkg = base_package.cloned().unwrap_or_else(|| {
7045        create_minimal_docx_package(Some(
7046            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>"#,
7047        ))
7048    });
7049    model_to_document_xml_with_pkg(model, &mut pkg)
7050}
7051
7052/// Panics for unsupported inline paint. Use `try_serialize_doc_model` for a fallible result.
7053pub fn serialize_doc_model(model: &DocModel, base_package: Option<&OoxmlPackage>) -> OoxmlPackage {
7054    try_serialize_doc_model(model, base_package)
7055        .expect("Unsupported inline image paint; use try_serialize_doc_model")
7056}
7057
7058pub fn try_serialize_doc_model(
7059    model: &DocModel,
7060    base_package: Option<&OoxmlPackage>,
7061) -> Result<OoxmlPackage, String> {
7062    validate_image_paint_bounds(model, base_package)?;
7063    let mut seed = base_package
7064        .cloned()
7065        .unwrap_or_else(|| create_minimal_docx_package(None));
7066    let document_part_name = crate::package::main_document_part_name(&seed);
7067    let document_xml = model_to_document_xml_with_pkg(model, &mut seed);
7068    let with_document = with_part(
7069        &seed,
7070        OoxmlPart {
7071            name: document_part_name,
7072            content: document_xml,
7073        },
7074    );
7075    let mut result = with_document;
7076    serialize_header_footer_parts(model, &mut result);
7077    serialize_comment_parts(model, &mut result);
7078    serialize_document_settings(model, &mut result);
7079    Ok(result)
7080}
7081
7082pub fn serialize_docx(
7083    model: &DocModel,
7084    base_package: Option<&OoxmlPackage>,
7085) -> Result<Vec<u8>, String> {
7086    crate::table_grid::validate_collapsed_table_grids(model)?;
7087    package_to_bytes(&try_serialize_doc_model(model, base_package)?)
7088}
7089
7090#[cfg(test)]
7091#[path = "serialize/tests.rs"]
7092mod fidelity_tests;