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