Skip to main content

extend_docx/
serialize.rs

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