Skip to main content

pptx_parse/
write.rs

1//! Edit-driven package writes. Parts the deck did not change are copied
2//! through byte for byte; edited slides are patched at the XML level so
3//! unmodeled markup survives.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6use std::ops::Range;
7
8use ooxml_drawingml::{
9    ColorValue, GradientFill, ShapeFill, ShapeOutline, Theme, resolve_color_value_to_hex_with_theme,
10};
11
12use crate::PptxError;
13use crate::comments::{CommentFlavor, CommentSlide, CommentsWrite, authors_xml, comments_xml};
14use crate::drawing::parse_run_properties;
15use crate::model::{
16    Bullet, PptxPackage, RunProperties, ShapeElements, SlideReference, TextAutofit,
17};
18use crate::xml::{
19    DRAWINGML_NS, PRESENTATIONML_NS, ParseBudget, ParseLimits, XmlElement, XmlNode,
20    alternate_content_branch_index, parse_xml, serialize_xml, serialize_xml_fragment,
21};
22
23const OFFICE_RELATIONSHIPS_NS: &str =
24    "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
25const SLIDE_RELATIONSHIP_TYPE: &str =
26    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
27const SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
28    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
29const STRICT_SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
30    "http://purl.oclc.org/ooxml/officeDocument/relationships/slideLayout";
31const SLIDE_CONTENT_TYPE: &str =
32    "application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
33const IMAGE_RELATIONSHIP_TYPE: &str =
34    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
35const MIN_SLIDE_ID: u32 = 256;
36const MAX_SLIDE_ID: u32 = 2_147_483_647;
37
38/// The desired final deck, expressed against the parsed source package.
39pub struct DeckWrite {
40    pub slides: Vec<SlideWrite>,
41    pub comments: Option<CommentsWrite>,
42    pub notes: Option<NotesWrite>,
43}
44
45/// Changed speaker notes; empty text clears the body placeholder.
46pub struct NotesWrite {
47    pub per_slide: Vec<(CommentSlide, String)>,
48}
49
50const NOTES_SLIDE_RELATIONSHIP_TYPE: &str =
51    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
52
53pub enum SlideWrite {
54    /// Copy the source part through untouched.
55    Keep { part_path: String },
56    /// Patch the source part; `shapes` is the full top-level list in final order.
57    Patch {
58        part_path: String,
59        shapes: Vec<ShapeWrite>,
60        background: Option<BackgroundWrite>,
61        layout_part_path: Option<String>,
62    },
63    Clone {
64        part_path: String,
65        shapes: Vec<ShapeWrite>,
66        background: Option<BackgroundWrite>,
67        layout_part_path: Option<String>,
68    },
69    /// Mint a new slide part.
70    Add {
71        name: Option<String>,
72        layout_part_path: Option<String>,
73        shapes: Vec<ShapeAdd>,
74        background: Option<Box<ShapeFill>>,
75    },
76}
77
78/// A slide background write: `Set` replaces the slide's `p:bg`, `Clear`
79/// removes it so the layout or master background shows.
80#[derive(Clone, Debug, PartialEq)]
81pub enum BackgroundWrite {
82    Set(Box<ShapeFill>),
83    Clear,
84}
85
86pub enum ShapeWrite {
87    /// Keep the source shape element untouched. `source_index` counts shape
88    /// elements at the same nesting level, in document order.
89    Keep {
90        source_index: usize,
91    },
92    Patch {
93        source_index: usize,
94        patch: Box<ShapePatch>,
95    },
96    Add(Box<ShapeAdd>),
97}
98
99#[derive(Default)]
100pub struct ShapePatch {
101    pub placeholder: Option<crate::Placeholder>,
102    /// The halves the edit changed. Transform pieces the source already
103    /// spells out and the edit did not touch are left as they are.
104    pub offset: Option<(i64, i64)>,
105    pub extent: Option<(i64, i64)>,
106    /// Fills in transform pieces the source never spelled out.
107    pub inherited: Option<InheritedTransform>,
108    /// The source spells out no transform of its own, so `inherited` also
109    /// sets the rotation and flips of an `a:xfrm` it already has.
110    pub materializes: bool,
111    pub fill: Option<ShapeFill>,
112    /// A default outline clears the stroke.
113    pub outline: Option<ShapeOutline>,
114    pub adjust_values: Option<BTreeMap<String, f64>>,
115    pub table_structure: Option<TableStructure>,
116    pub texts: Vec<TextWrite>,
117    /// Non-empty only when a group's child list must be rebuilt.
118    pub children: Vec<ShapeWrite>,
119}
120
121/// The transform a shape inherits from its layout or master placeholder.
122#[derive(Clone, Copy)]
123pub struct InheritedTransform {
124    pub x: i64,
125    pub y: i64,
126    pub width: i64,
127    pub height: i64,
128    pub rotation_deg: f64,
129    pub flip_horizontal: bool,
130    pub flip_vertical: bool,
131}
132
133#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct TableStructure {
136    pub generation: String,
137    pub story_keys: Vec<String>,
138    pub grid: Vec<i64>,
139    pub columns: Vec<usize>,
140    pub copied_columns: Vec<bool>,
141    pub rows: Vec<TableRowSource>,
142}
143
144#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct TableRowSource {
147    pub source_row: usize,
148    pub copied: bool,
149    pub copied_cells: Vec<bool>,
150    pub height: i64,
151    pub cells: Vec<[usize; 2]>,
152}
153
154pub struct TextWrite {
155    pub target: TextTarget,
156    pub autofit: Option<TextAutofit>,
157    pub paragraphs: Vec<ParagraphWrite>,
158}
159
160pub enum TextTarget {
161    Body,
162    TableCell { row: usize, cell: usize },
163}
164
165pub struct ParagraphWrite {
166    /// Index of the paragraph in the source text body, when it survives.
167    pub source_index: Option<usize>,
168    pub source_paragraphs: Vec<Option<String>>,
169    pub preserved_xml: Option<String>,
170    /// `false` keeps the source paragraph verbatim.
171    pub rebuild: bool,
172    pub properties_changed: bool,
173    pub alignment: Option<String>,
174    pub level: u32,
175    pub bullet: Option<Bullet>,
176    pub runs: Vec<RunWrite>,
177}
178
179pub struct RunWrite {
180    pub text: String,
181    pub properties: RunProperties,
182}
183
184pub struct ShapeAdd {
185    pub name: String,
186    pub geometry: String,
187    pub text_box: bool,
188    pub x: i64,
189    pub y: i64,
190    pub width: i64,
191    pub height: i64,
192    pub adjust_values: BTreeMap<String, f64>,
193    pub fill: Option<ShapeFill>,
194    pub outline: Option<ShapeOutline>,
195    pub paragraphs: Option<Vec<ParagraphWrite>>,
196    /// A picture instead of an autoshape; mints its own media part and relationship.
197    pub picture: Option<PictureAdd>,
198}
199
200pub struct PictureAdd {
201    pub media_bytes: Vec<u8>,
202    pub content_type: String,
203}
204
205/// Writes the package with `deck` applied. Slides marked [`SlideWrite::Keep`]
206/// and every non-slide part keep their exact source bytes.
207pub fn write_pptx_with_edits(
208    package: &PptxPackage,
209    deck: &DeckWrite,
210) -> Result<Vec<u8>, PptxError> {
211    let limits = ParseLimits::default();
212    let mut budget = ParseBudget::new(&limits);
213
214    let final_paths: HashSet<&str> = deck
215        .slides
216        .iter()
217        .filter_map(|slide| match slide {
218            SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
219                Some(part_path.as_str())
220            }
221            SlideWrite::Add { .. } | SlideWrite::Clone { .. } => None,
222        })
223        .collect();
224    let removed: Vec<&SlideReference> = package
225        .presentation
226        .slides
227        .iter()
228        .filter(|reference| !final_paths.contains(reference.part_path.as_str()))
229        .collect();
230
231    let mut replacements: HashMap<String, Vec<u8>> = HashMap::new();
232    let mut new_parts: Vec<(String, Vec<u8>)> = Vec::new();
233
234    let mut minted = Vec::new();
235    let mut next_slide_number = next_slide_number(package);
236    let mut minted_slide_id: Option<u32> = None;
237    let mut next_relationship = next_relationship_number(package);
238    let mut structural = false;
239    let mut final_order: Vec<FinalSlide<'_>> = Vec::new();
240    let mut minted_by_slide: HashMap<usize, usize> = HashMap::new();
241    let mut cloned_dependencies = Vec::new();
242    {
243        let mut sink = PartSink {
244            package,
245            replacements: &mut replacements,
246            new_parts: &mut new_parts,
247        };
248
249        for slide in &deck.slides {
250            if let SlideWrite::Patch {
251                part_path,
252                shapes,
253                background,
254                layout_part_path,
255            } = slide
256            {
257                let bytes = package
258                    .part_bytes(part_path)
259                    .ok_or_else(|| PptxError::MissingPart(part_path.clone()))?;
260                let mut root = parse_xml(bytes, part_path, &mut budget)?;
261                let original_root = root.clone();
262                let theme =
263                    crate::slide_theme(package, Some(part_path), layout_part_path.as_deref());
264                patch_slide(
265                    &mut root,
266                    shapes,
267                    background.as_ref(),
268                    Some(&theme),
269                    part_path,
270                    package.shape_elements,
271                    &mut sink,
272                    &mut budget,
273                )?;
274                if (layout_part_path.is_some() || has_placeholder_patch(shapes))
275                    && background.is_none()
276                    && only_placeholder_edits(shapes)
277                {
278                    root.attributes.clone_from(&original_root.attributes);
279                }
280                if root != original_root {
281                    let xml = if layout_part_path.is_some() || has_placeholder_patch(shapes) {
282                        crate::attribute_patch::patch_attributes(bytes, &original_root, &root)
283                            .unwrap_or_else(|| serialize_xml(&root))
284                    } else {
285                        serialize_xml(&root)
286                    };
287                    sink.store(part_path, xml);
288                }
289                if let Some(layout) = layout_part_path {
290                    patch_layout_relationship(&mut sink, part_path, layout, &mut budget)?;
291                }
292            }
293        }
294
295        for (slide_index, slide) in deck.slides.iter().enumerate() {
296            match slide {
297                SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
298                    let reference = package
299                        .presentation
300                        .slides
301                        .iter()
302                        .find(|reference| &reference.part_path == part_path)
303                        .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
304                    final_order.push(FinalSlide::Existing(reference));
305                }
306                SlideWrite::Add { .. } | SlideWrite::Clone { .. } => {
307                    structural = true;
308                    let part_path = format!("ppt/slides/slide{next_slide_number}.xml");
309                    next_slide_number += 1;
310                    let relationship_id = format!("rId{next_relationship}");
311                    next_relationship += 1;
312                    let slide_id = match minted_slide_id {
313                        None => next_slide_id(package)?,
314                        Some(previous) => previous
315                            .checked_add(1)
316                            .filter(|id| *id <= MAX_SLIDE_ID)
317                            .ok_or_else(|| {
318                                write_error(
319                                    &package.presentation.part_path,
320                                    "the slide id space is exhausted",
321                                )
322                            })?,
323                    };
324                    minted_slide_id = Some(slide_id);
325                    match slide {
326                        SlideWrite::Clone {
327                            part_path: source,
328                            shapes,
329                            background,
330                            layout_part_path,
331                        } => {
332                            let bytes = package
333                                .part_bytes(source)
334                                .ok_or_else(|| PptxError::MissingPart(source.clone()))?;
335                            let mut root = parse_xml(bytes, source, &mut budget)?;
336                            let source_rels = slide_relationships_path(source);
337                            if let Some(bytes) = package.part_bytes(&source_rels) {
338                                let mut rels = parse_xml(bytes, &source_rels, &mut budget)?;
339                                rels.children.retain(|child| match child {
340                                    XmlNode::Element(element) => {
341                                        element.attribute("Type").is_none_or(|kind| {
342                                            !kind.ends_with("/comments")
343                                                && !kind.ends_with("/comment")
344                                        })
345                                    }
346                                    _ => true,
347                                });
348                                sink.store(
349                                    &slide_relationships_path(&part_path),
350                                    serialize_xml(&rels),
351                                );
352                            }
353                            cloned_dependencies.extend(
354                                package
355                                    .relationships
356                                    .get(source)
357                                    .into_iter()
358                                    .flatten()
359                                    .filter(|relationship| {
360                                        !relationship.has_type("/notesSlide")
361                                            && !relationship.has_type("/comments")
362                                            && !relationship.has_type("/comment")
363                                    })
364                                    .filter_map(|relationship| {
365                                        relationship.resolved_target.clone()
366                                    }),
367                            );
368                            cloned_dependencies.extend(clone_notes_part(
369                                &mut sink,
370                                source,
371                                &part_path,
372                                &mut budget,
373                            )?);
374                            if let Some(layout) = layout_part_path {
375                                patch_layout_relationship(
376                                    &mut sink,
377                                    &part_path,
378                                    layout,
379                                    &mut budget,
380                                )?;
381                            }
382                            let theme = crate::slide_theme(
383                                package,
384                                Some(source),
385                                layout_part_path.as_deref(),
386                            );
387                            patch_slide(
388                                &mut root,
389                                shapes,
390                                background.as_ref(),
391                                Some(&theme),
392                                &part_path,
393                                package.shape_elements,
394                                &mut sink,
395                                &mut budget,
396                            )?;
397                            sink.store(&part_path, serialize_xml(&root));
398                        }
399                        SlideWrite::Add {
400                            name,
401                            layout_part_path,
402                            shapes,
403                            background,
404                        } => {
405                            let layout = layout_part_path
406                                .clone()
407                                .filter(|path| {
408                                    package
409                                        .layouts
410                                        .iter()
411                                        .any(|layout| &layout.part_path == path)
412                                })
413                                .or_else(|| {
414                                    package
415                                        .layouts
416                                        .first()
417                                        .map(|layout| layout.part_path.clone())
418                                });
419                            if let Some(layout) = &layout {
420                                sink.store(
421                                    &slide_relationships_path(&part_path),
422                                    slide_relationships_xml(&part_path, layout),
423                                );
424                            }
425                            let xml = slide_xml(
426                                name.as_deref(),
427                                shapes,
428                                background.as_deref(),
429                                &part_path,
430                                &mut sink,
431                                &mut budget,
432                            )?;
433                            sink.store(&part_path, xml);
434                        }
435                        _ => unreachable!(),
436                    }
437                    minted.push(MintedSlide {
438                        part_path,
439                        relationship_id,
440                        slide_id,
441                    });
442                    minted_by_slide.insert(slide_index, minted.len() - 1);
443                    final_order.push(FinalSlide::Added(minted.len() - 1));
444                }
445            }
446        }
447    }
448    let original_paths: Vec<&str> = package
449        .presentation
450        .slides
451        .iter()
452        .map(|reference| reference.part_path.as_str())
453        .collect();
454    let kept_paths: Vec<&str> = final_order
455        .iter()
456        .filter_map(|slide| match slide {
457            FinalSlide::Existing(reference) => Some(reference.part_path.as_str()),
458            FinalSlide::Added(_) => None,
459        })
460        .collect();
461    structural = structural || kept_paths != original_paths;
462
463    let mut removed_paths = HashSet::new();
464    for reference in &removed {
465        removed_paths.insert(reference.part_path.clone());
466        removed_paths.insert(slide_relationships_path(&reference.part_path));
467    }
468    let orphans = orphaned_parts(package, &removed, &cloned_dependencies);
469    for orphan in &orphans {
470        removed_paths.insert(orphan.clone());
471        removed_paths.insert(slide_relationships_path(orphan));
472    }
473
474    if structural {
475        patch_structure(
476            package,
477            &final_order,
478            &minted,
479            &removed,
480            &orphans,
481            &mut replacements,
482            &mut budget,
483        )?;
484    }
485    if let Some(comments) = &deck.comments {
486        patch_comment_parts(
487            package,
488            comments,
489            &MintedSlides {
490                slides: &minted,
491                by_slide_index: &minted_by_slide,
492            },
493            &mut replacements,
494            &mut new_parts,
495            &mut removed_paths,
496            &mut budget,
497        )?;
498    }
499    if let Some(notes) = &deck.notes {
500        patch_notes_parts(
501            package,
502            notes,
503            &MintedSlides {
504                slides: &minted,
505                by_slide_index: &minted_by_slide,
506            },
507            &mut replacements,
508            &mut new_parts,
509            &mut removed_paths,
510            &mut budget,
511        )?;
512    }
513    {
514        let mut sink = PartSink {
515            package,
516            replacements: &mut replacements,
517            new_parts: &mut new_parts,
518        };
519        for slide in &deck.slides {
520            if let SlideWrite::Patch {
521                part_path,
522                layout_part_path: Some(_),
523                ..
524            } = slide
525            {
526                let path = slide_relationships_path(part_path);
527                if let (Some(source), Some(current)) =
528                    (package.part_bytes(&path), sink.current(&path))
529                {
530                    let before = parse_xml(source, &path, &mut budget)?;
531                    let after = parse_xml(&current, &path, &mut budget)?;
532                    if after.children.len() >= before.children.len() {
533                        let mut retained = after.clone();
534                        retained.children.truncate(before.children.len());
535                        if let Some(bytes) =
536                            crate::attribute_patch::patch_attributes(source, &before, &retained)
537                                .and_then(|bytes| {
538                                    append_relationships(
539                                        bytes,
540                                        &after.children[before.children.len()..],
541                                    )
542                                })
543                        {
544                            sink.store(&path, bytes);
545                        }
546                    }
547                }
548            }
549        }
550    }
551    let mut parts = Vec::with_capacity(package.parts.len() + new_parts.len());
552    for part in &package.parts {
553        if removed_paths.contains(&part.path) {
554            continue;
555        }
556        match replacements.remove(&part.path) {
557            Some(bytes) => parts.push((part.path.clone(), bytes)),
558            None => parts.push((part.path.clone(), part.bytes.clone())),
559        }
560    }
561    parts.extend(new_parts);
562    ooxml_opc::rezip_parts_preserving(&parts, package.source_container.as_bytes())
563        .map_err(PptxError::Container)
564}
565
566struct MintedSlide {
567    part_path: String,
568    relationship_id: String,
569    slide_id: u32,
570}
571
572enum FinalSlide<'a> {
573    Existing(&'a SlideReference),
574    Added(usize),
575}
576
577fn write_error(part: &str, message: impl Into<String>) -> PptxError {
578    PptxError::Write {
579        part: part.to_owned(),
580        message: message.into(),
581    }
582}
583
584fn next_slide_number(package: &PptxPackage) -> usize {
585    let paths: HashSet<&str> = package
586        .parts
587        .iter()
588        .map(|part| part.path.as_str())
589        .collect();
590    let mut number = package
591        .parts
592        .iter()
593        .filter_map(|part| {
594            part.path
595                .strip_prefix("ppt/slides/slide")?
596                .strip_suffix(".xml")?
597                .parse::<usize>()
598                .ok()
599        })
600        .max()
601        .unwrap_or(0)
602        + 1;
603    while paths.contains(format!("ppt/slides/slide{number}.xml").as_str())
604        || paths.contains(format!("ppt/slides/_rels/slide{number}.xml.rels").as_str())
605    {
606        number += 1;
607    }
608    number
609}
610
611fn next_slide_id(package: &PptxPackage) -> Result<u32, PptxError> {
612    let next = package
613        .presentation
614        .slides
615        .iter()
616        .map(|reference| reference.id)
617        .max()
618        .unwrap_or(0)
619        .max(MIN_SLIDE_ID - 1)
620        .checked_add(1)
621        .filter(|id| *id <= MAX_SLIDE_ID);
622    next.ok_or_else(|| {
623        write_error(
624            &package.presentation.part_path,
625            "the slide id space is exhausted",
626        )
627    })
628}
629
630fn next_relationship_number(package: &PptxPackage) -> usize {
631    package
632        .relationships
633        .get(&package.presentation.part_path)
634        .into_iter()
635        .flatten()
636        .filter_map(|relationship| relationship.id.strip_prefix("rId")?.parse::<usize>().ok())
637        .max()
638        .unwrap_or(0)
639        + 1
640}
641
642fn slide_relationships_path(part_path: &str) -> String {
643    match part_path.rsplit_once('/') {
644        Some((directory, name)) => format!("{directory}/_rels/{name}.rels"),
645        None => format!("_rels/{part_path}.rels"),
646    }
647}
648
649/// Parts that were reachable from the package root only through the removed
650/// slides (notes slides, media used nowhere else, ...), transitively.
651fn orphaned_parts(
652    package: &PptxPackage,
653    removed: &[&SlideReference],
654    cloned_dependencies: &[String],
655) -> Vec<String> {
656    if removed.is_empty() {
657        return Vec::new();
658    }
659    let excluded: HashSet<&str> = removed
660        .iter()
661        .map(|reference| reference.part_path.as_str())
662        .collect();
663    let before = reachable_parts(package, &HashSet::new(), &[]);
664    let after = reachable_parts(package, &excluded, cloned_dependencies);
665    before
666        .into_iter()
667        .filter(|part| !after.contains(part) && !excluded.contains(part.as_str()))
668        .collect()
669}
670
671fn reachable_parts(
672    package: &PptxPackage,
673    excluded: &HashSet<&str>,
674    roots: &[String],
675) -> HashSet<String> {
676    let mut reachable: HashSet<String> = roots
677        .iter()
678        .filter(|part| !excluded.contains(part.as_str()))
679        .cloned()
680        .collect();
681    let mut queue: Vec<String> = reachable.iter().cloned().collect();
682    queue.push(String::new());
683    while let Some(source) = queue.pop() {
684        for relationship in package.relationships.get(&source).into_iter().flatten() {
685            let Some(target) = &relationship.resolved_target else {
686                continue;
687            };
688            if excluded.contains(target.as_str()) {
689                continue;
690            }
691            if reachable.insert(target.clone()) {
692                queue.push(target.clone());
693            }
694        }
695    }
696    reachable
697}
698
699fn relative_target(from_part: &str, to_part: &str) -> String {
700    let from: Vec<&str> = from_part.split('/').collect();
701    let to: Vec<&str> = to_part.split('/').collect();
702    let from_directory = &from[..from.len().saturating_sub(1)];
703    let to_directory = &to[..to.len().saturating_sub(1)];
704    let shared = from_directory
705        .iter()
706        .zip(to_directory)
707        .take_while(|(a, b)| a == b)
708        .count();
709    let mut segments: Vec<String> = vec!["..".to_owned(); from_directory.len() - shared];
710    segments.extend(to[shared..].iter().map(|segment| (*segment).to_owned()));
711    segments.join("/")
712}
713
714// --- structural parts -------------------------------------------------------
715
716fn patch_structure(
717    package: &PptxPackage,
718    final_order: &[FinalSlide<'_>],
719    minted: &[MintedSlide],
720    removed: &[&SlideReference],
721    orphans: &[String],
722    replacements: &mut HashMap<String, Vec<u8>>,
723    budget: &mut ParseBudget<'_>,
724) -> Result<(), PptxError> {
725    let presentation_path = package.presentation.part_path.as_str();
726    let bytes = package
727        .part_bytes(presentation_path)
728        .ok_or_else(|| PptxError::MissingPart(presentation_path.to_owned()))?;
729    let mut root = parse_xml(bytes, presentation_path, budget)?;
730    patch_slide_id_list(&mut root, final_order, minted, presentation_path)?;
731    prune_slide_references(&mut root, removed);
732    replacements.insert(presentation_path.to_owned(), serialize_xml(&root));
733
734    let relationships_path = slide_relationships_path(presentation_path);
735    let bytes = package
736        .part_bytes(&relationships_path)
737        .ok_or_else(|| PptxError::MissingPart(relationships_path.clone()))?;
738    let mut root = parse_xml(bytes, &relationships_path, budget)?;
739    let removed_ids: HashSet<&str> = removed
740        .iter()
741        .map(|reference| reference.relationship_id.as_str())
742        .collect();
743    root.children.retain(|child| match child {
744        XmlNode::Element(element) if element.local_name() == "Relationship" => element
745            .attribute("Id")
746            .is_none_or(|id| !removed_ids.contains(id)),
747        _ => true,
748    });
749    for slide in minted {
750        root.children.push(XmlNode::Element(
751            XmlElement::new("Relationship")
752                .with_attribute("Id", slide.relationship_id.clone())
753                .with_attribute("Type", SLIDE_RELATIONSHIP_TYPE)
754                .with_attribute(
755                    "Target",
756                    relative_target(presentation_path, &slide.part_path),
757                ),
758        ));
759    }
760    replacements.insert(relationships_path, serialize_xml(&root));
761
762    let content_types_path = "[Content_Types].xml";
763    let bytes = match replacements.get(content_types_path) {
764        Some(bytes) => bytes.clone(),
765        None => package
766            .part_bytes(content_types_path)
767            .ok_or_else(|| PptxError::MissingPart(content_types_path.to_owned()))?
768            .to_vec(),
769    };
770    let mut root = parse_xml(&bytes, content_types_path, budget)?;
771    let removed_names: HashSet<String> = removed
772        .iter()
773        .map(|reference| format!("/{}", reference.part_path))
774        .chain(orphans.iter().map(|orphan| format!("/{orphan}")))
775        .collect();
776    root.children.retain(|child| match child {
777        XmlNode::Element(element) if element.local_name() == "Override" => element
778            .attribute("PartName")
779            .is_none_or(|name| !removed_names.contains(name)),
780        _ => true,
781    });
782    for slide in minted {
783        root.children.push(XmlNode::Element(
784            XmlElement::new("Override")
785                .with_attribute("PartName", format!("/{}", slide.part_path))
786                .with_attribute("ContentType", SLIDE_CONTENT_TYPE),
787        ));
788    }
789    replacements.insert(content_types_path.to_owned(), serialize_xml(&root));
790    Ok(())
791}
792
793const PACKAGE_RELATIONSHIPS_NS: &str =
794    "http://schemas.openxmlformats.org/package/2006/relationships";
795
796struct MintedSlides<'a> {
797    slides: &'a [MintedSlide],
798    by_slide_index: &'a HashMap<usize, usize>,
799}
800
801impl MintedSlides<'_> {
802    fn get(&self, slide_index: usize) -> Option<&MintedSlide> {
803        self.by_slide_index
804            .get(&slide_index)
805            .and_then(|at| self.slides.get(*at))
806    }
807}
808
809fn patch_comment_parts(
810    package: &PptxPackage,
811    write: &CommentsWrite,
812    minted: &MintedSlides<'_>,
813    replacements: &mut HashMap<String, Vec<u8>>,
814    new_parts: &mut Vec<(String, Vec<u8>)>,
815    removed_paths: &mut HashSet<String>,
816    budget: &mut ParseBudget<'_>,
817) -> Result<(), PptxError> {
818    let mut taken: HashSet<String> = package
819        .parts
820        .iter()
821        .map(|part| part.path.clone())
822        .chain(
823            package
824                .relationships
825                .values()
826                .flatten()
827                .filter(|relationship| relationship.is_type(write.comments_relationship_type()))
828                .filter_map(|relationship| relationship.resolved_target.clone()),
829        )
830        .collect();
831
832    let mut sink = PartSink {
833        package,
834        replacements,
835        new_parts,
836    };
837    for (slide, comments) in &write.per_slide {
838        let (slide_part_path, slide_id) = match slide {
839            CommentSlide::Existing(part_path) => {
840                let reference = package
841                    .presentation
842                    .slides
843                    .iter()
844                    .find(|reference| &reference.part_path == part_path)
845                    .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
846                (part_path.clone(), reference.id)
847            }
848            CommentSlide::Added(index) => {
849                let Some(slide) = minted.get(*index) else {
850                    continue;
851                };
852                (slide.part_path.clone(), slide.slide_id)
853            }
854        };
855        let relationships_path = slide_relationships_path(&slide_part_path);
856        let existing = existing_comment_part(package, &slide_part_path, write);
857
858        if comments.is_empty() {
859            if let Some(part_path) = existing {
860                removed_paths.insert(part_path.clone());
861                sink.forget(&part_path);
862                remove_content_type_override(&mut sink, &part_path, budget)?;
863                remove_relationship(
864                    &mut sink,
865                    &relationships_path,
866                    write.comments_relationship_type(),
867                    budget,
868                )?;
869            }
870            continue;
871        }
872
873        let is_existing = existing.is_some();
874        let part_path = match existing {
875            Some(part_path) => part_path,
876            None => mint_comment_part_path(write, &slide_part_path, &mut taken),
877        };
878        removed_paths.remove(&part_path);
879        let template = comments
880            .iter()
881            .filter_map(|comment| comment.source.as_ref())
882            .find_map(|(slide, _)| {
883                let path = existing_comment_part(package, slide, write)?;
884                package.part_bytes(&path)
885            });
886        let bytes = match (sink.current(&part_path), template) {
887            (Some(bytes), _) => crate::comment_patch::patch_comments_xml(
888                &bytes, &part_path, write, comments, slide_id, false, budget,
889            )?,
890            (None, Some(bytes)) => crate::comment_patch::patch_comments_xml(
891                bytes, &part_path, write, comments, slide_id, true, budget,
892            )?,
893            (None, None) => comments_xml(write, comments, slide_id),
894        };
895        sink.store(&part_path, bytes);
896        if !is_existing {
897            set_content_type_override(
898                &mut sink,
899                &part_path,
900                write.comments_content_type(),
901                budget,
902            )?;
903            set_relationship(
904                &mut sink,
905                &relationships_path,
906                write.comments_relationship_type(),
907                &relative_target(&slide_part_path, &part_path),
908                budget,
909            )?;
910        }
911    }
912
913    let presentation_relationships = slide_relationships_path(&package.presentation.part_path);
914    let authors_path = existing_authors_part(package, write)
915        .unwrap_or_else(|| write.authors_part_path().to_owned());
916    if write.authors.is_empty() {
917        removed_paths.insert(authors_path.clone());
918        sink.forget(&authors_path);
919        remove_content_type_override(&mut sink, &authors_path, budget)?;
920        remove_relationship(
921            &mut sink,
922            &presentation_relationships,
923            write.authors_relationship_type(),
924            budget,
925        )?;
926    } else {
927        removed_paths.remove(&authors_path);
928        let existing = sink.current(&authors_path);
929        let bytes = match &existing {
930            Some(bytes) => {
931                crate::comment_patch::patch_authors_xml(bytes, &authors_path, write, budget)?
932            }
933            None => authors_xml(write),
934        };
935        sink.store(&authors_path, bytes);
936        if existing.is_none() {
937            set_content_type_override(
938                &mut sink,
939                &authors_path,
940                write.authors_content_type(),
941                budget,
942            )?;
943            set_relationship(
944                &mut sink,
945                &presentation_relationships,
946                write.authors_relationship_type(),
947                &relative_target(&package.presentation.part_path, &authors_path),
948                budget,
949            )?;
950        }
951    }
952
953    if package.comment_flavor != Some(write.flavor) {
954        drop_other_flavor(package, write, &mut sink, removed_paths, budget)?;
955    }
956    Ok(())
957}
958
959fn clone_notes_part(
960    sink: &mut PartSink<'_>,
961    source_slide: &str,
962    copy_slide: &str,
963    budget: &mut ParseBudget<'_>,
964) -> Result<Vec<String>, PptxError> {
965    let Some(source_notes) = existing_notes_part(sink.package, source_slide) else {
966        return Ok(Vec::new());
967    };
968    let mut taken = sink
969        .package
970        .parts
971        .iter()
972        .map(|part| part.path.clone())
973        .chain(sink.new_parts.iter().map(|(path, _)| path.clone()))
974        .chain(
975            sink.package
976                .relationships
977                .values()
978                .flatten()
979                .filter_map(|relationship| relationship.resolved_target.clone()),
980        )
981        .collect();
982    let copy_notes = mint_notes_part_path(copy_slide, &mut taken);
983    let bytes = sink
984        .package
985        .part_bytes(&source_notes)
986        .ok_or_else(|| PptxError::MissingPart(source_notes.clone()))?
987        .to_vec();
988    sink.store(&copy_notes, bytes);
989    set_content_type_override(sink, &copy_notes, crate::notes::CT_NOTES_SLIDE, budget)?;
990    set_relationship(
991        sink,
992        &slide_relationships_path(copy_slide),
993        NOTES_SLIDE_RELATIONSHIP_TYPE,
994        &relative_target(copy_slide, &copy_notes),
995        budget,
996    )?;
997    let source_rels = slide_relationships_path(&source_notes);
998    let copy_rels = slide_relationships_path(&copy_notes);
999    let mut dependencies = Vec::new();
1000    if let Some(bytes) = sink.package.part_bytes(&source_rels) {
1001        let mut root = parse_xml(bytes, &source_rels, budget)?;
1002        root.children.retain(|child| match child {
1003            XmlNode::Element(element) => element
1004                .attribute("Type")
1005                .is_none_or(|kind| !kind.ends_with("/comments") && !kind.ends_with("/comment")),
1006            _ => true,
1007        });
1008        for child in &mut root.children {
1009            let XmlNode::Element(element) = child else {
1010                continue;
1011            };
1012            if element.local_name() != "Relationship"
1013                || element.attribute("TargetMode") == Some("External")
1014            {
1015                continue;
1016            }
1017            let target = if element
1018                .attribute("Type")
1019                .is_some_and(|kind| kind.ends_with("/slide"))
1020            {
1021                copy_slide.to_owned()
1022            } else if let Some(target) = element.attribute("Target") {
1023                let target = crate::relationships::resolve_target(&source_notes, target)?;
1024                dependencies.push(target.clone());
1025                target
1026            } else {
1027                continue;
1028            };
1029            element.set_attribute("Target", relative_target(&copy_notes, &target));
1030        }
1031        sink.store(&copy_rels, serialize_xml(&root));
1032    }
1033    set_relationship(
1034        sink,
1035        &copy_rels,
1036        SLIDE_RELATIONSHIP_TYPE,
1037        &relative_target(&copy_notes, copy_slide),
1038        budget,
1039    )?;
1040    Ok(dependencies)
1041}
1042
1043fn patch_notes_parts(
1044    package: &PptxPackage,
1045    write: &NotesWrite,
1046    minted: &MintedSlides<'_>,
1047    replacements: &mut HashMap<String, Vec<u8>>,
1048    new_parts: &mut Vec<(String, Vec<u8>)>,
1049    removed_paths: &mut HashSet<String>,
1050    budget: &mut ParseBudget<'_>,
1051) -> Result<(), PptxError> {
1052    let mut taken: HashSet<String> = package
1053        .parts
1054        .iter()
1055        .map(|part| part.path.clone())
1056        .chain(new_parts.iter().map(|(path, _)| path.clone()))
1057        .chain(
1058            package
1059                .relationships
1060                .values()
1061                .flatten()
1062                .filter(|relationship| relationship.is_type(NOTES_SLIDE_RELATIONSHIP_TYPE))
1063                .filter_map(|relationship| relationship.resolved_target.clone()),
1064        )
1065        .collect();
1066
1067    let mut sink = PartSink {
1068        package,
1069        replacements,
1070        new_parts,
1071    };
1072    for (slide, text) in &write.per_slide {
1073        let slide_part_path = match slide {
1074            CommentSlide::Existing(part_path) => part_path.clone(),
1075            CommentSlide::Added(index) => {
1076                let Some(slide) = minted.get(*index) else {
1077                    continue;
1078                };
1079                slide.part_path.clone()
1080            }
1081        };
1082        let relationships_path = slide_relationships_path(&slide_part_path);
1083        let existing = sink
1084            .current(&relationships_path)
1085            .map(|bytes| {
1086                crate::relationships::parse_relationships(
1087                    &bytes,
1088                    &relationships_path,
1089                    &slide_part_path,
1090                    budget,
1091                )
1092            })
1093            .transpose()?
1094            .and_then(|relationships| crate::notes::slide_notes_part(&relationships));
1095
1096        if text.is_empty() && existing.is_none() {
1097            continue;
1098        }
1099
1100        let is_existing = existing.is_some();
1101        let part_path = match existing {
1102            Some(part_path) => part_path,
1103            None => mint_notes_part_path(&slide_part_path, &mut taken),
1104        };
1105        removed_paths.remove(&part_path);
1106        let bytes = match sink.current(&part_path) {
1107            Some(bytes) => crate::notes::patch_notes_xml(&bytes, &part_path, text, budget)?,
1108            None => crate::notes::notes_slide_xml(text),
1109        };
1110        sink.store(&part_path, bytes);
1111        if !is_existing {
1112            set_content_type_override(&mut sink, &part_path, crate::notes::CT_NOTES_SLIDE, budget)?;
1113            set_relationship(
1114                &mut sink,
1115                &relationships_path,
1116                NOTES_SLIDE_RELATIONSHIP_TYPE,
1117                &relative_target(&slide_part_path, &part_path),
1118                budget,
1119            )?;
1120            let notes_relationships = slide_relationships_path(&part_path);
1121            set_relationship(
1122                &mut sink,
1123                &notes_relationships,
1124                SLIDE_RELATIONSHIP_TYPE,
1125                &relative_target(&part_path, &slide_part_path),
1126                budget,
1127            )?;
1128            if let Some(master) = package
1129                .relationships
1130                .get(&package.presentation.part_path)
1131                .into_iter()
1132                .flatten()
1133                .find(|relationship| relationship.has_type("/notesMaster"))
1134                .and_then(|relationship| relationship.resolved_target.as_deref())
1135            {
1136                set_relationship(
1137                    &mut sink,
1138                    &notes_relationships,
1139                    &format!("{OFFICE_RELATIONSHIPS_NS}/notesMaster"),
1140                    &relative_target(&part_path, master),
1141                    budget,
1142                )?;
1143            }
1144        }
1145    }
1146    Ok(())
1147}
1148
1149fn existing_notes_part(package: &PptxPackage, slide_part_path: &str) -> Option<String> {
1150    package
1151        .relationships
1152        .get(slide_part_path)?
1153        .iter()
1154        .find(|relationship| relationship.is_type(NOTES_SLIDE_RELATIONSHIP_TYPE))
1155        .and_then(|relationship| relationship.resolved_target.clone())
1156}
1157
1158fn mint_notes_part_path(slide_part_path: &str, taken: &mut HashSet<String>) -> String {
1159    let preferred = slide_number(slide_part_path);
1160    let mut number = preferred;
1161    loop {
1162        let candidate = format!("ppt/notesSlides/notesSlide{number}.xml");
1163        if taken.insert(candidate.clone()) {
1164            return candidate;
1165        }
1166        number += 1;
1167    }
1168}
1169
1170fn mint_comment_part_path(
1171    write: &CommentsWrite,
1172    slide_part_path: &str,
1173    taken: &mut HashSet<String>,
1174) -> String {
1175    let preferred = slide_number(slide_part_path);
1176    let mut number = preferred;
1177    loop {
1178        let candidate = write.comments_part_path(number);
1179        if taken.insert(candidate.clone()) {
1180            return candidate;
1181        }
1182        number += 1;
1183    }
1184}
1185
1186fn drop_other_flavor(
1187    package: &PptxPackage,
1188    write: &CommentsWrite,
1189    sink: &mut PartSink<'_>,
1190    removed_paths: &mut HashSet<String>,
1191    budget: &mut ParseBudget<'_>,
1192) -> Result<(), PptxError> {
1193    let other = CommentsWrite {
1194        flavor: match write.flavor {
1195            CommentFlavor::Legacy => CommentFlavor::Modern,
1196            CommentFlavor::Modern => CommentFlavor::Legacy,
1197        },
1198        authors: Vec::new(),
1199        per_slide: Vec::new(),
1200    };
1201    for reference in &package.presentation.slides {
1202        let Some(part_path) = existing_comment_part(package, &reference.part_path, &other) else {
1203            continue;
1204        };
1205        removed_paths.insert(part_path.clone());
1206        sink.forget(&part_path);
1207        remove_content_type_override(sink, &part_path, budget)?;
1208        remove_relationship(
1209            sink,
1210            &slide_relationships_path(&reference.part_path),
1211            other.comments_relationship_type(),
1212            budget,
1213        )?;
1214    }
1215    let authors_path = existing_authors_part(package, &other)
1216        .unwrap_or_else(|| other.authors_part_path().to_owned());
1217    if package.part_bytes(&authors_path).is_some() {
1218        removed_paths.insert(authors_path.clone());
1219        sink.forget(&authors_path);
1220        remove_content_type_override(sink, &authors_path, budget)?;
1221        remove_relationship(
1222            sink,
1223            &slide_relationships_path(&package.presentation.part_path),
1224            other.authors_relationship_type(),
1225            budget,
1226        )?;
1227    }
1228    Ok(())
1229}
1230
1231fn existing_authors_part(package: &PptxPackage, write: &CommentsWrite) -> Option<String> {
1232    package
1233        .relationships
1234        .get(&package.presentation.part_path)?
1235        .iter()
1236        .find(|relationship| relationship.is_type(write.authors_relationship_type()))
1237        .and_then(|relationship| relationship.resolved_target.clone())
1238}
1239
1240fn existing_comment_part(
1241    package: &PptxPackage,
1242    slide_part_path: &str,
1243    write: &CommentsWrite,
1244) -> Option<String> {
1245    package
1246        .relationships
1247        .get(slide_part_path)?
1248        .iter()
1249        .find(|relationship| relationship.is_type(write.comments_relationship_type()))
1250        .and_then(|relationship| relationship.resolved_target.clone())
1251}
1252
1253fn slide_number(slide_part_path: &str) -> usize {
1254    slide_part_path
1255        .rsplit_once("/slide")
1256        .and_then(|(_, tail)| tail.strip_suffix(".xml"))
1257        .and_then(|digits| digits.parse().ok())
1258        .unwrap_or(1)
1259}
1260
1261struct PartSink<'a> {
1262    package: &'a PptxPackage,
1263    replacements: &'a mut HashMap<String, Vec<u8>>,
1264    new_parts: &'a mut Vec<(String, Vec<u8>)>,
1265}
1266
1267impl PartSink<'_> {
1268    fn current(&self, path: &str) -> Option<Vec<u8>> {
1269        if let Some(bytes) = self.replacements.get(path) {
1270            return Some(bytes.clone());
1271        }
1272        if let Some((_, bytes)) = self.new_parts.iter().find(|(name, _)| name == path) {
1273            return Some(bytes.clone());
1274        }
1275        self.package.part_bytes(path).map(<[u8]>::to_vec)
1276    }
1277
1278    fn store(&mut self, path: &str, bytes: Vec<u8>) {
1279        if self.package.part_bytes(path).is_some() {
1280            self.replacements.insert(path.to_owned(), bytes);
1281            return;
1282        }
1283        match self.new_parts.iter_mut().find(|(name, _)| name == path) {
1284            Some((_, existing)) => *existing = bytes,
1285            None => self.new_parts.push((path.to_owned(), bytes)),
1286        }
1287    }
1288
1289    fn forget(&mut self, path: &str) {
1290        self.replacements.remove(path);
1291        self.new_parts.retain(|(name, _)| name != path);
1292    }
1293}
1294
1295fn append_relationships(bytes: Vec<u8>, children: &[XmlNode]) -> Option<Vec<u8>> {
1296    if children.is_empty() {
1297        return Some(bytes);
1298    }
1299    let mut additions = Vec::new();
1300    for child in children {
1301        let XmlNode::Element(child) = child else {
1302            return None;
1303        };
1304        additions.extend(serialize_xml_fragment(child));
1305    }
1306    let mut reader = quick_xml::Reader::from_reader(bytes.as_slice());
1307    let mut depth = 0;
1308    loop {
1309        let start = reader.buffer_position() as usize;
1310        match reader.read_event().ok()? {
1311            quick_xml::events::Event::Start(_) => depth += 1,
1312            quick_xml::events::Event::End(_) => {
1313                depth -= 1;
1314                if depth == 0 {
1315                    let mut output = bytes[..start].to_vec();
1316                    output.extend(additions);
1317                    output.extend_from_slice(&bytes[start..]);
1318                    return Some(output);
1319                }
1320            }
1321            quick_xml::events::Event::Eof => return None,
1322            _ => {}
1323        }
1324    }
1325}
1326
1327fn only_placeholder_edits(shapes: &[ShapeWrite]) -> bool {
1328    shapes.iter().all(|shape| match shape {
1329        ShapeWrite::Keep { .. } => true,
1330        ShapeWrite::Patch { patch, .. } => {
1331            patch.offset.is_none()
1332                && patch.extent.is_none()
1333                && patch.inherited.is_none()
1334                && !patch.materializes
1335                && patch.fill.is_none()
1336                && patch.outline.is_none()
1337                && patch.adjust_values.is_none()
1338                && patch.table_structure.is_none()
1339                && patch.texts.is_empty()
1340                && only_placeholder_edits(&patch.children)
1341        }
1342        ShapeWrite::Add(_) => false,
1343    })
1344}
1345
1346fn has_placeholder_patch(shapes: &[ShapeWrite]) -> bool {
1347    shapes.iter().any(|shape| {
1348        matches!(shape, ShapeWrite::Patch { patch, .. }
1349        if patch.placeholder.is_some() || has_placeholder_patch(&patch.children))
1350    })
1351}
1352
1353fn is_slide_layout_relationship(element: &XmlElement) -> bool {
1354    element.local_name() == "Relationship"
1355        && matches!(
1356            element.attribute("Type"),
1357            Some(SLIDE_LAYOUT_RELATIONSHIP_TYPE | STRICT_SLIDE_LAYOUT_RELATIONSHIP_TYPE)
1358        )
1359}
1360
1361fn patch_layout_relationship(
1362    sink: &mut PartSink<'_>,
1363    slide: &str,
1364    layout: &str,
1365    budget: &mut ParseBudget<'_>,
1366) -> Result<(), PptxError> {
1367    let path = slide_relationships_path(slide);
1368    let bytes = sink
1369        .current(&path)
1370        .ok_or_else(|| PptxError::InvalidSlideLayoutRelationships {
1371            part: slide.to_owned(),
1372            count: 0,
1373        })?;
1374    let mut root = parse_xml(&bytes, &path, budget)?;
1375    let before = root.clone();
1376    let count = root
1377        .child_elements()
1378        .filter(|element| is_slide_layout_relationship(element))
1379        .count();
1380    if count != 1 {
1381        return Err(PptxError::InvalidSlideLayoutRelationships {
1382            part: slide.to_owned(),
1383            count,
1384        });
1385    }
1386    for child in &mut root.children {
1387        if let XmlNode::Element(element) = child
1388            && is_slide_layout_relationship(element)
1389        {
1390            element.set_attribute("Target", relative_target(slide, layout));
1391        }
1392    }
1393    let output = crate::attribute_patch::patch_attributes(&bytes, &before, &root)
1394        .ok_or_else(|| write_error(&path, "could not patch slide layout relationship"))?;
1395    sink.store(&path, output);
1396    Ok(())
1397}
1398
1399fn set_relationship(
1400    sink: &mut PartSink<'_>,
1401    relationships_path: &str,
1402    relationship_type: &str,
1403    target: &str,
1404    budget: &mut ParseBudget<'_>,
1405) -> Result<(), PptxError> {
1406    let mut root = match sink.current(relationships_path) {
1407        Some(bytes) => parse_xml(&bytes, relationships_path, budget)?,
1408        None => XmlElement::new("Relationships").with_attribute("xmlns", PACKAGE_RELATIONSHIPS_NS),
1409    };
1410    let existing = root.children.iter_mut().find_map(|child| match child {
1411        XmlNode::Element(element)
1412            if element.local_name() == "Relationship"
1413                && element.attribute("Type") == Some(relationship_type) =>
1414        {
1415            Some(element)
1416        }
1417        _ => None,
1418    });
1419    match existing {
1420        Some(element) => element.set_attribute("Target", target),
1421        None => {
1422            let id = format!("rId{}", max_relationship_id(&root) + 1);
1423            root.children.push(XmlNode::Element(
1424                XmlElement::new("Relationship")
1425                    .with_attribute("Id", id)
1426                    .with_attribute("Type", relationship_type)
1427                    .with_attribute("Target", target),
1428            ));
1429        }
1430    }
1431    sink.store(relationships_path, serialize_xml(&root));
1432    Ok(())
1433}
1434
1435fn remove_relationship(
1436    sink: &mut PartSink<'_>,
1437    relationships_path: &str,
1438    relationship_type: &str,
1439    budget: &mut ParseBudget<'_>,
1440) -> Result<(), PptxError> {
1441    let Some(bytes) = sink.current(relationships_path) else {
1442        return Ok(());
1443    };
1444    let mut root = parse_xml(&bytes, relationships_path, budget)?;
1445    root.children.retain(|child| match child {
1446        XmlNode::Element(element) if element.local_name() == "Relationship" => {
1447            element.attribute("Type") != Some(relationship_type)
1448        }
1449        _ => true,
1450    });
1451    sink.store(relationships_path, serialize_xml(&root));
1452    Ok(())
1453}
1454
1455fn max_relationship_id(root: &XmlElement) -> usize {
1456    root.children_named("Relationship")
1457        .filter_map(|element| element.attribute("Id"))
1458        .filter_map(|id| id.strip_prefix("rId"))
1459        .filter_map(|number| number.parse::<usize>().ok())
1460        .max()
1461        .unwrap_or(0)
1462}
1463
1464fn set_content_type_override(
1465    sink: &mut PartSink<'_>,
1466    part_path: &str,
1467    content_type: &str,
1468    budget: &mut ParseBudget<'_>,
1469) -> Result<(), PptxError> {
1470    let path = "[Content_Types].xml";
1471    let bytes = sink
1472        .current(path)
1473        .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
1474    let mut root = parse_xml(&bytes, path, budget)?;
1475    let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
1476    let override_name = qualified(prefix, "Override");
1477    let name = format!("/{part_path}");
1478    let existing = root.children.iter_mut().find_map(|child| match child {
1479        XmlNode::Element(element)
1480            if element.local_name() == "Override"
1481                && element.attribute("PartName") == Some(name.as_str()) =>
1482        {
1483            Some(element)
1484        }
1485        _ => None,
1486    });
1487    match existing {
1488        Some(element) => element.set_attribute("ContentType", content_type),
1489        None => root.children.push(XmlNode::Element(
1490            XmlElement::new(override_name)
1491                .with_attribute("PartName", name)
1492                .with_attribute("ContentType", content_type),
1493        )),
1494    }
1495    sink.store(path, serialize_xml(&root));
1496    Ok(())
1497}
1498
1499fn remove_content_type_override(
1500    sink: &mut PartSink<'_>,
1501    part_path: &str,
1502    budget: &mut ParseBudget<'_>,
1503) -> Result<(), PptxError> {
1504    let path = "[Content_Types].xml";
1505    let Some(bytes) = sink.current(path) else {
1506        return Ok(());
1507    };
1508    let mut root = parse_xml(&bytes, path, budget)?;
1509    let name = format!("/{part_path}");
1510    root.children.retain(|child| match child {
1511        XmlNode::Element(element) if element.local_name() == "Override" => {
1512            element.attribute("PartName") != Some(name.as_str())
1513        }
1514        _ => true,
1515    });
1516    sink.store(path, serialize_xml(&root));
1517    Ok(())
1518}
1519
1520/// Drops references to removed slides from custom shows (`r:id`) and
1521/// section lists (`p14:sldId` inside `extLst`).
1522fn prune_slide_references(root: &mut XmlElement, removed: &[&SlideReference]) {
1523    if removed.is_empty() {
1524        return;
1525    }
1526    let removed_relationship_ids: HashSet<&str> = removed
1527        .iter()
1528        .map(|reference| reference.relationship_id.as_str())
1529        .collect();
1530    let removed_slide_ids: HashSet<String> = removed
1531        .iter()
1532        .map(|reference| reference.id.to_string())
1533        .collect();
1534    for child in root.children.iter_mut() {
1535        let XmlNode::Element(element) = child else {
1536            continue;
1537        };
1538        match element.local_name() {
1539            "custShowLst" => remove_matching_descendants(element, &|candidate| {
1540                candidate.local_name() == "sld"
1541                    && candidate.attributes.iter().any(|(key, value)| {
1542                        key.ends_with(":id") && removed_relationship_ids.contains(value.as_str())
1543                    })
1544            }),
1545            "extLst" => remove_matching_descendants(element, &|candidate| {
1546                candidate.local_name() == "sldId"
1547                    && candidate
1548                        .attribute("id")
1549                        .is_some_and(|id| removed_slide_ids.contains(id))
1550            }),
1551            _ => {}
1552        }
1553    }
1554}
1555
1556fn remove_matching_descendants(element: &mut XmlElement, matches: &dyn Fn(&XmlElement) -> bool) {
1557    element.children.retain(|child| match child {
1558        XmlNode::Element(candidate) => !matches(candidate),
1559        _ => true,
1560    });
1561    for child in element.children.iter_mut() {
1562        if let XmlNode::Element(child) = child {
1563            remove_matching_descendants(child, matches);
1564        }
1565    }
1566}
1567
1568fn patch_slide_id_list(
1569    root: &mut XmlElement,
1570    final_order: &[FinalSlide<'_>],
1571    minted: &[MintedSlide],
1572    part: &str,
1573) -> Result<(), PptxError> {
1574    let presentation_prefix = resolve_prefix(root, PRESENTATIONML_NS, "p");
1575    let relationship_prefix = resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r");
1576    let list_name = qualified(&presentation_prefix, "sldIdLst");
1577    if root.child_mut("sldIdLst").is_none() {
1578        let position = root
1579            .children
1580            .iter()
1581            .position(|child| {
1582                matches!(child, XmlNode::Element(element) if element.local_name() == "sldMasterIdLst")
1583            })
1584            .map(|index| index + 1)
1585            .unwrap_or(0);
1586        root.children.insert(
1587            position,
1588            XmlNode::Element(XmlElement::new(list_name.clone())),
1589        );
1590    }
1591    let relationship_attribute = qualified(&relationship_prefix, "id");
1592    let list = root
1593        .child_mut("sldIdLst")
1594        .ok_or_else(|| write_error(part, "missing slide id list"))?;
1595    let mut existing: HashMap<String, XmlElement> = HashMap::new();
1596    for child in std::mem::take(&mut list.children) {
1597        if let XmlNode::Element(element) = child
1598            && element.local_name() == "sldId"
1599        {
1600            let relationship_id = element.attribute(&relationship_attribute).or_else(|| {
1601                element
1602                    .attributes
1603                    .iter()
1604                    .find(|(key, _)| key.ends_with(":id"))
1605                    .map(|(_, value)| value.as_str())
1606            });
1607            if let Some(id) = relationship_id {
1608                existing.insert(id.to_owned(), element);
1609            }
1610        }
1611    }
1612    let entry_name = qualified(&presentation_prefix, "sldId");
1613    for slide in final_order {
1614        let element = match slide {
1615            FinalSlide::Existing(reference) => existing
1616                .remove(&reference.relationship_id)
1617                .unwrap_or_else(|| {
1618                    XmlElement::new(entry_name.clone())
1619                        .with_attribute("id", reference.id.to_string())
1620                        .with_attribute(
1621                            relationship_attribute.clone(),
1622                            reference.relationship_id.clone(),
1623                        )
1624                }),
1625            FinalSlide::Added(index) => {
1626                let slide = &minted[*index];
1627                XmlElement::new(entry_name.clone())
1628                    .with_attribute("id", slide.slide_id.to_string())
1629                    .with_attribute(
1630                        relationship_attribute.clone(),
1631                        slide.relationship_id.clone(),
1632                    )
1633            }
1634        };
1635        list.children.push(XmlNode::Element(element));
1636    }
1637    Ok(())
1638}
1639
1640// --- namespace prefixes -----------------------------------------------------
1641
1642fn find_prefix(root: &XmlElement, namespace: &str) -> Option<String> {
1643    root.attributes.iter().find_map(|(key, value)| {
1644        if value != namespace {
1645            return None;
1646        }
1647        if key == "xmlns" {
1648            Some(String::new())
1649        } else {
1650            key.strip_prefix("xmlns:").map(str::to_owned)
1651        }
1652    })
1653}
1654
1655fn resolve_prefix(root: &mut XmlElement, namespace: &str, fallback: &str) -> String {
1656    if let Some(prefix) = find_prefix(root, namespace) {
1657        return prefix;
1658    }
1659    root.set_attribute(format!("xmlns:{fallback}"), namespace);
1660    fallback.to_owned()
1661}
1662
1663fn qualified(prefix: &str, local: &str) -> String {
1664    if prefix.is_empty() {
1665        local.to_owned()
1666    } else {
1667        format!("{prefix}:{local}")
1668    }
1669}
1670
1671struct Prefixes {
1672    drawing: String,
1673    presentation: String,
1674    relationship: String,
1675}
1676
1677impl Prefixes {
1678    fn from_root(root: &mut XmlElement) -> Self {
1679        Self {
1680            drawing: resolve_prefix(root, DRAWINGML_NS, "a"),
1681            presentation: resolve_prefix(root, PRESENTATIONML_NS, "p"),
1682            relationship: resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r"),
1683        }
1684    }
1685
1686    fn drawing(&self, local: &str) -> String {
1687        qualified(&self.drawing, local)
1688    }
1689
1690    fn presentation(&self, local: &str) -> String {
1691        qualified(&self.presentation, local)
1692    }
1693
1694    fn relationship(&self, local: &str) -> String {
1695        qualified(&self.relationship, local)
1696    }
1697}
1698
1699// --- slide patching ---------------------------------------------------------
1700
1701/// Resolves source ordinals with the original parser filter.
1702#[allow(clippy::too_many_arguments)]
1703fn patch_slide(
1704    root: &mut XmlElement,
1705    shapes: &[ShapeWrite],
1706    background: Option<&BackgroundWrite>,
1707    theme: Option<&Theme>,
1708    part: &str,
1709    elements: ShapeElements,
1710    sink: &mut PartSink<'_>,
1711    budget: &mut ParseBudget<'_>,
1712) -> Result<(), PptxError> {
1713    let prefixes = Prefixes::from_root(root);
1714    if let Some(background) = background {
1715        let common = root
1716            .child_mut("cSld")
1717            .ok_or_else(|| write_error(part, "slide has no common slide data"))?;
1718        patch_background(common, background, &prefixes, part)?;
1719    }
1720    let mut next_shape_id = max_shape_id(root).checked_add(1);
1721    let tree = root
1722        .child_mut("cSld")
1723        .and_then(|common| common.child_mut("spTree"))
1724        .ok_or_else(|| write_error(part, "slide has no shape tree"))?;
1725    patch_shape_children(
1726        tree,
1727        shapes,
1728        &mut next_shape_id,
1729        theme,
1730        &prefixes,
1731        part,
1732        elements,
1733        sink,
1734        budget,
1735    )
1736}
1737
1738/// Inserts, replaces or removes the slide's `p:bg` declaration.
1739fn patch_background(
1740    common: &mut XmlElement,
1741    background: &BackgroundWrite,
1742    prefixes: &Prefixes,
1743    part: &str,
1744) -> Result<(), PptxError> {
1745    common.children.retain(|child| match child {
1746        XmlNode::Element(element) => element.local_name() != "bg",
1747        _ => true,
1748    });
1749    if let BackgroundWrite::Set(fill) = background {
1750        common.children.insert(
1751            0,
1752            XmlNode::Element(background_element(fill, prefixes, part)?),
1753        );
1754    }
1755    Ok(())
1756}
1757
1758/// `<p:bg><p:bgPr>…fill…<a:effectLst/></p:bgPr></p:bg>`, the first `cSld` child.
1759fn background_element(
1760    fill: &ShapeFill,
1761    prefixes: &Prefixes,
1762    part: &str,
1763) -> Result<XmlElement, PptxError> {
1764    Ok(XmlElement::new(prefixes.presentation("bg")).with_child(
1765        XmlElement::new(prefixes.presentation("bgPr"))
1766            .with_child(fill_element(fill, prefixes, part)?)
1767            .with_child(XmlElement::new(prefixes.drawing("effectLst"))),
1768    ))
1769}
1770
1771fn max_shape_id(root: &XmlElement) -> u32 {
1772    root.descendants_named("cNvPr")
1773        .iter()
1774        .filter_map(|element| element.attribute("id")?.parse::<u32>().ok())
1775        .max()
1776        .unwrap_or(1)
1777}
1778
1779fn alloc_shape_id(next_shape_id: &mut Option<u32>, part: &str) -> Result<u32, PptxError> {
1780    let shape_id =
1781        next_shape_id.ok_or_else(|| write_error(part, "the shape id space is exhausted"))?;
1782    if shape_id == 0 {
1783        return Err(write_error(part, "the shape id space is exhausted"));
1784    }
1785    *next_shape_id = shape_id.checked_add(1);
1786    Ok(shape_id)
1787}
1788
1789/// A parsed shape's source element: a direct child of the tree, or one the
1790/// `mc:AlternateContent` at `position` contributes through `path`.
1791struct ShapeSlot {
1792    position: usize,
1793    path: Vec<usize>,
1794}
1795
1796/// Rebuilds the shape run in place: non-shape siblings keep their slots
1797/// relative to the shape that follows them, and elements trailing the last
1798/// shape (`p:extLst` in particular) stay last.
1799#[allow(clippy::too_many_arguments)]
1800fn patch_shape_children(
1801    parent: &mut XmlElement,
1802    writes: &[ShapeWrite],
1803    next_shape_id: &mut Option<u32>,
1804    theme: Option<&Theme>,
1805    prefixes: &Prefixes,
1806    part: &str,
1807    elements: ShapeElements,
1808    sink: &mut PartSink<'_>,
1809    budget: &mut ParseBudget<'_>,
1810) -> Result<(), PptxError> {
1811    let mut slots: Vec<Option<XmlNode>> = std::mem::take(&mut parent.children)
1812        .into_iter()
1813        .map(Some)
1814        .collect();
1815    let mut shape_slots = shape_slots(&slots, elements);
1816    let mut kept = vec![false; shape_slots.len()];
1817    for write in writes {
1818        if let ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } = write
1819            && let Some(flag) = kept.get_mut(*source_index)
1820        {
1821            *flag = true;
1822        }
1823    }
1824    // A wrapper is not a shape element, so the sibling flush carries it through whatever became of
1825    // the shapes inside it: a deleted one leaves the branch, an emptied wrapper leaves the file.
1826    let mut nested: BTreeMap<usize, (usize, Vec<Vec<usize>>)> = BTreeMap::new();
1827    for (slot, kept) in shape_slots.iter().zip(&kept) {
1828        if slot.path.is_empty() {
1829            continue;
1830        }
1831        let entry = nested.entry(slot.position).or_default();
1832        entry.0 += 1;
1833        if !kept {
1834            entry.1.push(slot.path.clone());
1835        }
1836    }
1837    for (position, (total, mut paths)) in nested {
1838        if paths.len() == total && branch_holds_only(&slots[position], &paths) {
1839            slots[position] = None;
1840            continue;
1841        }
1842        paths.sort();
1843        for path in paths.iter().rev() {
1844            let Some(node) = slots[position].as_mut() else {
1845                continue;
1846            };
1847            remove_at_path(node, path);
1848            for slot in shape_slots
1849                .iter_mut()
1850                .filter(|slot| slot.position == position)
1851            {
1852                shift_path(&mut slot.path, path);
1853            }
1854        }
1855    }
1856    let mut children = Vec::with_capacity(slots.len());
1857    let mut hosts: BTreeMap<usize, usize> = BTreeMap::new();
1858    let first_ext_list = |slots: &[Option<XmlNode>]| {
1859        slots
1860            .iter()
1861            .position(|slot| {
1862                matches!(slot, Some(XmlNode::Element(element)) if element.local_name() == "extLst")
1863            })
1864            .unwrap_or(slots.len())
1865    };
1866    let prologue_end = shape_slots
1867        .first()
1868        .map(|slot| slot.position)
1869        .unwrap_or_else(|| first_ext_list(&slots));
1870    emit_sibling_slots(&mut slots, &mut children, prologue_end, elements);
1871    let last_kept = writes
1872        .iter()
1873        .rposition(|write| !matches!(write, ShapeWrite::Add(_)));
1874    for (index, write) in writes.iter().enumerate() {
1875        match write {
1876            ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } => {
1877                let slot = shape_slots.get(*source_index).ok_or_else(|| {
1878                    write_error(part, format!("shape index {source_index} is not available"))
1879                })?;
1880                emit_sibling_slots(&mut slots, &mut children, slot.position, elements);
1881                let target = emit_source_element(&mut slots, &mut children, &mut hosts, slot)
1882                    .ok_or_else(|| {
1883                        write_error(
1884                            part,
1885                            format!("shape index {source_index} was already written"),
1886                        )
1887                    })?;
1888                if let ShapeWrite::Patch { patch, .. } = write {
1889                    let element =
1890                        element_at_path(&mut children[target], &slot.path).ok_or_else(|| {
1891                            write_error(
1892                                part,
1893                                format!("shape index {source_index} is not available"),
1894                            )
1895                        })?;
1896                    patch_shape(
1897                        element,
1898                        patch,
1899                        next_shape_id,
1900                        theme,
1901                        prefixes,
1902                        part,
1903                        elements,
1904                        sink,
1905                        budget,
1906                    )?;
1907                }
1908            }
1909            ShapeWrite::Add(add) => {
1910                // A trailing add goes on top: after every remaining sibling
1911                // except a final extLst.
1912                if last_kept.is_none_or(|kept| index > kept) {
1913                    let flush_end = first_ext_list(&slots);
1914                    emit_sibling_slots(&mut slots, &mut children, flush_end, elements);
1915                }
1916                children.push(XmlNode::Element(add_shape_element(
1917                    add,
1918                    next_shape_id,
1919                    prefixes,
1920                    part,
1921                    sink,
1922                    budget,
1923                )?));
1924            }
1925        }
1926    }
1927    for slot in slots.into_iter().flatten() {
1928        if !matches!(&slot, XmlNode::Element(element) if elements.contains(element.local_name())) {
1929            children.push(slot);
1930        }
1931    }
1932    parent.children = children;
1933    Ok(())
1934}
1935
1936/// Source ordinals in the order [`common_slide_data`] parses them.
1937fn shape_slots(slots: &[Option<XmlNode>], elements: ShapeElements) -> Vec<ShapeSlot> {
1938    let mut found = Vec::new();
1939    for (position, slot) in slots.iter().enumerate() {
1940        let Some(XmlNode::Element(element)) = slot else {
1941            continue;
1942        };
1943        if elements.contains(element.local_name()) {
1944            found.push(ShapeSlot {
1945                position,
1946                path: Vec::new(),
1947            });
1948        } else if element.local_name() == "AlternateContent" {
1949            collect_branch_slots(element, position, &mut Vec::new(), elements, &mut found);
1950        }
1951    }
1952    found
1953}
1954
1955fn collect_branch_slots(
1956    alternate: &XmlElement,
1957    position: usize,
1958    path: &mut Vec<usize>,
1959    elements: ShapeElements,
1960    found: &mut Vec<ShapeSlot>,
1961) {
1962    let Some(index) = alternate_content_branch_index(alternate) else {
1963        return;
1964    };
1965    let XmlNode::Element(branch) = &alternate.children[index] else {
1966        return;
1967    };
1968    path.push(index);
1969    for (index, child) in branch.children.iter().enumerate() {
1970        let XmlNode::Element(child) = child else {
1971            continue;
1972        };
1973        path.push(index);
1974        if elements.contains(child.local_name()) {
1975            found.push(ShapeSlot {
1976                position,
1977                path: path.clone(),
1978            });
1979        } else if child.local_name() == "AlternateContent" {
1980            collect_branch_slots(child, position, path, elements, found);
1981        }
1982        path.pop();
1983    }
1984    path.pop();
1985}
1986
1987/// Moves a shape's source element into `children`, or reuses the
1988/// `mc:AlternateContent` already moved there for an earlier nested shape.
1989fn emit_source_element(
1990    slots: &mut [Option<XmlNode>],
1991    children: &mut Vec<XmlNode>,
1992    hosts: &mut BTreeMap<usize, usize>,
1993    slot: &ShapeSlot,
1994) -> Option<usize> {
1995    if let Some(host) = hosts.get(&slot.position) {
1996        return Some(*host);
1997    }
1998    let XmlNode::Element(element) = slots[slot.position].take()? else {
1999        return None;
2000    };
2001    children.push(XmlNode::Element(element));
2002    if !slot.path.is_empty() {
2003        hosts.insert(slot.position, children.len() - 1);
2004    }
2005    Some(children.len() - 1)
2006}
2007
2008/// Whether the branch `slot` reads holds nothing but the elements at `paths`.
2009fn branch_holds_only(slot: &Option<XmlNode>, paths: &[Vec<usize>]) -> bool {
2010    let Some(XmlNode::Element(alternate)) = slot else {
2011        return true;
2012    };
2013    let Some(index) = alternate_content_branch_index(alternate) else {
2014        return true;
2015    };
2016    let XmlNode::Element(branch) = &alternate.children[index] else {
2017        return true;
2018    };
2019    branch.children.iter().enumerate().all(|(child, node)| {
2020        !matches!(node, XmlNode::Element(_)) || paths.iter().any(|path| path == &[index, child])
2021    })
2022}
2023
2024fn remove_at_path(node: &mut XmlNode, path: &[usize]) {
2025    let Some((last, parents)) = path.split_last() else {
2026        return;
2027    };
2028    if let Some(parent) = element_at_path(node, parents)
2029        && *last < parent.children.len()
2030    {
2031        parent.children.remove(*last);
2032    }
2033}
2034
2035/// Closes the gap [`remove_at_path`] leaves in the paths of its later siblings.
2036fn shift_path(path: &mut [usize], removed: &[usize]) {
2037    let Some((last, parents)) = removed.split_last() else {
2038        return;
2039    };
2040    if path.len() > parents.len() && path.starts_with(parents) && path[parents.len()] > *last {
2041        path[parents.len()] -= 1;
2042    }
2043}
2044
2045fn element_at_path<'a>(node: &'a mut XmlNode, path: &[usize]) -> Option<&'a mut XmlElement> {
2046    let XmlNode::Element(element) = node else {
2047        return None;
2048    };
2049    let mut current = element;
2050    for index in path {
2051        match current.children.get_mut(*index)? {
2052            XmlNode::Element(child) => current = child,
2053            XmlNode::Text(_) => return None,
2054        }
2055    }
2056    Some(current)
2057}
2058
2059/// Emits the not-yet-written non-shape nodes that precede `position`.
2060fn emit_sibling_slots(
2061    slots: &mut [Option<XmlNode>],
2062    children: &mut Vec<XmlNode>,
2063    position: usize,
2064    elements: ShapeElements,
2065) {
2066    for slot in slots.iter_mut().take(position) {
2067        let is_other = !matches!(
2068            slot,
2069            Some(XmlNode::Element(element)) if elements.contains(element.local_name())
2070        );
2071        if is_other && let Some(node) = slot.take() {
2072            children.push(node);
2073        }
2074    }
2075}
2076
2077#[allow(clippy::too_many_arguments)]
2078fn patch_shape(
2079    element: &mut XmlElement,
2080    patch: &ShapePatch,
2081    next_shape_id: &mut Option<u32>,
2082    theme: Option<&Theme>,
2083    prefixes: &Prefixes,
2084    part: &str,
2085    elements: ShapeElements,
2086    sink: &mut PartSink<'_>,
2087    budget: &mut ParseBudget<'_>,
2088) -> Result<(), PptxError> {
2089    if let Some(placeholder) = &patch.placeholder {
2090        let non_visual = element
2091            .children
2092            .iter_mut()
2093            .find_map(|child| match child {
2094                XmlNode::Element(child)
2095                    if matches!(
2096                        child.local_name(),
2097                        "nvSpPr" | "nvPicPr" | "nvGraphicFramePr" | "nvGrpSpPr" | "nvCxnSpPr"
2098                    ) =>
2099                {
2100                    Some(child)
2101                }
2102                _ => None,
2103            })
2104            .and_then(|child| child.child_mut("nvPr"))
2105            .and_then(|child| child.child_mut("ph"))
2106            .ok_or_else(|| write_error(part, "mapped shape has no placeholder element"))?;
2107        non_visual
2108            .attributes
2109            .retain(|name, _| name == "xmlns" || name.starts_with("xmlns:"));
2110        for (name, value) in [
2111            ("type", placeholder.placeholder_type.clone()),
2112            ("idx", placeholder.index.map(|index| index.to_string())),
2113            ("orient", placeholder.orientation.clone()),
2114            ("sz", placeholder.size.clone()),
2115        ] {
2116            if let Some(value) = value {
2117                non_visual.set_attribute(name, value);
2118            }
2119        }
2120    }
2121    if patch.offset.is_some() || patch.extent.is_some() {
2122        patch_transform(element, patch, prefixes, part)?;
2123    }
2124    if let Some(fill) = &patch.fill {
2125        let properties = shape_properties_mut(element, part)?;
2126        set_fill(properties, fill, prefixes, part)?;
2127    }
2128    if let Some(outline) = &patch.outline {
2129        let properties = shape_properties_mut(element, part)?;
2130        set_outline(properties, outline, prefixes);
2131    }
2132    if let Some(adjust_values) = &patch.adjust_values {
2133        let properties = shape_properties_mut(element, part)?;
2134        set_adjust_values(properties, adjust_values, prefixes, part)?;
2135    }
2136    if let Some(structure) = &patch.table_structure {
2137        patch_table(element, structure, part)?;
2138    }
2139    for text in &patch.texts {
2140        let empty_runs = if patch.table_structure.is_some() {
2141            empty_table_runs(element, text, theme, prefixes)
2142        } else {
2143            Vec::new()
2144        };
2145        patch_text(element, text, theme, prefixes, part)?;
2146        if let TextTarget::TableCell { row, cell } = text.target {
2147            for (paragraph, run) in empty_runs {
2148                let body = table_cell_mut(element, row, cell)
2149                    .and_then(|cell| cell.child_mut("txBody"))
2150                    .ok_or_else(|| write_error(part, "table cell has no text body"))?;
2151                let paragraph = nth_child_mut(body, "p", paragraph)
2152                    .ok_or_else(|| write_error(part, "table cell paragraph is missing"))?;
2153                let at = paragraph.children.iter().position(|node| matches!(node, XmlNode::Element(element) if element.local_name() == "endParaRPr")).unwrap_or(paragraph.children.len());
2154                paragraph.children.insert(at, XmlNode::Element(run));
2155            }
2156        }
2157    }
2158    if !patch.children.is_empty() {
2159        patch_shape_children(
2160            element,
2161            &patch.children,
2162            next_shape_id,
2163            theme,
2164            prefixes,
2165            part,
2166            elements,
2167            sink,
2168            budget,
2169        )?;
2170    }
2171    Ok(())
2172}
2173
2174fn shape_properties_mut<'a>(
2175    element: &'a mut XmlElement,
2176    part: &str,
2177) -> Result<&'a mut XmlElement, PptxError> {
2178    let container = match element.local_name() {
2179        "grpSp" => "grpSpPr",
2180        _ => "spPr",
2181    };
2182    element
2183        .child_mut(container)
2184        .ok_or_else(|| write_error(part, "shape has no properties element"))
2185}
2186
2187/// Writes the edited transform halves. Pieces the source spells out and the
2188/// edit did not change are left untouched; pieces the source lacks come from
2189/// the inherited transform, and so do the rotation and flips when the
2190/// `a:xfrm` is new or the patch materializes it.
2191fn patch_transform(
2192    element: &mut XmlElement,
2193    patch: &ShapePatch,
2194    prefixes: &Prefixes,
2195    part: &str,
2196) -> Result<(), PptxError> {
2197    let offset = patch
2198        .offset
2199        .or(patch.inherited.map(|inherited| (inherited.x, inherited.y)));
2200    let extent = patch.extent.or(patch
2201        .inherited
2202        .map(|inherited| (inherited.width, inherited.height)));
2203    let (container, transform_name) = match element.local_name() {
2204        "graphicFrame" => (None, prefixes.presentation("xfrm")),
2205        "grpSp" => (Some("grpSpPr"), prefixes.drawing("xfrm")),
2206        _ => (Some("spPr"), prefixes.drawing("xfrm")),
2207    };
2208    let parent = match container {
2209        Some(name) => element
2210            .child_mut(name)
2211            .ok_or_else(|| write_error(part, "shape has no properties element"))?,
2212        None => element,
2213    };
2214    let created = parent.child_mut("xfrm").is_none();
2215    if created {
2216        if offset.is_none() || extent.is_none() {
2217            return Err(write_error(
2218                part,
2219                "cannot write half a transform onto a shape without one",
2220            ));
2221        }
2222        let position = parent
2223            .children
2224            .iter()
2225            .position(|child| {
2226                matches!(child, XmlNode::Element(element) if element.local_name() != "nvGraphicFramePr")
2227            })
2228            .unwrap_or(parent.children.len());
2229        parent
2230            .children
2231            .insert(position, XmlNode::Element(XmlElement::new(transform_name)));
2232    }
2233    let transform = parent.child_mut("xfrm").expect("transform ensured above");
2234    if (created || patch.materializes)
2235        && let Some(inherited) = patch.inherited
2236    {
2237        if inherited.rotation_deg != 0.0 {
2238            transform.set_attribute("rot", format_fixed(inherited.rotation_deg * 60_000.0));
2239        }
2240        if inherited.flip_horizontal {
2241            transform.set_attribute("flipH", "1");
2242        }
2243        if inherited.flip_vertical {
2244            transform.set_attribute("flipV", "1");
2245        }
2246    }
2247    if let Some((x, y)) = offset
2248        && (patch.offset.is_some() || transform.child_mut("off").is_none())
2249    {
2250        if transform.child_mut("off").is_none() {
2251            transform.children.insert(
2252                0,
2253                XmlNode::Element(XmlElement::new(prefixes.drawing("off"))),
2254            );
2255        }
2256        let element = transform.child_mut("off").expect("offset ensured above");
2257        element.set_attribute("x", x.to_string());
2258        element.set_attribute("y", y.to_string());
2259    }
2260    if let Some((width, height)) = extent
2261        && (patch.extent.is_some() || transform.child_mut("ext").is_none())
2262    {
2263        if transform.child_mut("ext").is_none() {
2264            let position = transform
2265                .children
2266                .iter()
2267                .position(
2268                    |child| matches!(child, XmlNode::Element(element) if element.local_name() == "off"),
2269                )
2270                .map(|index| index + 1)
2271                .unwrap_or(transform.children.len());
2272            transform.children.insert(
2273                position,
2274                XmlNode::Element(XmlElement::new(prefixes.drawing("ext"))),
2275            );
2276        }
2277        let element = transform.child_mut("ext").expect("extent ensured above");
2278        element.set_attribute("cx", width.to_string());
2279        element.set_attribute("cy", height.to_string());
2280    }
2281    Ok(())
2282}
2283
2284const FILL_ELEMENTS: [&str; 6] = [
2285    "noFill",
2286    "solidFill",
2287    "gradFill",
2288    "blipFill",
2289    "pattFill",
2290    "grpFill",
2291];
2292const POST_FILL_ELEMENTS: [&str; 6] = ["ln", "effectLst", "effectDag", "scene3d", "sp3d", "extLst"];
2293
2294fn set_fill(
2295    properties: &mut XmlElement,
2296    fill: &ShapeFill,
2297    prefixes: &Prefixes,
2298    part: &str,
2299) -> Result<(), PptxError> {
2300    let element = fill_element(fill, prefixes, part)?;
2301    let existing = properties.children.iter().position(|child| {
2302        matches!(child, XmlNode::Element(element) if FILL_ELEMENTS.contains(&element.local_name()))
2303    });
2304    match existing {
2305        Some(index) => properties.children[index] = XmlNode::Element(element),
2306        None => {
2307            let position = properties
2308                .children
2309                .iter()
2310                .position(|child| {
2311                    matches!(
2312                        child,
2313                        XmlNode::Element(element)
2314                            if POST_FILL_ELEMENTS.contains(&element.local_name())
2315                    )
2316                })
2317                .unwrap_or(properties.children.len());
2318            properties
2319                .children
2320                .insert(position, XmlNode::Element(element));
2321        }
2322    }
2323    Ok(())
2324}
2325
2326fn fill_element(
2327    fill: &ShapeFill,
2328    prefixes: &Prefixes,
2329    part: &str,
2330) -> Result<XmlElement, PptxError> {
2331    match fill.fill_type.as_str() {
2332        "none" => Ok(XmlElement::new(prefixes.drawing("noFill"))),
2333        "solid" => {
2334            let color = fill
2335                .color
2336                .as_ref()
2337                .and_then(|color| color_element(color, prefixes))
2338                .ok_or_else(|| write_error(part, "solid fill without a serializable color"))?;
2339            Ok(XmlElement::new(prefixes.drawing("solidFill")).with_child(color))
2340        }
2341        "gradient" => {
2342            let gradient = fill
2343                .gradient
2344                .as_ref()
2345                .ok_or_else(|| write_error(part, "gradient fill without stops"))?;
2346            gradient_element(gradient, prefixes)
2347                .ok_or_else(|| write_error(part, "gradient stop without a color"))
2348        }
2349        other => Err(write_error(
2350            part,
2351            format!("unsupported fill type {other:?}"),
2352        )),
2353    }
2354}
2355
2356fn gradient_element(gradient: &GradientFill, prefixes: &Prefixes) -> Option<XmlElement> {
2357    let mut stops = XmlElement::new(prefixes.drawing("gsLst"));
2358    for stop in &gradient.stops {
2359        stops = stops.with_child(
2360            XmlElement::new(prefixes.drawing("gs"))
2361                .with_attribute("pos", format_fixed(stop.position))
2362                .with_child(color_element(&stop.color, prefixes)?),
2363        );
2364    }
2365    let mut element = XmlElement::new(prefixes.drawing("gradFill")).with_child(stops);
2366    match gradient.gradient_type.as_str() {
2367        "linear" => {
2368            element = element.with_child(
2369                XmlElement::new(prefixes.drawing("lin"))
2370                    .with_attribute(
2371                        "ang",
2372                        format_fixed(gradient.angle.unwrap_or_default() * 60_000.0),
2373                    )
2374                    .with_attribute("scaled", "1"),
2375            );
2376        }
2377        kind => {
2378            let path = match kind {
2379                "radial" => "circle",
2380                "rectangular" => "rect",
2381                _ => "shape",
2382            };
2383            element = element
2384                .with_child(XmlElement::new(prefixes.drawing("path")).with_attribute("path", path));
2385        }
2386    }
2387    Some(element)
2388}
2389
2390fn color_element(color: &ColorValue, prefixes: &Prefixes) -> Option<XmlElement> {
2391    let mut element = if let Some(rgb) = &color.rgb {
2392        XmlElement::new(prefixes.drawing("srgbClr")).with_attribute("val", rgb.clone())
2393    } else if let Some(theme) = &color.theme_color {
2394        XmlElement::new(prefixes.drawing("schemeClr"))
2395            .with_attribute("val", denormalize_scheme_color(theme))
2396    } else {
2397        return None;
2398    };
2399    if !color.transforms.is_empty() {
2400        for transform in color.drawingml_transforms() {
2401            let mut child = XmlElement::new(prefixes.drawing(transform.xml_name()));
2402            if let Some(value) = transform.xml_value() {
2403                child = child.with_attribute("val", value);
2404            }
2405            element = element.with_child(child);
2406        }
2407        return Some(element);
2408    }
2409    let fractions = [
2410        ("alpha", color.alpha),
2411        ("lumMod", color.luminance_modulation),
2412        ("lumOff", color.luminance_offset),
2413        ("satMod", color.saturation_modulation),
2414    ];
2415    for (name, value) in fractions {
2416        if let Some(value) = value {
2417            element = element.with_child(
2418                XmlElement::new(prefixes.drawing(name))
2419                    .with_attribute("val", format_fixed(value * 100_000.0)),
2420            );
2421        }
2422    }
2423    let modifiers = [("tint", &color.theme_tint), ("shade", &color.theme_shade)];
2424    for (name, value) in modifiers {
2425        if let Some(byte) = value
2426            .as_deref()
2427            .and_then(|value| u8::from_str_radix(value, 16).ok())
2428        {
2429            element = element.with_child(
2430                XmlElement::new(prefixes.drawing(name))
2431                    .with_attribute("val", format_fixed(f64::from(byte) / 255.0 * 100_000.0)),
2432            );
2433        }
2434    }
2435    Some(element)
2436}
2437
2438fn denormalize_scheme_color(value: &str) -> String {
2439    match value {
2440        "text1" => "tx1",
2441        "text2" => "tx2",
2442        "background1" => "bg1",
2443        "background2" => "bg2",
2444        value => value,
2445    }
2446    .to_owned()
2447}
2448
2449fn format_fixed(value: f64) -> String {
2450    (value.round() as i64).to_string()
2451}
2452
2453fn set_outline(properties: &mut XmlElement, outline: &ShapeOutline, prefixes: &Prefixes) {
2454    if let Some(line) = properties.child_mut("ln")
2455        && let Some(mut original) = crate::drawing::parse_outline_element(line)
2456    {
2457        original.width = outline.width;
2458        if original == *outline {
2459            if let Some(width) = outline.width {
2460                line.set_attribute("w", format_fixed(width));
2461            } else {
2462                line.attributes.remove("w");
2463            }
2464            return;
2465        }
2466    }
2467    let element = outline_element(outline, prefixes);
2468    let existing = properties.children.iter().position(
2469        |child| matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
2470    );
2471    match existing {
2472        Some(index) => properties.children[index] = XmlNode::Element(element),
2473        None => {
2474            let position = properties
2475                .children
2476                .iter()
2477                .position(|child| {
2478                    matches!(
2479                        child,
2480                        XmlNode::Element(element)
2481                            if POST_FILL_ELEMENTS[1..].contains(&element.local_name())
2482                    )
2483                })
2484                .unwrap_or(properties.children.len());
2485            properties
2486                .children
2487                .insert(position, XmlNode::Element(element));
2488        }
2489    }
2490}
2491
2492fn outline_element(outline: &ShapeOutline, prefixes: &Prefixes) -> XmlElement {
2493    let mut element = XmlElement::new(prefixes.drawing("ln"));
2494    if *outline == ShapeOutline::default() {
2495        return element.with_child(XmlElement::new(prefixes.drawing("noFill")));
2496    }
2497    if let Some(width) = outline.width {
2498        element.set_attribute("w", format_fixed(width));
2499    }
2500    if let Some(cap) = &outline.cap {
2501        element.set_attribute("cap", cap.clone());
2502    }
2503    if let Some(color) = outline
2504        .color
2505        .as_ref()
2506        .and_then(|color| color_element(color, prefixes))
2507    {
2508        element =
2509            element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
2510    } else if let Some(gradient) = outline
2511        .gradient
2512        .as_ref()
2513        .and_then(|gradient| gradient_element(gradient, prefixes))
2514    {
2515        element = element.with_child(gradient);
2516    }
2517    if let Some(style) = &outline.style {
2518        element = element.with_child(
2519            XmlElement::new(prefixes.drawing("prstDash")).with_attribute("val", style.clone()),
2520        );
2521    }
2522    if let Some(join) = &outline.join {
2523        element = element.with_child(XmlElement::new(prefixes.drawing(join)));
2524    }
2525    let ends = [
2526        ("headEnd", &outline.head_end),
2527        ("tailEnd", &outline.tail_end),
2528    ];
2529    for (name, end) in ends {
2530        if let Some(end) = end {
2531            let mut end_element = XmlElement::new(prefixes.drawing(name))
2532                .with_attribute("type", end.end_type.clone());
2533            if let Some(width) = &end.width {
2534                end_element.set_attribute("w", width.clone());
2535            }
2536            if let Some(length) = &end.length {
2537                end_element.set_attribute("len", length.clone());
2538            }
2539            element = element.with_child(end_element);
2540        }
2541    }
2542    element
2543}
2544
2545fn set_adjust_values(
2546    properties: &mut XmlElement,
2547    adjust_values: &BTreeMap<String, f64>,
2548    prefixes: &Prefixes,
2549    part: &str,
2550) -> Result<(), PptxError> {
2551    let Some(geometry) = properties.child_mut("prstGeom") else {
2552        return Err(write_error(
2553            part,
2554            "cannot write adjustments onto a shape without preset geometry",
2555        ));
2556    };
2557    let list_name = prefixes.drawing("avLst");
2558    if geometry.child_mut("avLst").is_none() {
2559        geometry
2560            .children
2561            .insert(0, XmlNode::Element(XmlElement::new(list_name)));
2562    }
2563    let list = geometry.child_mut("avLst").expect("list ensured above");
2564    // Guides absent from the model (unevaluated formulas) are kept as-is.
2565    for (name, value) in adjust_values {
2566        let formula = format!("val {}", format_fixed(value * 100_000.0));
2567        let existing = list.children.iter_mut().find_map(|child| match child {
2568            XmlNode::Element(element)
2569                if element.local_name() == "gd" && element.attribute("name") == Some(name) =>
2570            {
2571                Some(element)
2572            }
2573            _ => None,
2574        });
2575        match existing {
2576            Some(guide) => guide.set_attribute("fmla", formula),
2577            None => list.children.push(XmlNode::Element(
2578                XmlElement::new(prefixes.drawing("gd"))
2579                    .with_attribute("name", name.clone())
2580                    .with_attribute("fmla", formula),
2581            )),
2582        }
2583    }
2584    Ok(())
2585}
2586
2587// --- text -------------------------------------------------------------------
2588
2589/// Copies paragraph markup for a source text body.
2590pub fn text_paragraph_xml(
2591    package: &PptxPackage,
2592    part: &str,
2593    shape_path: &[usize],
2594    target: &TextTarget,
2595) -> Result<Vec<String>, PptxError> {
2596    let Some(bytes) = package.part_bytes(part) else {
2597        return Ok(Vec::new());
2598    };
2599    let limits = ParseLimits::default();
2600    let mut budget = ParseBudget::new(&limits);
2601    let mut root = parse_xml(bytes, part, &mut budget)?;
2602    let mut element = root
2603        .child_mut("cSld")
2604        .and_then(|data| data.child_mut("spTree"))
2605        .ok_or_else(|| write_error(part, "missing shape tree"))?
2606        .clone();
2607    for &index in shape_path {
2608        let mut slots: Vec<_> = element.children.into_iter().map(Some).collect();
2609        let paths = shape_slots(&slots, package.shape_elements);
2610        let slot = paths
2611            .get(index)
2612            .ok_or_else(|| write_error(part, "missing source shape"))?;
2613        element = element_at_path(slots[slot.position].as_mut().unwrap(), &slot.path)
2614            .ok_or_else(|| write_error(part, "missing source shape"))?
2615            .clone();
2616    }
2617    let body = match target {
2618        TextTarget::Body => element.child_mut("txBody"),
2619        TextTarget::TableCell { row, cell } => element
2620            .child_mut("graphic")
2621            .and_then(|graphic| graphic.child_mut("graphicData"))
2622            .and_then(|data| data.child_mut("tbl"))
2623            .and_then(|table| nth_child_mut(table, "tr", *row))
2624            .and_then(|row| nth_child_mut(row, "tc", *cell))
2625            .and_then(|cell| cell.child_mut("txBody")),
2626    };
2627    Ok(body
2628        .into_iter()
2629        .flat_map(|body| body.children_named("p"))
2630        .map(|paragraph| String::from_utf8(serialize_xml_fragment(paragraph)).unwrap())
2631        .collect())
2632}
2633
2634fn table_cell_mut(element: &mut XmlElement, row: usize, cell: usize) -> Option<&mut XmlElement> {
2635    element
2636        .child_mut("graphic")
2637        .and_then(|graphic| graphic.child_mut("graphicData"))
2638        .and_then(|data| data.child_mut("tbl"))
2639        .and_then(|table| nth_child_mut(table, "tr", row))
2640        .and_then(|row| nth_child_mut(row, "tc", cell))
2641}
2642
2643fn empty_table_runs(
2644    element: &mut XmlElement,
2645    text: &TextWrite,
2646    theme: Option<&Theme>,
2647    prefixes: &Prefixes,
2648) -> Vec<(usize, XmlElement)> {
2649    let TextTarget::TableCell { row, cell } = text.target else {
2650        return Vec::new();
2651    };
2652    let Some(body) = table_cell_mut(element, row, cell).and_then(|cell| cell.child_mut("txBody"))
2653    else {
2654        return Vec::new();
2655    };
2656    text.paragraphs
2657        .iter()
2658        .enumerate()
2659        .filter_map(|(index, paragraph)| {
2660            if !paragraph.rebuild || paragraph.runs.iter().any(|run| !run.text.is_empty()) {
2661                return None;
2662            }
2663            let source = body.children_named("p").nth(paragraph.source_index?)?;
2664            let mut run = source.children_named("r").next()?.clone();
2665            run.children.retain(
2666                |child| matches!(child, XmlNode::Element(element) if element.local_name() == "rPr"),
2667            );
2668            if let Some(style) = paragraph.runs.first() {
2669                if run.child("rPr").is_none() {
2670                    run.children.insert(
2671                        0,
2672                        XmlNode::Element(XmlElement::new(prefixes.drawing("rPr"))),
2673                    );
2674                }
2675                apply_run_properties(
2676                    run.child_mut("rPr").unwrap(),
2677                    &style.properties,
2678                    theme,
2679                    prefixes,
2680                );
2681            }
2682            let name = source.children_named("r").next()?.child("t")?.name.clone();
2683            run.children.push(XmlNode::Element(XmlElement::new(name)));
2684            Some((index, run))
2685        })
2686        .collect()
2687}
2688
2689fn patch_table(
2690    element: &mut XmlElement,
2691    structure: &TableStructure,
2692    part: &str,
2693) -> Result<(), PptxError> {
2694    if structure.grid.is_empty()
2695        || structure.rows.is_empty()
2696        || structure.grid.len() != structure.columns.len()
2697        || structure.grid.len() != structure.copied_columns.len()
2698        || structure.rows.iter().any(|row| {
2699            row.cells.len() != structure.grid.len()
2700                || row.copied_cells.len() != structure.grid.len()
2701        })
2702    {
2703        return Err(write_error(
2704            part,
2705            "table structure grid and cell counts must agree",
2706        ));
2707    }
2708    let table = element
2709        .child_mut("graphic")
2710        .and_then(|graphic| graphic.child_mut("graphicData"))
2711        .and_then(|data| data.child_mut("tbl"))
2712        .ok_or_else(|| write_error(part, "table structure target is not a table"))?;
2713    let mut used: HashSet<String> = table
2714        .descendants_named("rowId")
2715        .into_iter()
2716        .chain(table.descendants_named("colId"))
2717        .chain(table.descendants_named("creationId"))
2718        .flat_map(|element| element.attributes.values())
2719        .cloned()
2720        .collect();
2721    let mut next_id = used
2722        .iter()
2723        .filter_map(|value| value.parse::<u64>().ok())
2724        .max()
2725        .unwrap_or(0);
2726    let original_rows: Vec<_> = table.children_named("tr").cloned().collect();
2727    let grid = table
2728        .child_mut("tblGrid")
2729        .ok_or_else(|| write_error(part, "table has no grid"))?;
2730    let originals: Vec<_> = grid.children_named("gridCol").cloned().collect();
2731    let columns = structure
2732        .grid
2733        .iter()
2734        .zip(&structure.columns)
2735        .zip(&structure.copied_columns)
2736        .map(|((width, source), copied)| {
2737            let mut column = originals
2738                .get(*source)
2739                .cloned()
2740                .ok_or_else(|| write_error(part, "table source grid column is out of range"))?;
2741            column.set_attribute("w", width.to_string());
2742            if *copied {
2743                fresh_table_ids(&mut column, &mut next_id, &mut used, part)?;
2744            }
2745            Ok(XmlNode::Element(column))
2746        })
2747        .collect::<Result<Vec<_>, PptxError>>()?;
2748    replace_children(grid, "gridCol", columns);
2749    let rows = structure
2750        .rows
2751        .iter()
2752        .map(|source| {
2753            let mut row = original_rows
2754                .get(source.source_row)
2755                .cloned()
2756                .ok_or_else(|| write_error(part, "table source row is out of range"))?;
2757            row.set_attribute("h", source.height.to_string());
2758            let cells = source
2759                .cells
2760                .iter()
2761                .zip(&source.copied_cells)
2762                .map(|(&[r, c], copied)| {
2763                    let mut cell = original_rows
2764                        .get(r)
2765                        .and_then(|row| row.children_named("tc").nth(c))
2766                        .cloned()
2767                        .ok_or_else(|| write_error(part, "table source cell is out of range"))?;
2768                    if *copied && !source.copied {
2769                        fresh_table_ids(&mut cell, &mut next_id, &mut used, part)?;
2770                    }
2771                    Ok(XmlNode::Element(cell))
2772                })
2773                .collect::<Result<Vec<_>, PptxError>>()?;
2774            replace_children(&mut row, "tc", cells);
2775            if source.copied {
2776                fresh_table_ids(&mut row, &mut next_id, &mut used, part)?;
2777            }
2778            Ok(XmlNode::Element(row))
2779        })
2780        .collect::<Result<Vec<_>, PptxError>>()?;
2781    replace_children(table, "tr", rows);
2782    Ok(())
2783}
2784
2785fn fresh_table_ids(
2786    element: &mut XmlElement,
2787    next: &mut u64,
2788    used: &mut HashSet<String>,
2789    part: &str,
2790) -> Result<(), PptxError> {
2791    if matches!(element.local_name(), "rowId" | "colId" | "creationId") {
2792        for (key, value) in &mut element.attributes {
2793            if matches!(key.rsplit(':').next(), Some("val" | "id")) {
2794                loop {
2795                    *next = next
2796                        .checked_add(1)
2797                        .filter(|id| *id <= u64::from(u32::MAX))
2798                        .ok_or_else(|| write_error(part, "table identifier range exhausted"))?;
2799                    let candidate = if value.parse::<u64>().is_ok() {
2800                        next.to_string()
2801                    } else {
2802                        format!("{{FFFFFFFF-FFFF-4FFF-8FFF-{:012X}}}", *next)
2803                    };
2804                    if used.insert(candidate.clone()) {
2805                        *value = candidate;
2806                        break;
2807                    }
2808                }
2809            }
2810        }
2811    }
2812    for child in &mut element.children {
2813        if let XmlNode::Element(child) = child {
2814            fresh_table_ids(child, next, used, part)?;
2815        }
2816    }
2817    Ok(())
2818}
2819
2820fn replace_children(parent: &mut XmlElement, local: &str, replacements: Vec<XmlNode>) {
2821    let at = parent
2822        .children
2823        .iter()
2824        .position(
2825            |child| matches!(child, XmlNode::Element(element) if element.local_name() == local),
2826        )
2827        .unwrap_or(parent.children.len());
2828    parent.children.retain(
2829        |child| !matches!(child, XmlNode::Element(element) if element.local_name() == local),
2830    );
2831    parent.children.splice(at..at, replacements);
2832}
2833
2834fn patch_text(
2835    element: &mut XmlElement,
2836    text: &TextWrite,
2837    theme: Option<&Theme>,
2838    prefixes: &Prefixes,
2839    part: &str,
2840) -> Result<(), PptxError> {
2841    let body = match &text.target {
2842        TextTarget::Body => element.child_mut("txBody"),
2843        TextTarget::TableCell { row, cell } => element
2844            .child_mut("graphic")
2845            .and_then(|graphic| graphic.child_mut("graphicData"))
2846            .and_then(|data| data.child_mut("tbl"))
2847            .and_then(|table| nth_child_mut(table, "tr", *row))
2848            .and_then(|table_row| nth_child_mut(table_row, "tc", *cell))
2849            .and_then(|table_cell| table_cell.child_mut("txBody")),
2850    };
2851    let body = body.ok_or_else(|| write_error(part, "text target has no body"))?;
2852    if let Some(TextAutofit::Normal {
2853        font_scale,
2854        line_space_reduction,
2855    }) = &text.autofit
2856    {
2857        if body.child("bodyPr").is_none() {
2858            body.children.insert(
2859                0,
2860                XmlNode::Element(XmlElement::new(prefixes.drawing("bodyPr"))),
2861            );
2862        }
2863        let properties = body.child_mut("bodyPr").unwrap();
2864        if properties.child("normAutofit").is_none() {
2865            let position = properties.children.iter().position(|child| {
2866                matches!(child, XmlNode::Element(element) if matches!(element.local_name(), "scene3d" | "sp3d" | "flatTx" | "extLst"))
2867            }).unwrap_or(properties.children.len());
2868            properties.children.insert(
2869                position,
2870                XmlNode::Element(XmlElement::new(prefixes.drawing("normAutofit"))),
2871            );
2872        }
2873        let autofit = properties.child_mut("normAutofit").unwrap();
2874        if let Some(scale) = font_scale {
2875            autofit.set_attribute("fontScale", format_fixed(scale * 100_000.0));
2876        }
2877        if let Some(reduction) = line_space_reduction {
2878            autofit.set_attribute("lnSpcReduction", format_fixed(reduction * 100_000.0));
2879        }
2880    }
2881    rebuild_paragraphs(body, &text.paragraphs, theme, prefixes)?;
2882    Ok(())
2883}
2884
2885fn nth_child_mut<'a>(
2886    parent: &'a mut XmlElement,
2887    local: &str,
2888    index: usize,
2889) -> Option<&'a mut XmlElement> {
2890    parent
2891        .children
2892        .iter_mut()
2893        .filter_map(|child| match child {
2894            XmlNode::Element(element) if element.local_name() == local => Some(element),
2895            _ => None,
2896        })
2897        .nth(index)
2898}
2899
2900fn rebuild_paragraphs(
2901    body: &mut XmlElement,
2902    paragraphs: &[ParagraphWrite],
2903    theme: Option<&Theme>,
2904    prefixes: &Prefixes,
2905) -> Result<(), PptxError> {
2906    let mut preamble = Vec::new();
2907    let mut originals: Vec<XmlElement> = Vec::new();
2908    for child in std::mem::take(&mut body.children) {
2909        match child {
2910            XmlNode::Element(element) if element.local_name() == "p" => {
2911                originals.push(element);
2912            }
2913            XmlNode::Text(text) if text.trim().is_empty() => {}
2914            other => preamble.push(other),
2915        }
2916    }
2917    let mut children = preamble;
2918    let limits = ParseLimits::default();
2919    let mut budget = ParseBudget::new(&limits);
2920    for paragraph in paragraphs {
2921        let source = if let Some(first) = paragraph.source_paragraphs.first() {
2922            first
2923                .as_ref()
2924                .map(|xml| parse_xml(xml.as_bytes(), "paragraph", &mut budget))
2925                .transpose()?
2926        } else if let Some(xml) = &paragraph.preserved_xml {
2927            Some(parse_xml(xml.as_bytes(), "paragraph", &mut budget)?)
2928        } else {
2929            paragraph
2930                .source_index
2931                .and_then(|index| originals.get(index).cloned())
2932        };
2933        let mut source = source;
2934        for xml in paragraph.source_paragraphs.iter().skip(1) {
2935            let source = source.get_or_insert_with(|| XmlElement::new(prefixes.drawing("p")));
2936            if let Some(xml) = xml {
2937                let next = parse_xml(xml.as_bytes(), "paragraph", &mut budget)?;
2938                if next.child("endParaRPr").is_some() {
2939                    source.children.retain(|child| {
2940                        !matches!(child, XmlNode::Element(element) if element.local_name() == "endParaRPr")
2941                    });
2942                }
2943                source.children.extend(
2944                    next.child_elements()
2945                        .filter(|child| {
2946                            is_run_element(child.local_name()) || child.local_name() == "endParaRPr"
2947                        })
2948                        .cloned()
2949                        .map(XmlNode::Element),
2950                );
2951            }
2952        }
2953        let element = match source {
2954            Some(element) if !paragraph.rebuild => element,
2955            source => build_paragraph(paragraph, source, theme, prefixes),
2956        };
2957        children.push(XmlNode::Element(element));
2958    }
2959    if children
2960        .iter()
2961        .all(|child| !matches!(child, XmlNode::Element(element) if element.local_name() == "p"))
2962    {
2963        children.push(XmlNode::Element(XmlElement::new(prefixes.drawing("p"))));
2964    }
2965    body.children = children;
2966    Ok(())
2967}
2968
2969fn is_run_element(local: &str) -> bool {
2970    matches!(local, "r" | "br" | "fld")
2971}
2972
2973/// One piece of the paragraph's target text: a line break or a styled span.
2974struct RunSegment<'a> {
2975    line_break: bool,
2976    text: &'a str,
2977    properties: &'a RunProperties,
2978}
2979
2980fn run_segments(runs: &[RunWrite]) -> Vec<RunSegment<'_>> {
2981    let mut segments = Vec::new();
2982    for run in runs {
2983        for (index, piece) in run.text.split('\n').enumerate() {
2984            if index > 0 {
2985                segments.push(RunSegment {
2986                    line_break: true,
2987                    text: "",
2988                    properties: &run.properties,
2989                });
2990            }
2991            if !piece.is_empty() {
2992                segments.push(RunSegment {
2993                    line_break: false,
2994                    text: piece,
2995                    properties: &run.properties,
2996                });
2997            }
2998        }
2999    }
3000    segments
3001}
3002
3003/// Matches text and modeled styling against a source run.
3004fn segment_matches(element: &XmlElement, segment: &RunSegment<'_>, theme: Option<&Theme>) -> bool {
3005    if element.local_name() == "br" {
3006        return segment.line_break;
3007    }
3008    if segment.line_break {
3009        return false;
3010    }
3011    let text = element
3012        .child("t")
3013        .map(XmlElement::text_content)
3014        .unwrap_or_default();
3015    if text != segment.text {
3016        return false;
3017    }
3018    let source = parse_run_properties(element.child("rPr"));
3019    let target = segment.properties;
3020    source.baseline_pct == target.baseline_pct
3021        && source.bold == target.bold
3022        && source.italic == target.italic
3023        && source.font_size_pt == target.font_size_pt
3024        && source.spacing_pt == target.spacing_pt
3025        && source.kern_pt == target.kern_pt
3026        && source.underline == target.underline
3027        && source.caps == target.caps
3028        && source.font_family == target.font_family
3029        && resolve_color_value_to_hex_with_theme(source.color.as_ref(), theme)
3030            == resolve_color_value_to_hex_with_theme(target.color.as_ref(), theme)
3031}
3032
3033fn build_paragraph(
3034    write: &ParagraphWrite,
3035    source: Option<XmlElement>,
3036    theme: Option<&Theme>,
3037    prefixes: &Prefixes,
3038) -> XmlElement {
3039    let (mut paragraph, source_runs) = match source {
3040        Some(mut element) => {
3041            let mut runs = Vec::new();
3042            let mut kept = Vec::new();
3043            for child in std::mem::take(&mut element.children) {
3044                match child {
3045                    XmlNode::Element(child) if is_run_element(child.local_name()) => {
3046                        runs.push(child);
3047                    }
3048                    XmlNode::Text(text) if text.trim().is_empty() => {}
3049                    other => kept.push(other),
3050                }
3051            }
3052            element.children = kept;
3053            (element, runs)
3054        }
3055        None => (XmlElement::new(prefixes.drawing("p")), Vec::new()),
3056    };
3057    let fresh = write.source_index.is_none();
3058    if write.properties_changed || fresh {
3059        apply_paragraph_properties(&mut paragraph, write, prefixes);
3060    }
3061    let segments = run_segments(&write.runs);
3062    let mut front = 0;
3063    let mut back = 0;
3064    while front < source_runs.len()
3065        && front < segments.len()
3066        && segment_matches(&source_runs[front], &segments[front], theme)
3067    {
3068        let steals = front + 1 < source_runs.len() - back
3069            && segment_matches(&source_runs[front + 1], &segments[front], theme)
3070            && source_span_len(&source_runs, front + 1..source_runs.len() - back)
3071                > target_span_len(&segments, front + 1..segments.len() - back);
3072        if steals {
3073            break;
3074        }
3075        front += 1;
3076    }
3077    while back < source_runs.len() - front
3078        && back < segments.len() - front
3079        && segment_matches(
3080            &source_runs[source_runs.len() - 1 - back],
3081            &segments[segments.len() - 1 - back],
3082            theme,
3083        )
3084    {
3085        let src = source_runs.len() - 1 - back;
3086        let seg = segments.len() - 1 - back;
3087        let steals = src > front
3088            && segment_matches(&source_runs[src - 1], &segments[seg], theme)
3089            && source_span_len(&source_runs, front..src) > target_span_len(&segments, front..seg);
3090        if steals {
3091            break;
3092        }
3093        back += 1;
3094    }
3095    let tail_start = source_runs.len() - back;
3096    let rebuilt = span_elements(
3097        &segments[front..segments.len() - back],
3098        &source_runs,
3099        front..tail_start,
3100        theme,
3101        prefixes,
3102    );
3103    let mut runs: Vec<XmlNode> = Vec::with_capacity(segments.len());
3104    let mut source_runs = source_runs.into_iter();
3105    for element in source_runs.by_ref().take(front) {
3106        runs.push(XmlNode::Element(element));
3107    }
3108    runs.extend(rebuilt);
3109    for element in source_runs.skip(tail_start - front) {
3110        runs.push(XmlNode::Element(element));
3111    }
3112    let position = paragraph
3113        .children
3114        .iter()
3115        .position(|child| {
3116            matches!(child, XmlNode::Element(element) if element.local_name() == "endParaRPr")
3117        })
3118        .unwrap_or(paragraph.children.len());
3119    paragraph.children.splice(position..position, runs);
3120    paragraph
3121}
3122
3123/// Target slice rebuilt onto one source run.
3124struct TargetRange {
3125    end: usize,
3126    source: usize,
3127    /// Source text kept unchanged, in order.
3128    verbatim: bool,
3129    /// Intact `a:fld` kept as a field.
3130    field: bool,
3131}
3132
3133/// Rebuilds the span onto source runs, preserving unmodeled markup.
3134fn span_elements(
3135    segments: &[RunSegment<'_>],
3136    source_runs: &[XmlElement],
3137    span: Range<usize>,
3138    theme: Option<&Theme>,
3139    prefixes: &Prefixes,
3140) -> Vec<XmlNode> {
3141    if source_runs.is_empty() {
3142        return segments
3143            .iter()
3144            .map(|segment| XmlNode::Element(segment_element(segment, None, theme, prefixes)))
3145            .collect();
3146    }
3147    let ranges = align_span(segments, source_runs, span);
3148    let mut output = Vec::new();
3149    let mut offset = 0;
3150    let mut range_index = 0;
3151    let mut range_start = 0;
3152    for segment in segments {
3153        let mut remaining = segment.text;
3154        loop {
3155            while ranges[range_index].end <= offset {
3156                range_start = ranges[range_index].end;
3157                range_index += 1;
3158            }
3159            let range = &ranges[range_index];
3160            let source = &source_runs[range.source];
3161            if segment.line_break {
3162                let template = if source.local_name() == "br"
3163                    || source
3164                        .child("rPr")
3165                        .is_some_and(|properties| properties.child("gradFill").is_some())
3166                {
3167                    source.child("rPr")
3168                } else {
3169                    None
3170                };
3171                output.push(XmlNode::Element(segment_element(
3172                    segment, template, theme, prefixes,
3173                )));
3174                offset += 1;
3175                break;
3176            }
3177            let length = remaining.len().min(range.end - offset);
3178            let piece = RunSegment {
3179                text: &remaining[..length],
3180                ..*segment
3181            };
3182            let element = if range.field && offset == range_start && offset + length == range.end {
3183                field_element(source, &piece, theme, prefixes)
3184            } else {
3185                segment_element(&piece, source.child("rPr"), theme, prefixes)
3186            };
3187            output.push(XmlNode::Element(element));
3188            offset += length;
3189            remaining = &remaining[length..];
3190            if remaining.is_empty() {
3191                break;
3192            }
3193        }
3194    }
3195    output
3196}
3197
3198fn run_text(run: &XmlElement) -> String {
3199    if run.local_name() == "br" {
3200        return "\n".to_owned();
3201    }
3202    run.child("t")
3203        .map(XmlElement::text_content)
3204        .unwrap_or_default()
3205}
3206
3207fn segment_text<'a>(segment: &RunSegment<'a>) -> &'a str {
3208    if segment.line_break {
3209        "\n"
3210    } else {
3211        segment.text
3212    }
3213}
3214
3215fn source_span_len(runs: &[XmlElement], span: Range<usize>) -> usize {
3216    runs[span].iter().map(|run| run_text(run).len()).sum()
3217}
3218
3219fn target_span_len(segments: &[RunSegment<'_>], span: Range<usize>) -> usize {
3220    segments[span]
3221        .iter()
3222        .map(|segment| segment_text(segment).len())
3223        .sum()
3224}
3225
3226fn push_range(ranges: &mut Vec<TargetRange>, end: usize, source: usize, verbatim: bool) {
3227    if end <= ranges.last().map_or(0, |last| last.end) {
3228        return;
3229    }
3230    match ranges.last_mut() {
3231        Some(last) if last.source == source && last.verbatim == verbatim => last.end = end,
3232        _ => ranges.push(TargetRange {
3233            end,
3234            source,
3235            verbatim,
3236            field: false,
3237        }),
3238    }
3239}
3240
3241fn align_span(
3242    segments: &[RunSegment<'_>],
3243    source_runs: &[XmlElement],
3244    span: Range<usize>,
3245) -> Vec<TargetRange> {
3246    let runs = &source_runs[span.clone()];
3247    let mut source_text = String::new();
3248    let mut source_ends = Vec::with_capacity(runs.len());
3249    for run in runs {
3250        source_text.push_str(&run_text(run));
3251        source_ends.push(source_text.len());
3252    }
3253    let target_text: String = segments.iter().map(segment_text).collect();
3254    let run_at = |offset: usize| {
3255        span.start
3256            + source_ends
3257                .iter()
3258                .position(|&end| end > offset)
3259                .unwrap_or(source_ends.len().saturating_sub(1))
3260    };
3261    let prefix: usize = source_text
3262        .chars()
3263        .zip(target_text.chars())
3264        .take_while(|(left, right)| left == right)
3265        .map(|(value, _)| value.len_utf8())
3266        .sum();
3267    let suffix: usize = source_text[prefix..]
3268        .chars()
3269        .rev()
3270        .zip(target_text[prefix..].chars().rev())
3271        .take_while(|(left, right)| left == right)
3272        .map(|(value, _)| value.len_utf8())
3273        .sum();
3274    let source_end = source_text.len() - suffix;
3275    let target_end = target_text.len() - suffix;
3276
3277    let mut ranges: Vec<TargetRange> = Vec::new();
3278    for (index, &end) in source_ends.iter().enumerate() {
3279        push_range(&mut ranges, end.min(prefix), span.start + index, true);
3280    }
3281    let seed = span.start.saturating_sub(1);
3282    let (mut source_offset, mut target_offset) = (prefix, prefix);
3283    let mut replaced: Option<usize> = None;
3284    for op in char_diff(
3285        &source_text[prefix..source_end],
3286        &target_text[prefix..target_end],
3287    ) {
3288        match op {
3289            DiffOp::Match(length) => {
3290                let mut done = 0;
3291                while done < length {
3292                    let run = run_at(source_offset + done);
3293                    let piece =
3294                        (source_ends[run - span.start] - (source_offset + done)).min(length - done);
3295                    push_range(&mut ranges, target_offset + done + piece, run, true);
3296                    done += piece;
3297                }
3298                source_offset += length;
3299                target_offset += length;
3300                replaced = None;
3301            }
3302            DiffOp::Delete(length) => {
3303                if replaced.is_none() {
3304                    replaced = Some(run_at(source_offset));
3305                }
3306                source_offset += length;
3307            }
3308            DiffOp::Insert(length) => {
3309                let source = replaced
3310                    .take()
3311                    .or_else(|| ranges.last().map(|range| range.source))
3312                    .unwrap_or(seed);
3313                push_range(&mut ranges, target_offset + length, source, false);
3314                target_offset += length;
3315            }
3316        }
3317    }
3318    for (index, &end) in source_ends.iter().enumerate() {
3319        if end > source_end {
3320            push_range(
3321                &mut ranges,
3322                target_end + end - source_end,
3323                span.start + index,
3324                true,
3325            );
3326        }
3327    }
3328    let mut kept = vec![false; runs.len()];
3329    for range in &ranges {
3330        if range.verbatim {
3331            kept[range.source - span.start] = true;
3332        }
3333    }
3334    for field_idx in 0..runs.len() {
3335        if runs[field_idx].local_name() != "fld" || kept[field_idx] {
3336            continue;
3337        }
3338        let field_text = run_text(&runs[field_idx]);
3339        if field_text.is_empty() {
3340            continue;
3341        }
3342        let mut donor: Option<usize> = None;
3343        for candidate in [
3344            field_idx.checked_sub(1),
3345            field_idx.checked_add(1).filter(|next| *next < runs.len()),
3346        ]
3347        .into_iter()
3348        .flatten()
3349        {
3350            if runs[candidate].local_name() == "r"
3351                && kept[candidate]
3352                && run_text(&runs[candidate]) == field_text
3353            {
3354                donor = Some(candidate);
3355                break;
3356            }
3357        }
3358        if let Some(donor) = donor {
3359            for range in &mut ranges {
3360                if range.verbatim && range.source == span.start + donor {
3361                    range.source = span.start + field_idx;
3362                }
3363            }
3364            kept[donor] = false;
3365            kept[field_idx] = true;
3366        }
3367    }
3368
3369    let mut verbatim_ranges = vec![0usize; runs.len()];
3370    for range in ranges.iter().filter(|range| range.verbatim) {
3371        verbatim_ranges[range.source - span.start] += 1;
3372    }
3373    let mut merged: Vec<TargetRange> = Vec::with_capacity(ranges.len());
3374    let mut start = 0;
3375    for mut range in ranges {
3376        range.field = range.verbatim && {
3377            let relative = range.source - span.start;
3378            let run_start = relative
3379                .checked_sub(1)
3380                .map_or(0, |previous| source_ends[previous]);
3381            runs[relative].local_name() == "fld"
3382                && verbatim_ranges[relative] == 1
3383                && range.end - start == source_ends[relative] - run_start
3384        };
3385        start = range.end;
3386        match merged.last_mut() {
3387            Some(last) if last.source == range.source && !last.field && !range.field => {
3388                last.end = range.end
3389            }
3390            _ => merged.push(range),
3391        }
3392    }
3393    merged
3394}
3395
3396/// Diff step in bytes.
3397enum DiffOp {
3398    Match(usize),
3399    Delete(usize),
3400    Insert(usize),
3401}
3402
3403const DIFF_CELL_LIMIT: usize = 1 << 20;
3404
3405fn push_op(ops: &mut Vec<DiffOp>, op: DiffOp) {
3406    match (ops.last_mut(), op) {
3407        (Some(DiffOp::Match(last)), DiffOp::Match(length))
3408        | (Some(DiffOp::Delete(last)), DiffOp::Delete(length))
3409        | (Some(DiffOp::Insert(last)), DiffOp::Insert(length)) => *last += length,
3410        (_, op) => ops.push(op),
3411    }
3412}
3413
3414/// LCS alignment; ties delete first, oversized counts as replacement.
3415fn char_diff(source: &str, target: &str) -> Vec<DiffOp> {
3416    let source_chars: Vec<char> = source.chars().collect();
3417    let target_chars: Vec<char> = target.chars().collect();
3418    let (rows, columns) = (source_chars.len(), target_chars.len());
3419    let mut ops = Vec::new();
3420    if rows == 0 || columns == 0 || rows.saturating_mul(columns) > DIFF_CELL_LIMIT {
3421        if rows > 0 {
3422            push_op(&mut ops, DiffOp::Delete(source.len()));
3423        }
3424        if columns > 0 {
3425            push_op(&mut ops, DiffOp::Insert(target.len()));
3426        }
3427        return ops;
3428    }
3429    let width = columns + 1;
3430    let mut table = vec![0u16; (rows + 1) * width];
3431    for row in (0..rows).rev() {
3432        for column in (0..columns).rev() {
3433            table[row * width + column] = if source_chars[row] == target_chars[column] {
3434                table[(row + 1) * width + column + 1] + 1
3435            } else {
3436                table[(row + 1) * width + column].max(table[row * width + column + 1])
3437            };
3438        }
3439    }
3440    let (mut row, mut column) = (0, 0);
3441    while row < rows && column < columns {
3442        if source_chars[row] == target_chars[column] {
3443            push_op(&mut ops, DiffOp::Match(source_chars[row].len_utf8()));
3444            row += 1;
3445            column += 1;
3446        } else if table[(row + 1) * width + column] >= table[row * width + column + 1] {
3447            push_op(&mut ops, DiffOp::Delete(source_chars[row].len_utf8()));
3448            row += 1;
3449        } else {
3450            push_op(&mut ops, DiffOp::Insert(target_chars[column].len_utf8()));
3451            column += 1;
3452        }
3453    }
3454    for value in &source_chars[row..] {
3455        push_op(&mut ops, DiffOp::Delete(value.len_utf8()));
3456    }
3457    for value in &target_chars[column..] {
3458        push_op(&mut ops, DiffOp::Insert(value.len_utf8()));
3459    }
3460    ops
3461}
3462
3463fn segment_properties(
3464    segment: &RunSegment<'_>,
3465    template: Option<&XmlElement>,
3466    theme: Option<&Theme>,
3467    prefixes: &Prefixes,
3468) -> Option<XmlElement> {
3469    match template {
3470        Some(template) => {
3471            let mut base = template.clone();
3472            apply_run_properties(&mut base, segment.properties, theme, prefixes);
3473            Some(base)
3474        }
3475        None => run_properties_element(segment.properties, prefixes),
3476    }
3477}
3478
3479fn segment_element(
3480    segment: &RunSegment<'_>,
3481    template: Option<&XmlElement>,
3482    theme: Option<&Theme>,
3483    prefixes: &Prefixes,
3484) -> XmlElement {
3485    let mut element =
3486        XmlElement::new(prefixes.drawing(if segment.line_break { "br" } else { "r" }));
3487    if let Some(properties) = segment_properties(segment, template, theme, prefixes) {
3488        element = element.with_child(properties);
3489    }
3490    if segment.line_break {
3491        element
3492    } else {
3493        element.with_child(XmlElement::new(prefixes.drawing("t")).with_text(segment.text))
3494    }
3495}
3496
3497/// Intact `a:fld` keeps its binding.
3498fn field_element(
3499    source: &XmlElement,
3500    segment: &RunSegment<'_>,
3501    theme: Option<&Theme>,
3502    prefixes: &Prefixes,
3503) -> XmlElement {
3504    let mut element = XmlElement::new(source.name.clone());
3505    element.attributes = source.attributes.clone();
3506    if let Some(properties) = segment_properties(segment, source.child("rPr"), theme, prefixes) {
3507        element.children.push(XmlNode::Element(properties));
3508    }
3509    element.children.extend(
3510        source
3511            .child_elements()
3512            .filter(|child| !matches!(child.local_name(), "rPr" | "t"))
3513            .cloned()
3514            .map(XmlNode::Element),
3515    );
3516    element.children.push(XmlNode::Element(
3517        XmlElement::new(prefixes.drawing("t")).with_text(segment.text),
3518    ));
3519    element
3520}
3521
3522const POST_LATIN_ELEMENTS: [&str; 7] = [
3523    "ea",
3524    "cs",
3525    "sym",
3526    "hlinkClick",
3527    "hlinkMouseOver",
3528    "rtl",
3529    "extLst",
3530];
3531
3532fn is_flattened_colour_snapshot(
3533    authored: Option<&ColorValue>,
3534    requested: Option<&ColorValue>,
3535    theme: Option<&Theme>,
3536) -> bool {
3537    let (Some(authored), Some(requested)) = (authored, requested) else {
3538        return false;
3539    };
3540    if !requested.transforms.is_empty()
3541        || requested.theme_color.is_some()
3542        || requested.theme_tint.is_some()
3543        || requested.theme_shade.is_some()
3544        || requested.auto.is_some()
3545        || requested.luminance_modulation.is_some()
3546        || requested.luminance_offset.is_some()
3547        || requested.saturation_modulation.is_some()
3548    {
3549        return false;
3550    }
3551    if requested.alpha.is_some()
3552        && ooxml_drawingml::resolve_color_value_alpha(requested)
3553            != ooxml_drawingml::resolve_color_value_alpha(authored)
3554    {
3555        return false;
3556    }
3557    resolve_color_value_to_hex_with_theme(Some(authored), theme)
3558        .zip(requested.rgb.as_deref())
3559        .is_some_and(|(hex, rgb)| rgb.eq_ignore_ascii_case(&hex[1..]))
3560}
3561
3562/// Updates modeled styling while preserving unmodeled XML.
3563fn apply_run_properties(
3564    base: &mut XmlElement,
3565    properties: &RunProperties,
3566    theme: Option<&Theme>,
3567    prefixes: &Prefixes,
3568) {
3569    match properties.font_size_pt {
3570        Some(size) => base.set_attribute("sz", format_fixed(size * 100.0)),
3571        None => {
3572            base.attributes.remove("sz");
3573        }
3574    }
3575    match properties.spacing_pt {
3576        Some(spacing) => base.set_attribute("spc", format_fixed(spacing * 100.0)),
3577        None => {
3578            base.attributes.remove("spc");
3579        }
3580    }
3581    match properties.kern_pt {
3582        Some(kern) => base.set_attribute("kern", format_fixed(kern * 100.0)),
3583        None => {
3584            base.attributes.remove("kern");
3585        }
3586    }
3587    match properties.baseline_pct {
3588        Some(baseline) => base.set_attribute("baseline", format_fixed(baseline * 1000.0)),
3589        None => {
3590            base.attributes.remove("baseline");
3591        }
3592    }
3593    match properties.caps {
3594        Some(caps) => base.set_attribute("cap", caps.as_attribute()),
3595        None => {
3596            base.attributes.remove("cap");
3597        }
3598    }
3599    let toggles = [("b", properties.bold), ("i", properties.italic)];
3600    for (name, value) in toggles {
3601        match value {
3602            Some(value) => base.set_attribute(name, if value { "1" } else { "0" }),
3603            None => {
3604                base.attributes.remove(name);
3605            }
3606        }
3607    }
3608    match &properties.underline {
3609        Some(underline) => base.set_attribute("u", underline.clone()),
3610        None => {
3611            base.attributes.remove("u");
3612        }
3613    }
3614    let authored = parse_run_properties(Some(base)).color;
3615    let keeps_fill = authored == properties.color
3616        || is_flattened_colour_snapshot(authored.as_ref(), properties.color.as_ref(), theme);
3617    // A colour write replaces the whole fill choice; clearing the colour only
3618    // drops an explicit solidFill so an unmodeled noFill/gradFill survives.
3619    let removed_fills: &[&str] = if keeps_fill {
3620        &[]
3621    } else if properties.color.is_some() {
3622        &FILL_ELEMENTS
3623    } else {
3624        &["solidFill"]
3625    };
3626    base.children.retain(|child| {
3627        !matches!(
3628            child,
3629            XmlNode::Element(element) if removed_fills.contains(&element.local_name())
3630        )
3631    });
3632    if let Some(color) = properties
3633        .color
3634        .as_ref()
3635        .filter(|_| !keeps_fill)
3636        .and_then(|color| color_element(color, prefixes))
3637    {
3638        let position = base
3639            .children
3640            .iter()
3641            .position(
3642                |child| !matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
3643            )
3644            .unwrap_or(base.children.len());
3645        base.children.insert(
3646            position,
3647            XmlNode::Element(XmlElement::new(prefixes.drawing("solidFill")).with_child(color)),
3648        );
3649    }
3650    let Some(family) = &properties.font_family else {
3651        base.children.retain(|child| {
3652            !matches!(
3653                child,
3654                XmlNode::Element(element) if element.local_name() == "latin"
3655            )
3656        });
3657        return;
3658    };
3659    if let Some(latin) = base.child_mut("latin") {
3660        if latin.attribute("typeface") != Some(family.as_str()) {
3661            // panose, pitchFamily and charset describe the old face.
3662            latin
3663                .attributes
3664                .retain(|name, _| name == "xmlns" || name.starts_with("xmlns:"));
3665            latin.set_attribute("typeface", family.clone());
3666        }
3667    } else {
3668        let position = base
3669            .children
3670            .iter()
3671            .position(|child| {
3672                matches!(
3673                    child,
3674                    XmlNode::Element(element)
3675                        if POST_LATIN_ELEMENTS.contains(&element.local_name())
3676                )
3677            })
3678            .unwrap_or(base.children.len());
3679        base.children.insert(
3680            position,
3681            XmlNode::Element(
3682                XmlElement::new(prefixes.drawing("latin"))
3683                    .with_attribute("typeface", family.clone()),
3684            ),
3685        );
3686    }
3687}
3688
3689fn apply_paragraph_properties(
3690    paragraph: &mut XmlElement,
3691    write: &ParagraphWrite,
3692    prefixes: &Prefixes,
3693) {
3694    let needs_properties = write.alignment.is_some() || write.level > 0 || write.bullet.is_some();
3695    if paragraph.child_mut("pPr").is_none() {
3696        if !needs_properties {
3697            return;
3698        }
3699        paragraph.children.insert(
3700            0,
3701            XmlNode::Element(XmlElement::new(prefixes.drawing("pPr"))),
3702        );
3703    }
3704    let properties = paragraph.child_mut("pPr").expect("ensured above");
3705    match &write.alignment {
3706        Some(alignment) => properties.set_attribute("algn", alignment.clone()),
3707        None => {
3708            properties.attributes.remove("algn");
3709        }
3710    }
3711    if write.level > 0 {
3712        properties.set_attribute("lvl", write.level.to_string());
3713    } else {
3714        properties.attributes.remove("lvl");
3715    }
3716    properties.children.retain(|child| {
3717        !matches!(
3718            child,
3719            XmlNode::Element(element)
3720                if matches!(element.local_name(), "buNone" | "buChar" | "buAutoNum")
3721        )
3722    });
3723    let bullet = match &write.bullet {
3724        Some(Bullet::Character { value }) => {
3725            Some(XmlElement::new(prefixes.drawing("buChar")).with_attribute("char", value.clone()))
3726        }
3727        Some(Bullet::AutoNumber {
3728            scheme,
3729            start_at,
3730            restart,
3731        }) => {
3732            let mut element = XmlElement::new(prefixes.drawing("buAutoNum"))
3733                .with_attribute("type", scheme.clone());
3734            if *restart || *start_at != 1 {
3735                element.set_attribute("startAt", start_at.to_string());
3736            }
3737            Some(element)
3738        }
3739        Some(Bullet::None) => Some(XmlElement::new(prefixes.drawing("buNone"))),
3740        None => None,
3741    };
3742    if let Some(bullet) = bullet {
3743        let position = properties
3744            .children
3745            .iter()
3746            .position(|child| {
3747                matches!(
3748                    child,
3749                    XmlNode::Element(element)
3750                        if matches!(element.local_name(), "tabLst" | "defRPr" | "extLst")
3751                )
3752            })
3753            .unwrap_or(properties.children.len());
3754        properties
3755            .children
3756            .insert(position, XmlNode::Element(bullet));
3757    }
3758}
3759
3760fn run_properties_element(properties: &RunProperties, prefixes: &Prefixes) -> Option<XmlElement> {
3761    let mut element = XmlElement::new(prefixes.drawing("rPr"));
3762    let mut present = false;
3763    if let Some(language) = &properties.language {
3764        element.set_attribute("lang", language.clone());
3765        present = true;
3766    }
3767    if let Some(size) = properties.font_size_pt {
3768        element.set_attribute("sz", format_fixed(size * 100.0));
3769        present = true;
3770    }
3771    if let Some(spacing) = properties.spacing_pt {
3772        element.set_attribute("spc", format_fixed(spacing * 100.0));
3773        present = true;
3774    }
3775    if let Some(kern) = properties.kern_pt {
3776        element.set_attribute("kern", format_fixed(kern * 100.0));
3777        present = true;
3778    }
3779    if let Some(baseline) = properties.baseline_pct {
3780        element.set_attribute("baseline", format_fixed(baseline * 1000.0));
3781        present = true;
3782    }
3783    if let Some(bold) = properties.bold {
3784        element.set_attribute("b", if bold { "1" } else { "0" });
3785        present = true;
3786    }
3787    if let Some(italic) = properties.italic {
3788        element.set_attribute("i", if italic { "1" } else { "0" });
3789        present = true;
3790    }
3791    if let Some(underline) = &properties.underline {
3792        element.set_attribute("u", underline.clone());
3793        present = true;
3794    }
3795    if let Some(caps) = properties.caps {
3796        element.set_attribute("cap", caps.as_attribute());
3797        present = true;
3798    }
3799    if let Some(color) = properties
3800        .color
3801        .as_ref()
3802        .and_then(|color| color_element(color, prefixes))
3803    {
3804        element =
3805            element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
3806        present = true;
3807    }
3808    if let Some(family) = &properties.font_family {
3809        element = element.with_child(
3810            XmlElement::new(prefixes.drawing("latin")).with_attribute("typeface", family.clone()),
3811        );
3812        present = true;
3813    }
3814    present.then_some(element)
3815}
3816
3817// --- new shapes and slides --------------------------------------------------
3818
3819fn shape_element(
3820    add: &ShapeAdd,
3821    next_shape_id: &mut Option<u32>,
3822    prefixes: &Prefixes,
3823    part: &str,
3824) -> Result<XmlElement, PptxError> {
3825    if matches!(add.geometry.as_str(), "custom" | "group") {
3826        return Err(write_error(
3827            part,
3828            format!("unsupported geometry {:?} for a new shape", add.geometry),
3829        ));
3830    }
3831    let shape_id = alloc_shape_id(next_shape_id, part)?;
3832    let mut non_visual_properties = XmlElement::new(prefixes.presentation("cNvSpPr"));
3833    if add.text_box {
3834        non_visual_properties.set_attribute("txBox", "1");
3835    }
3836    let non_visual = XmlElement::new(prefixes.presentation("nvSpPr"))
3837        .with_child(
3838            XmlElement::new(prefixes.presentation("cNvPr"))
3839                .with_attribute("id", shape_id.to_string())
3840                .with_attribute("name", add.name.clone()),
3841        )
3842        .with_child(non_visual_properties)
3843        .with_child(XmlElement::new(prefixes.presentation("nvPr")));
3844    let transform = XmlElement::new(prefixes.drawing("xfrm"))
3845        .with_child(
3846            XmlElement::new(prefixes.drawing("off"))
3847                .with_attribute("x", add.x.to_string())
3848                .with_attribute("y", add.y.to_string()),
3849        )
3850        .with_child(
3851            XmlElement::new(prefixes.drawing("ext"))
3852                .with_attribute("cx", add.width.to_string())
3853                .with_attribute("cy", add.height.to_string()),
3854        );
3855    let mut adjust_list = XmlElement::new(prefixes.drawing("avLst"));
3856    for (name, value) in &add.adjust_values {
3857        adjust_list = adjust_list.with_child(
3858            XmlElement::new(prefixes.drawing("gd"))
3859                .with_attribute("name", name.clone())
3860                .with_attribute("fmla", format!("val {}", format_fixed(value * 100_000.0))),
3861        );
3862    }
3863    let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
3864        .with_attribute("prst", add.geometry.clone())
3865        .with_child(adjust_list);
3866    let mut properties = XmlElement::new(prefixes.presentation("spPr"))
3867        .with_child(transform)
3868        .with_child(geometry);
3869    if let Some(fill) = &add.fill {
3870        properties = properties.with_child(fill_element(fill, prefixes, part)?);
3871    }
3872    if let Some(outline) = &add.outline {
3873        properties = properties.with_child(outline_element(outline, prefixes));
3874    }
3875    let mut shape = XmlElement::new(prefixes.presentation("sp"))
3876        .with_child(non_visual)
3877        .with_child(properties);
3878    if let Some(paragraphs) = &add.paragraphs {
3879        let mut body = XmlElement::new(prefixes.presentation("txBody"))
3880            .with_child(XmlElement::new(prefixes.drawing("bodyPr")))
3881            .with_child(XmlElement::new(prefixes.drawing("lstStyle")));
3882        for paragraph in paragraphs {
3883            body = body.with_child(build_paragraph(paragraph, None, None, prefixes));
3884        }
3885        if paragraphs.is_empty() {
3886            body = body.with_child(XmlElement::new(prefixes.drawing("p")));
3887        }
3888        shape = shape.with_child(body);
3889    }
3890    Ok(shape)
3891}
3892
3893/// A new shape, or a picture that also needs a media part and relationship.
3894fn add_shape_element(
3895    add: &ShapeAdd,
3896    next_shape_id: &mut Option<u32>,
3897    prefixes: &Prefixes,
3898    part: &str,
3899    sink: &mut PartSink<'_>,
3900    budget: &mut ParseBudget<'_>,
3901) -> Result<XmlElement, PptxError> {
3902    match &add.picture {
3903        Some(picture) => {
3904            picture_shape_element(add, picture, next_shape_id, prefixes, part, sink, budget)
3905        }
3906        None => shape_element(add, next_shape_id, prefixes, part),
3907    }
3908}
3909
3910fn image_content_type_extension(content_type: &str) -> Option<&'static str> {
3911    match content_type {
3912        "image/png" => Some("png"),
3913        "image/jpeg" | "image/jpg" => Some("jpeg"),
3914        "image/gif" => Some("gif"),
3915        "image/bmp" => Some("bmp"),
3916        "image/tiff" => Some("tiff"),
3917        "image/webp" => Some("webp"),
3918        "image/svg+xml" => Some("svg"),
3919        _ => None,
3920    }
3921}
3922
3923fn image_extension(content_type: &str) -> Result<&'static str, PptxError> {
3924    image_content_type_extension(content_type)
3925        .ok_or_else(|| write_error("media", format!("unsupported image type {content_type:?}")))
3926}
3927
3928/// Whether [`write_pptx_with_edits`] can mint a media part for this MIME type.
3929pub fn is_supported_image_content_type(content_type: &str) -> bool {
3930    image_content_type_extension(content_type).is_some()
3931}
3932
3933fn next_media_part_path(
3934    package: &PptxPackage,
3935    new_parts: &[(String, Vec<u8>)],
3936    extension: &str,
3937) -> String {
3938    let existing: HashSet<&str> = package
3939        .parts
3940        .iter()
3941        .map(|part| part.path.as_str())
3942        .chain(new_parts.iter().map(|(path, _)| path.as_str()))
3943        .collect();
3944    let mut number = 1_u64;
3945    while existing.contains(format!("ppt/media/image{number}.{extension}").as_str()) {
3946        number += 1;
3947    }
3948    format!("ppt/media/image{number}.{extension}")
3949}
3950
3951/// Registers the extension as a package-wide default, unless already declared.
3952fn ensure_media_content_type(
3953    sink: &mut PartSink<'_>,
3954    extension: &str,
3955    part_path: &str,
3956    content_type: &str,
3957    budget: &mut ParseBudget<'_>,
3958) -> Result<(), PptxError> {
3959    let path = "[Content_Types].xml";
3960    let bytes = sink
3961        .current(path)
3962        .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
3963    let mut root = parse_xml(&bytes, path, budget)?;
3964    let default = root.children.iter().find_map(|child| match child {
3965        XmlNode::Element(element)
3966            if element.local_name() == "Default"
3967                && element
3968                    .attribute("Extension")
3969                    .is_some_and(|value| value.eq_ignore_ascii_case(extension)) =>
3970        {
3971            Some(element)
3972        }
3973        _ => None,
3974    });
3975    if let Some(default) = default {
3976        if default.attribute("ContentType") != Some(content_type) {
3977            set_content_type_override(sink, part_path, content_type, budget)?;
3978        }
3979    } else {
3980        let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
3981        root.children.push(XmlNode::Element(
3982            XmlElement::new(qualified(prefix, "Default"))
3983                .with_attribute("Extension", extension)
3984                .with_attribute("ContentType", content_type),
3985        ));
3986        sink.store(path, serialize_xml(&root));
3987    }
3988    Ok(())
3989}
3990
3991/// Unlike [`set_relationship`], always mints a fresh one rather than
3992/// replacing an existing match, since a slide can carry many images.
3993fn add_relationship(
3994    sink: &mut PartSink<'_>,
3995    relationships_path: &str,
3996    relationship_type: &str,
3997    target: &str,
3998    budget: &mut ParseBudget<'_>,
3999) -> Result<String, PptxError> {
4000    let mut root = match sink.current(relationships_path) {
4001        Some(bytes) => parse_xml(&bytes, relationships_path, budget)?,
4002        None => XmlElement::new("Relationships").with_attribute("xmlns", PACKAGE_RELATIONSHIPS_NS),
4003    };
4004    let used: HashSet<&str> = root
4005        .children_named("Relationship")
4006        .filter_map(|element| element.attribute("Id"))
4007        .collect();
4008    let mut number = 1_u64;
4009    while used.contains(format!("rId{number}").as_str()) {
4010        number += 1;
4011    }
4012    let id = format!("rId{number}");
4013    let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
4014    root.children.push(XmlNode::Element(
4015        XmlElement::new(qualified(prefix, "Relationship"))
4016            .with_attribute("Id", id.clone())
4017            .with_attribute("Type", relationship_type)
4018            .with_attribute("Target", target),
4019    ));
4020    sink.store(relationships_path, serialize_xml(&root));
4021    Ok(id)
4022}
4023
4024fn picture_shape_element(
4025    add: &ShapeAdd,
4026    picture: &PictureAdd,
4027    next_shape_id: &mut Option<u32>,
4028    prefixes: &Prefixes,
4029    slide_part_path: &str,
4030    sink: &mut PartSink<'_>,
4031    budget: &mut ParseBudget<'_>,
4032) -> Result<XmlElement, PptxError> {
4033    let extension = image_extension(&picture.content_type)?;
4034    let media_part_path = next_media_part_path(sink.package, sink.new_parts.as_slice(), extension);
4035    sink.store(&media_part_path, picture.media_bytes.clone());
4036    ensure_media_content_type(
4037        sink,
4038        extension,
4039        &media_part_path,
4040        &picture.content_type,
4041        budget,
4042    )?;
4043    let relationships_path = slide_relationships_path(slide_part_path);
4044    let target = relative_target(slide_part_path, &media_part_path);
4045    let relationship_id = add_relationship(
4046        sink,
4047        &relationships_path,
4048        IMAGE_RELATIONSHIP_TYPE,
4049        &target,
4050        budget,
4051    )?;
4052
4053    let shape_id = alloc_shape_id(next_shape_id, slide_part_path)?;
4054    let non_visual = XmlElement::new(prefixes.presentation("nvPicPr"))
4055        .with_child(
4056            XmlElement::new(prefixes.presentation("cNvPr"))
4057                .with_attribute("id", shape_id.to_string())
4058                .with_attribute("name", add.name.clone()),
4059        )
4060        .with_child(
4061            XmlElement::new(prefixes.presentation("cNvPicPr")).with_child(
4062                XmlElement::new(prefixes.drawing("picLocks")).with_attribute("noChangeAspect", "1"),
4063            ),
4064        )
4065        .with_child(XmlElement::new(prefixes.presentation("nvPr")));
4066    let blip_fill = XmlElement::new(prefixes.presentation("blipFill"))
4067        .with_child(
4068            XmlElement::new(prefixes.drawing("blip"))
4069                .with_attribute(prefixes.relationship("embed"), relationship_id),
4070        )
4071        .with_child(
4072            XmlElement::new(prefixes.drawing("stretch"))
4073                .with_child(XmlElement::new(prefixes.drawing("fillRect"))),
4074        );
4075    let transform = XmlElement::new(prefixes.drawing("xfrm"))
4076        .with_child(
4077            XmlElement::new(prefixes.drawing("off"))
4078                .with_attribute("x", add.x.to_string())
4079                .with_attribute("y", add.y.to_string()),
4080        )
4081        .with_child(
4082            XmlElement::new(prefixes.drawing("ext"))
4083                .with_attribute("cx", add.width.to_string())
4084                .with_attribute("cy", add.height.to_string()),
4085        );
4086    let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
4087        .with_attribute("prst", "rect")
4088        .with_child(XmlElement::new(prefixes.drawing("avLst")));
4089    let properties = XmlElement::new(prefixes.presentation("spPr"))
4090        .with_child(transform)
4091        .with_child(geometry);
4092    Ok(XmlElement::new(prefixes.presentation("pic"))
4093        .with_child(non_visual)
4094        .with_child(blip_fill)
4095        .with_child(properties))
4096}
4097
4098fn slide_xml(
4099    name: Option<&str>,
4100    shapes: &[ShapeAdd],
4101    background: Option<&ShapeFill>,
4102    part: &str,
4103    sink: &mut PartSink<'_>,
4104    budget: &mut ParseBudget<'_>,
4105) -> Result<Vec<u8>, PptxError> {
4106    let mut root = XmlElement::new("p:sld")
4107        .with_attribute("xmlns:a", DRAWINGML_NS)
4108        .with_attribute("xmlns:r", OFFICE_RELATIONSHIPS_NS)
4109        .with_attribute("xmlns:p", PRESENTATIONML_NS);
4110    let prefixes = Prefixes {
4111        drawing: "a".to_owned(),
4112        presentation: "p".to_owned(),
4113        relationship: "r".to_owned(),
4114    };
4115    let group_transform = XmlElement::new(prefixes.drawing("xfrm"))
4116        .with_child(
4117            XmlElement::new(prefixes.drawing("off"))
4118                .with_attribute("x", "0")
4119                .with_attribute("y", "0"),
4120        )
4121        .with_child(
4122            XmlElement::new(prefixes.drawing("ext"))
4123                .with_attribute("cx", "0")
4124                .with_attribute("cy", "0"),
4125        )
4126        .with_child(
4127            XmlElement::new(prefixes.drawing("chOff"))
4128                .with_attribute("x", "0")
4129                .with_attribute("y", "0"),
4130        )
4131        .with_child(
4132            XmlElement::new(prefixes.drawing("chExt"))
4133                .with_attribute("cx", "0")
4134                .with_attribute("cy", "0"),
4135        );
4136    let mut tree = XmlElement::new(prefixes.presentation("spTree"))
4137        .with_child(
4138            XmlElement::new(prefixes.presentation("nvGrpSpPr"))
4139                .with_child(
4140                    XmlElement::new(prefixes.presentation("cNvPr"))
4141                        .with_attribute("id", "1")
4142                        .with_attribute("name", ""),
4143                )
4144                .with_child(XmlElement::new(prefixes.presentation("cNvGrpSpPr")))
4145                .with_child(XmlElement::new(prefixes.presentation("nvPr"))),
4146        )
4147        .with_child(XmlElement::new(prefixes.presentation("grpSpPr")).with_child(group_transform));
4148    let mut next_shape_id = Some(2);
4149    for shape in shapes {
4150        tree = tree.with_child(add_shape_element(
4151            shape,
4152            &mut next_shape_id,
4153            &prefixes,
4154            part,
4155            sink,
4156            budget,
4157        )?);
4158    }
4159    let mut common = XmlElement::new(prefixes.presentation("cSld"));
4160    if let Some(name) = name {
4161        common.set_attribute("name", name);
4162    }
4163    if let Some(fill) = background {
4164        common = common.with_child(background_element(fill, &prefixes, part)?);
4165    }
4166    root = root.with_child(common.with_child(tree)).with_child(
4167        XmlElement::new(prefixes.presentation("clrMapOvr"))
4168            .with_child(XmlElement::new(prefixes.drawing("masterClrMapping"))),
4169    );
4170    Ok(serialize_xml(&root))
4171}
4172
4173fn slide_relationships_xml(part_path: &str, layout_part_path: &str) -> Vec<u8> {
4174    let root = XmlElement::new("Relationships")
4175        .with_attribute(
4176            "xmlns",
4177            "http://schemas.openxmlformats.org/package/2006/relationships",
4178        )
4179        .with_child(
4180            XmlElement::new("Relationship")
4181                .with_attribute("Id", "rId1")
4182                .with_attribute("Type", SLIDE_LAYOUT_RELATIONSHIP_TYPE)
4183                .with_attribute("Target", relative_target(part_path, layout_part_path)),
4184        );
4185    serialize_xml(&root)
4186}
4187
4188#[cfg(test)]
4189mod tests {
4190    use super::*;
4191    use crate::ParseLimits;
4192    use crate::drawing::parse_run_properties;
4193    use crate::model::ShapeElements;
4194    use crate::xml::{ParseBudget, parse_xml};
4195
4196    #[test]
4197    fn a_labelled_ellipse_is_not_written_as_a_text_box() {
4198        let add = ShapeAdd {
4199            name: "Labelled ellipse".to_owned(),
4200            geometry: "ellipse".to_owned(),
4201            text_box: false,
4202            x: 0,
4203            y: 0,
4204            width: 1_000_000,
4205            height: 500_000,
4206            adjust_values: BTreeMap::new(),
4207            fill: None,
4208            outline: None,
4209            paragraphs: Some(vec![ParagraphWrite {
4210                source_paragraphs: Vec::new(),
4211                source_index: None,
4212                preserved_xml: None,
4213                rebuild: true,
4214                properties_changed: true,
4215                alignment: None,
4216                level: 0,
4217                bullet: None,
4218                runs: vec![RunWrite {
4219                    text: "Ellipse label".to_owned(),
4220                    properties: RunProperties::default(),
4221                }],
4222            }]),
4223            picture: None,
4224        };
4225        let mut root = XmlElement::new("p:sld");
4226        let prefixes = Prefixes::from_root(&mut root);
4227        let shape = shape_element(&add, &mut Some(2), &prefixes, "ppt/slides/slide1.xml").unwrap();
4228        assert!(shape.child("txBody").unwrap().child("p").is_some());
4229        assert_eq!(
4230            shape
4231                .child("spPr")
4232                .unwrap()
4233                .child("prstGeom")
4234                .unwrap()
4235                .attribute("prst"),
4236            Some("ellipse")
4237        );
4238        assert_eq!(
4239            shape
4240                .child("nvSpPr")
4241                .unwrap()
4242                .child("cNvSpPr")
4243                .unwrap()
4244                .attribute("txBox"),
4245            None
4246        );
4247    }
4248
4249    #[test]
4250    fn changing_outline_width_preserves_unchanged_color_source() {
4251        let limits = ParseLimits::default();
4252        let mut properties = parse_xml(
4253            br#"<p:spPr><a:ln w="9525"><a:solidFill><a:schemeClr val="accent1"><a:tint val="50%"/><a:lumMod val="60000"/><a:extLst/></a:schemeClr></a:solidFill></a:ln></p:spPr>"#,
4254            "shape.xml",
4255            &mut ParseBudget::new(&limits),
4256        )
4257        .unwrap();
4258        let original_color = properties
4259            .child("ln")
4260            .unwrap()
4261            .child("solidFill")
4262            .unwrap()
4263            .clone();
4264        let mut outline =
4265            crate::drawing::parse_outline_element(properties.child("ln").unwrap()).unwrap();
4266        outline.width = Some(19050.0);
4267        let prefixes = Prefixes::from_root(&mut properties);
4268        set_outline(&mut properties, &outline, &prefixes);
4269        let line = properties.child("ln").unwrap();
4270        assert_eq!(line.attribute("w"), Some("19050"));
4271        assert_eq!(line.child("solidFill"), Some(&original_color));
4272    }
4273
4274    #[test]
4275    fn rebuilt_color_preserves_transform_order_and_exact_percentages() {
4276        let limits = ParseLimits::default();
4277        let source = parse_xml(
4278            br#"<a:solidFill><a:srgbClr val="4472C4"><a:tint val="50000"/><a:lumMod val="75000"/><a:shade val="12345"/><a:shade val="60000"/><a:hueOff val="-5400000"/><a:comp/><a:alpha val="50000"/></a:srgbClr></a:solidFill>"#,
4279            "color.xml",
4280            &mut ParseBudget::new(&limits),
4281        )
4282        .unwrap();
4283        let color = crate::drawing::parse_color_container(&source).unwrap();
4284        let mut properties = XmlElement::new("p:spPr");
4285        let prefixes = Prefixes::from_root(&mut properties);
4286        let written = color_element(&color, &prefixes).unwrap();
4287        assert_eq!(
4288            written
4289                .child_elements()
4290                .map(|child| (child.local_name(), child.attribute("val")))
4291                .collect::<Vec<_>>(),
4292            vec![
4293                ("tint", Some("50000")),
4294                ("lumMod", Some("75000")),
4295                ("shade", Some("12345")),
4296                ("shade", Some("60000")),
4297                ("hueOff", Some("-5400000")),
4298                ("comp", None),
4299                ("alpha", Some("50000")),
4300            ]
4301        );
4302        assert_eq!(
4303            crate::drawing::parse_color_container(
4304                &XmlElement::new("a:solidFill").with_child(written)
4305            ),
4306            Some(color)
4307        );
4308    }
4309
4310    #[test]
4311    fn mixed_color_alpha_survives_serialization() {
4312        use ooxml_drawingml::{ColorTransform, resolve_color_value_to_rgba_hex};
4313
4314        for step in [None, Some(ColorTransform::AlphaMod(0.5))] {
4315            let color = ColorValue {
4316                rgb: Some("112233".to_owned()),
4317                theme_tint: Some("00".to_owned()),
4318                alpha: Some(0.25),
4319                transforms: std::iter::once(ColorTransform::Tint(1.0))
4320                    .chain(step)
4321                    .collect(),
4322                ..Default::default()
4323            };
4324            let mut properties = XmlElement::new("p:spPr");
4325            let prefixes = Prefixes::from_root(&mut properties);
4326            let written = color_element(&color, &prefixes).unwrap();
4327            assert_eq!(written.child("alpha").is_some(), step.is_none());
4328            assert_eq!(written.child_elements().count(), 2);
4329            let reopened = crate::drawing::parse_color_container(
4330                &XmlElement::new("a:solidFill").with_child(written),
4331            )
4332            .unwrap();
4333            let expected = if step.is_none() {
4334                "#11223340"
4335            } else {
4336                "#11223380"
4337            };
4338            assert_eq!(
4339                resolve_color_value_to_rgba_hex(Some(&reopened), None).as_deref(),
4340                Some(expected)
4341            );
4342        }
4343    }
4344
4345    #[test]
4346    fn a_new_gradient_outline_writes_its_stops_and_angle() {
4347        let package =
4348            crate::parse_pptx(include_bytes!("../tests/fixtures/gradient-outline.pptx")).unwrap();
4349        let crate::ShapeNode::Shape(shape) = &package.slides[0].shapes[0] else {
4350            panic!("shape")
4351        };
4352        let outline = shape.outline.as_ref().unwrap();
4353        let mut properties = XmlElement::new("p:spPr");
4354        let prefixes = Prefixes::from_root(&mut properties);
4355        set_outline(&mut properties, outline, &prefixes);
4356        let line = properties.child("ln").unwrap();
4357        assert_eq!(line.attribute("w"), Some("76200"));
4358        let gradient = line.child("gradFill").unwrap();
4359        assert_eq!(gradient.child("gsLst").unwrap().child_elements().count(), 3);
4360        assert_eq!(gradient.child("lin").unwrap().attribute("ang"), Some("0"));
4361        assert!(line.child("solidFill").is_none());
4362        assert_eq!(
4363            crate::drawing::parse_outline_element(line).as_ref(),
4364            Some(outline)
4365        );
4366    }
4367
4368    #[test]
4369    fn source_ordinals_cover_the_shapes_an_alternate_content_contributes() {
4370        let limits = ParseLimits::default();
4371        let mut budget = ParseBudget::new(&limits);
4372        let part = "ppt/slides/slide1.xml";
4373        let mut root = parse_xml(
4374            br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp><mc:AlternateContent><mc:Choice xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" Requires="p14"><p:sp><p:nvSpPr><p:cNvPr id="3" name="choice"/></p:nvSpPr></p:sp></mc:Choice><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="4" name="two"/></p:nvSpPr></p:sp><p:pic><p:nvPicPr><p:cNvPr id="5" name="three"/></p:nvPicPr></p:pic></mc:Fallback></mc:AlternateContent><p:sp><p:nvSpPr><p:cNvPr id="6" name="four"/></p:nvSpPr></p:sp></p:spTree></p:cSld></p:sld>"#,
4375            part,
4376            &mut budget,
4377        )
4378        .unwrap();
4379        let parsed = crate::drawing::common_slide_data(
4380            &root,
4381            &[],
4382            part,
4383            &mut budget,
4384            ShapeElements::WithConnectors,
4385        )
4386        .unwrap();
4387        let tree = root
4388            .child_mut("cSld")
4389            .and_then(|common| common.child_mut("spTree"))
4390            .unwrap();
4391        let slots: Vec<Option<XmlNode>> = std::mem::take(&mut tree.children)
4392            .into_iter()
4393            .map(Some)
4394            .collect();
4395
4396        let found = shape_slots(&slots, ShapeElements::WithConnectors);
4397
4398        assert_eq!(found.len(), parsed.shapes.len());
4399        let names: Vec<String> = found
4400            .iter()
4401            .map(|slot| {
4402                let mut node = slots[slot.position].clone().unwrap();
4403                let element = element_at_path(&mut node, &slot.path).unwrap();
4404                element.descendants_named("cNvPr")[0]
4405                    .attribute("name")
4406                    .unwrap()
4407                    .to_owned()
4408            })
4409            .collect();
4410        assert_eq!(names, ["one", "two", "three", "four"]);
4411    }
4412
4413    #[test]
4414    fn deleting_one_of_the_shapes_a_branch_holds_leaves_the_others_in_place() {
4415        let limits = ParseLimits::default();
4416        let mut budget = ParseBudget::new(&limits);
4417        let part = "ppt/slides/slide1.xml";
4418        let mut root = parse_xml(
4419            br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr><p:spPr/></p:sp><mc:AlternateContent><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="3" name="two"/></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="4" name="three"/></p:nvSpPr><p:spPr/></p:sp></mc:Fallback></mc:AlternateContent></p:spTree></p:cSld></p:sld>"#,
4420            part,
4421            &mut budget,
4422        )
4423        .unwrap();
4424
4425        let package = PptxPackage::default();
4426        let mut replacements = HashMap::new();
4427        let mut new_parts = Vec::new();
4428        let mut sink = PartSink {
4429            package: &package,
4430            replacements: &mut replacements,
4431            new_parts: &mut new_parts,
4432        };
4433        patch_slide(
4434            &mut root,
4435            &[
4436                ShapeWrite::Keep { source_index: 0 },
4437                ShapeWrite::Patch {
4438                    source_index: 2,
4439                    patch: Box::new(ShapePatch {
4440                        fill: Some(ShapeFill {
4441                            color: Some(ColorValue::from_attribute("DC2626")),
4442                            ..ShapeFill::named("solid")
4443                        }),
4444                        ..ShapePatch::default()
4445                    }),
4446                },
4447            ],
4448            None,
4449            None,
4450            part,
4451            ShapeElements::WithConnectors,
4452            &mut sink,
4453            &mut budget,
4454        )
4455        .unwrap();
4456
4457        let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4458        assert!(xml.contains(r#"name="one""#));
4459        assert!(!xml.contains(r#"name="two""#), "{xml}");
4460        let survivor = xml.split(r#"name="three""#).nth(1).unwrap();
4461        assert!(survivor.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
4462    }
4463
4464    fn patch_background_fixture(xml: &[u8], background: Option<&BackgroundWrite>) -> String {
4465        let limits = ParseLimits::default();
4466        let mut budget = ParseBudget::new(&limits);
4467        let part = "ppt/slides/slide1.xml";
4468        let mut root = parse_xml(xml, part, &mut budget).unwrap();
4469        let package = PptxPackage::default();
4470        let mut replacements = HashMap::new();
4471        let mut new_parts = Vec::new();
4472        let mut sink = PartSink {
4473            package: &package,
4474            replacements: &mut replacements,
4475            new_parts: &mut new_parts,
4476        };
4477        patch_slide(
4478            &mut root,
4479            &[ShapeWrite::Keep { source_index: 0 }],
4480            background,
4481            None,
4482            part,
4483            ShapeElements::WithConnectors,
4484            &mut sink,
4485            &mut budget,
4486        )
4487        .unwrap();
4488        String::from_utf8(serialize_xml(&root)).unwrap()
4489    }
4490
4491    #[test]
4492    fn a_new_background_precedes_the_shape_tree() {
4493        let xml = patch_background_fixture(
4494            br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp></p:spTree></p:cSld></p:sld>"#,
4495            Some(&BackgroundWrite::Set(Box::new(ShapeFill {
4496                color: Some(ColorValue::from_attribute("0F1720")),
4497                ..ShapeFill::named("solid")
4498            }))),
4499        );
4500        let common = xml.find("<p:cSld>").unwrap();
4501        let bg = xml.find("<p:bg>").unwrap();
4502        let effects = xml.find("<a:effectLst/>").unwrap();
4503        let tree = xml.find("<p:spTree>").unwrap();
4504        assert!(common < bg && bg < effects && effects < tree, "{xml}");
4505        assert!(xml.contains(r#"<a:srgbClr val="0F1720"/>"#), "{xml}");
4506    }
4507
4508    #[test]
4509    fn clearing_a_background_removes_only_the_declaration() {
4510        let xml = patch_background_fixture(
4511            br#"<p:sld><p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="0F1720"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp></p:spTree></p:cSld></p:sld>"#,
4512            Some(&BackgroundWrite::Clear),
4513        );
4514        assert!(!xml.contains("<p:bg>"), "{xml}");
4515        assert!(xml.contains(r#"name="one""#), "{xml}");
4516    }
4517
4518    #[test]
4519    fn an_untouched_background_survives_a_shape_patch() {
4520        let limits = ParseLimits::default();
4521        let mut budget = ParseBudget::new(&limits);
4522        let part = "ppt/slides/slide1.xml";
4523        let mut root = parse_xml(
4524            br#"<p:sld><p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="0F1720"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr><p:spPr/></p:sp></p:spTree></p:cSld></p:sld>"#,
4525            part,
4526            &mut budget,
4527        )
4528        .unwrap();
4529        let package = PptxPackage::default();
4530        let mut replacements = HashMap::new();
4531        let mut new_parts = Vec::new();
4532        let mut sink = PartSink {
4533            package: &package,
4534            replacements: &mut replacements,
4535            new_parts: &mut new_parts,
4536        };
4537        patch_slide(
4538            &mut root,
4539            &[ShapeWrite::Patch {
4540                source_index: 0,
4541                patch: Box::new(ShapePatch {
4542                    fill: Some(ShapeFill {
4543                        color: Some(ColorValue::from_attribute("DC2626")),
4544                        ..ShapeFill::named("solid")
4545                    }),
4546                    ..ShapePatch::default()
4547                }),
4548            }],
4549            None,
4550            None,
4551            part,
4552            ShapeElements::WithConnectors,
4553            &mut sink,
4554            &mut budget,
4555        )
4556        .unwrap();
4557
4558        let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4559        assert!(xml.contains(r#"<a:srgbClr val="0F1720"/>"#), "{xml}");
4560        assert!(xml.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
4561    }
4562
4563    #[test]
4564    fn a_patched_use_bg_fill_shape_keeps_the_attribute() {
4565        let limits = ParseLimits::default();
4566        let mut budget = ParseBudget::new(&limits);
4567        let part = "ppt/slides/slide1.xml";
4568        let mut root = parse_xml(
4569            br#"<p:sld><p:cSld><p:spTree><p:sp useBgFill="1"><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr><p:spPr/></p:sp></p:spTree></p:cSld></p:sld>"#,
4570            part,
4571            &mut budget,
4572        )
4573        .unwrap();
4574        let package = PptxPackage::default();
4575        let mut replacements = HashMap::new();
4576        let mut new_parts = Vec::new();
4577        let mut sink = PartSink {
4578            package: &package,
4579            replacements: &mut replacements,
4580            new_parts: &mut new_parts,
4581        };
4582        patch_slide(
4583            &mut root,
4584            &[ShapeWrite::Patch {
4585                source_index: 0,
4586                patch: Box::new(ShapePatch {
4587                    fill: Some(ShapeFill {
4588                        color: Some(ColorValue::from_attribute("DC2626")),
4589                        ..ShapeFill::named("solid")
4590                    }),
4591                    ..ShapePatch::default()
4592                }),
4593            }],
4594            None,
4595            None,
4596            part,
4597            ShapeElements::WithConnectors,
4598            &mut sink,
4599            &mut budget,
4600        )
4601        .unwrap();
4602
4603        let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4604        assert!(xml.contains(r#"useBgFill="1""#), "{xml}");
4605        assert!(xml.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
4606    }
4607
4608    #[test]
4609    fn an_added_slide_background_precedes_the_shape_tree() {
4610        let package = PptxPackage::default();
4611        let mut replacements = HashMap::new();
4612        let mut new_parts = Vec::new();
4613        let mut sink = PartSink {
4614            package: &package,
4615            replacements: &mut replacements,
4616            new_parts: &mut new_parts,
4617        };
4618        let limits = ParseLimits::default();
4619        let mut budget = ParseBudget::new(&limits);
4620        let fill = ShapeFill {
4621            color: Some(ColorValue::from_attribute("F8FAFC")),
4622            ..ShapeFill::named("solid")
4623        };
4624        let xml = slide_xml(
4625            None,
4626            &[],
4627            Some(&fill),
4628            "ppt/slides/slide9.xml",
4629            &mut sink,
4630            &mut budget,
4631        )
4632        .unwrap();
4633        let xml = String::from_utf8(xml).unwrap();
4634        let common = xml.find("<p:cSld>").unwrap();
4635        let bg = xml.find("<p:bg>").unwrap();
4636        let tree = xml.find("<p:spTree>").unwrap();
4637        assert!(common < bg && bg < tree, "{xml}");
4638        assert!(xml.contains(r#"<a:srgbClr val="F8FAFC"/>"#), "{xml}");
4639    }
4640
4641    #[test]
4642    fn a_branch_that_still_holds_unmodeled_markup_outlives_its_only_shape() {
4643        let limits = ParseLimits::default();
4644        let mut budget = ParseBudget::new(&limits);
4645        let part = "ppt/slides/slide1.xml";
4646        let mut root = parse_xml(
4647            br#"<p:sld><p:cSld><p:spTree><mc:AlternateContent><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp><p:cxnSp><p:nvCxnSpPr><p:cNvPr id="3" name="two"/></p:nvCxnSpPr></p:cxnSp></mc:Fallback></mc:AlternateContent></p:spTree></p:cSld></p:sld>"#,
4648            part,
4649            &mut budget,
4650        )
4651        .unwrap();
4652
4653        let package = PptxPackage::default();
4654        let mut replacements = HashMap::new();
4655        let mut new_parts = Vec::new();
4656        let mut sink = PartSink {
4657            package: &package,
4658            replacements: &mut replacements,
4659            new_parts: &mut new_parts,
4660        };
4661        patch_slide(
4662            &mut root,
4663            &[],
4664            None,
4665            None,
4666            part,
4667            ShapeElements::WithoutConnectors,
4668            &mut sink,
4669            &mut budget,
4670        )
4671        .unwrap();
4672
4673        let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4674        assert!(!xml.contains(r#"name="one""#), "{xml}");
4675        assert!(xml.contains(r#"name="two""#), "{xml}");
4676    }
4677
4678    fn run_properties(xml: &[u8]) -> (XmlElement, Prefixes, RunProperties) {
4679        let limits = ParseLimits::default();
4680        let mut budget = ParseBudget::new(&limits);
4681        let mut root = parse_xml(xml, "ppt/slides/slide1.xml", &mut budget).unwrap();
4682        let prefixes = Prefixes::from_root(&mut root);
4683        let properties = parse_run_properties(Some(&root));
4684        (root, prefixes, properties)
4685    }
4686
4687    #[test]
4688    fn an_unedited_gradient_fill_survives_a_rewrite() {
4689        let (mut element, prefixes, properties) = run_properties(
4690            br#"<a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en"><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst></a:gradFill></a:rPr>"#,
4691        );
4692        assert!(
4693            properties.color.is_some(),
4694            "the gradient should flatten to a colour"
4695        );
4696        let gradient = element.child("gradFill").unwrap().clone();
4697        apply_run_properties(&mut element, &properties, None, &prefixes);
4698        assert_eq!(element.child("gradFill"), Some(&gradient));
4699        let serialized = String::from_utf8(serialize_xml(&element)).unwrap();
4700        assert!(
4701            serialized.contains("gradFill"),
4702            "the authored gradient must be left alone"
4703        );
4704        assert!(
4705            !serialized.contains("solidFill"),
4706            "it must not be rewritten as a solid fill"
4707        );
4708    }
4709
4710    #[test]
4711    fn a_lowercase_flattened_colour_keeps_the_gradient() {
4712        let (mut element, prefixes, mut properties) = run_properties(
4713            br#"<a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en"><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst></a:gradFill></a:rPr>"#,
4714        );
4715        properties.color = Some(ColorValue {
4716            rgb: Some("ff0000".to_owned()),
4717            ..ColorValue::default()
4718        });
4719        let gradient = element.child("gradFill").unwrap().clone();
4720        apply_run_properties(&mut element, &properties, None, &prefixes);
4721        assert_eq!(element.child("gradFill"), Some(&gradient));
4722    }
4723
4724    #[test]
4725    fn an_edited_colour_replaces_the_gradient() {
4726        let (mut element, prefixes, mut properties) = run_properties(
4727            br#"<a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en"><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst></a:gradFill></a:rPr>"#,
4728        );
4729        properties.color = Some(ColorValue {
4730            rgb: Some("FF0000".to_owned()),
4731            ..ColorValue::default()
4732        });
4733        apply_run_properties(&mut element, &properties, None, &prefixes);
4734        let serialized = String::from_utf8(serialize_xml(&element)).unwrap();
4735        assert!(!serialized.contains("gradFill"));
4736        assert!(serialized.contains("solidFill"));
4737        assert_eq!(parse_run_properties(Some(&element)).color, properties.color);
4738    }
4739
4740    #[test]
4741    fn the_writer_recognises_exactly_what_the_parser_reads() {
4742        for local in ["sp", "cxnSp", "pic", "graphicFrame", "grpSp"] {
4743            assert!(
4744                ShapeElements::WithConnectors.contains(local),
4745                "{local} is parsed as a shape but not recognised here"
4746            );
4747        }
4748        for local in ["txBody", "nvGrpSpPr", "extLst"] {
4749            assert!(
4750                !ShapeElements::WithConnectors.contains(local),
4751                "{local} is not a shape element"
4752            );
4753        }
4754    }
4755
4756    #[test]
4757    fn the_pre_connector_set_leaves_out_only_connectors() {
4758        assert!(!ShapeElements::WithoutConnectors.contains("cxnSp"));
4759        for local in ["sp", "pic", "graphicFrame", "grpSp"] {
4760            assert!(ShapeElements::WithoutConnectors.contains(local));
4761        }
4762    }
4763
4764    const LINKED_PARAGRAPH: &[u8] = br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:r><a:rPr lang="en-US" dirty="0"/><a:t>See </a:t></a:r><a:r><a:rPr lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr><a:t>the docs</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> today</a:t></a:r></a:p>"#;
4765    const LINK_PROPERTIES: &str =
4766        r#"<a:rPr lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr>"#;
4767    const FIELD_PARAGRAPH: &[u8] = br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t> of </a:t></a:r><a:fld id="{B}" type="datetime1"><a:rPr lang="en-US"/><a:t>2024</a:t></a:fld></a:p>"#;
4768
4769    fn rebuilt_paragraph(xml: &[u8], runs: &[(&str, RunProperties)]) -> String {
4770        let limits = ParseLimits::default();
4771        let mut budget = ParseBudget::new(&limits);
4772        let mut root = parse_xml(xml, "ppt/slides/slide1.xml", &mut budget).unwrap();
4773        let prefixes = Prefixes::from_root(&mut root);
4774        let write = ParagraphWrite {
4775            source_paragraphs: Vec::new(),
4776            source_index: Some(0),
4777            preserved_xml: None,
4778            rebuild: true,
4779            properties_changed: false,
4780            alignment: None,
4781            level: 0,
4782            bullet: None,
4783            runs: runs
4784                .iter()
4785                .map(|(text, properties)| RunWrite {
4786                    text: (*text).to_owned(),
4787                    properties: properties.clone(),
4788                })
4789                .collect(),
4790        };
4791        let paragraph = build_paragraph(&write, Some(root), None, &prefixes);
4792        String::from_utf8(serialize_xml(&paragraph)).unwrap()
4793    }
4794
4795    #[test]
4796    fn an_edit_spanning_several_runs_keeps_each_survivor_on_its_source_run() {
4797        let xml = rebuilt_paragraph(
4798            LINKED_PARAGRAPH,
4799            &[("Seethe doc now", RunProperties::default())],
4800        );
4801        assert!(
4802            xml.contains(r#"<a:r><a:rPr dirty="0" lang="en-US"/><a:t>See</a:t></a:r>"#),
4803            "{xml}"
4804        );
4805        assert!(
4806            xml.contains(&format!("<a:r>{LINK_PROPERTIES}<a:t>the doc</a:t></a:r>")),
4807            "{xml}"
4808        );
4809        assert!(
4810            xml.contains(r#"<a:r><a:rPr lang="en-US"/><a:t> now</a:t></a:r>"#),
4811            "{xml}"
4812        );
4813        assert_eq!(xml.matches("<a:r>").count(), 3, "{xml}");
4814    }
4815
4816    #[test]
4817    fn a_line_break_inside_the_span_keeps_the_runs_around_it_aligned() {
4818        let xml = rebuilt_paragraph(
4819            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:r><a:rPr lang="en-US"/><a:t>First</a:t></a:r><a:br/><a:r><a:rPr><a:hlinkClick r:id="rId2"/></a:rPr><a:t>Second</a:t></a:r></a:p>"#,
4820            &[("Firs\nSecond!", RunProperties::default())],
4821        );
4822        assert!(
4823            xml.contains(
4824                r#"<a:r><a:rPr lang="en-US"/><a:t>Firs</a:t></a:r><a:br/><a:r><a:rPr><a:hlinkClick r:id="rId2"/></a:rPr><a:t>Second!</a:t></a:r>"#
4825            ),
4826            "{xml}"
4827        );
4828    }
4829
4830    const FONT_PARAGRAPH: &str = r#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:pPr algn="ctr"/><a:r><a:rPr lang="en-US"><a:highlight><a:srgbClr val="FFFF00"/></a:highlight><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial" vendorHint="1"/><a:cs charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/></a:rPr><a:t>38.8%</a:t></a:r><a:r><a:rPr b="1" lang="en-US"><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/></a:rPr><a:t> of cases</a:t></a:r></a:p>"#;
4831    const ARIAL_LATIN: &str = r#"<a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial" vendorHint="1"/>"#;
4832
4833    fn font_runs(family: &str) -> [(&'static str, RunProperties); 2] {
4834        let arial = RunProperties {
4835            font_family: Some("Arial".to_owned()),
4836            ..RunProperties::default()
4837        };
4838        [
4839            (
4840                "39.8%",
4841                RunProperties {
4842                    font_family: Some(family.to_owned()),
4843                    ..RunProperties::default()
4844                },
4845            ),
4846            (
4847                " of cases",
4848                RunProperties {
4849                    bold: Some(true),
4850                    ..arial
4851                },
4852            ),
4853        ]
4854    }
4855
4856    fn edited_font_body(latin: &str) -> String {
4857        let (_, body) = FONT_PARAGRAPH.split_once('>').unwrap();
4858        let edited = body.replace("38.8%", "39.8%");
4859        edited.replacen(ARIAL_LATIN, latin, 1)
4860    }
4861
4862    #[test]
4863    fn a_text_edit_keeps_the_latin_font_element_whole() {
4864        let xml = rebuilt_paragraph(FONT_PARAGRAPH.as_bytes(), &font_runs("Arial"));
4865        assert!(xml.ends_with(&edited_font_body(ARIAL_LATIN)), "{xml}");
4866    }
4867
4868    #[test]
4869    fn a_new_font_family_drops_the_old_face_metrics_in_place() {
4870        let xml = rebuilt_paragraph(FONT_PARAGRAPH.as_bytes(), &font_runs("Calibri"));
4871        let calibri = edited_font_body(r#"<a:latin typeface="Calibri"/>"#);
4872        assert!(xml.ends_with(&calibri), "{xml}");
4873    }
4874
4875    const FILL_PARAGRAPH: &str = r#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent6"><a:lumMod val="75000"/></a:schemeClr></a:solidFill></a:rPr><a:t>amongst</a:t></a:r><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:rPr><a:t> them</a:t></a:r></a:p>"#;
4876
4877    /// Each run's colour flattened to hex, as the editor snapshot hands it back.
4878    fn hex_runs() -> Vec<(&'static str, RunProperties)> {
4879        let limits = ParseLimits::default();
4880        let mut budget = ParseBudget::new(&limits);
4881        let path = "ppt/slides/slide1.xml";
4882        let root = parse_xml(FILL_PARAGRAPH.as_bytes(), path, &mut budget).unwrap();
4883        root.child_elements()
4884            .zip(["among", " then"])
4885            .map(|(run, text)| {
4886                let color = parse_run_properties(run.child("rPr")).color;
4887                let hex = resolve_color_value_to_hex_with_theme(color.as_ref(), None);
4888                let color = ColorValue {
4889                    rgb: hex.map(|hex| hex[1..].to_owned()),
4890                    ..ColorValue::default()
4891                };
4892                let properties = RunProperties {
4893                    color: Some(color),
4894                    ..RunProperties::default()
4895                };
4896                (text, properties)
4897            })
4898            .collect()
4899    }
4900
4901    #[test]
4902    fn a_text_edit_keeps_theme_colours_and_their_transforms() {
4903        let xml = rebuilt_paragraph(FILL_PARAGRAPH.as_bytes(), &hex_runs());
4904        let (_, body) = FILL_PARAGRAPH.split_once('>').unwrap();
4905        let edited = body.replace("amongst", "among").replace(" them", " then");
4906        assert!(xml.ends_with(&edited), "{xml}");
4907    }
4908
4909    #[test]
4910    fn an_explicit_colour_edit_replaces_the_theme_fill() {
4911        let mut runs = hex_runs();
4912        runs[0].1.color = Some(ColorValue {
4913            rgb: Some("FF0000".to_owned()),
4914            ..ColorValue::default()
4915        });
4916        let xml = rebuilt_paragraph(FILL_PARAGRAPH.as_bytes(), &runs);
4917        let red = r#"<a:rPr lang="en-US"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:rPr><a:t>among</a:t>"#;
4918        assert!(xml.contains(red), "{xml}");
4919        assert!(xml.contains(r#"<a:schemeClr val="tx1"/>"#), "{xml}");
4920    }
4921
4922    #[test]
4923    fn an_alpha_change_on_an_unchanged_theme_colour_is_written() {
4924        let xml = rebuilt_paragraph(
4925            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="tx1"><a:alpha val="50000"/></a:schemeClr></a:solidFill></a:rPr><a:t>amongst</a:t></a:r></a:p>"#,
4926            &[(
4927                "among",
4928                RunProperties {
4929                    color: Some(ColorValue {
4930                        theme_color: Some("text1".to_owned()),
4931                        alpha: Some(1.0),
4932                        ..ColorValue::default()
4933                    }),
4934                    ..RunProperties::default()
4935                },
4936            )],
4937        );
4938        assert!(
4939            xml.contains(r#"<a:schemeClr val="tx1"><a:alpha val="100000"/></a:schemeClr>"#),
4940            "{xml}"
4941        );
4942        assert!(!xml.contains(r#"val="50000""#), "{xml}");
4943    }
4944
4945    #[test]
4946    fn switching_theme_slots_with_the_same_rgb_writes_the_new_slot() {
4947        let xml = rebuilt_paragraph(
4948            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:rPr><a:t>amongst</a:t></a:r></a:p>"#,
4949            &[(
4950                "among",
4951                RunProperties {
4952                    color: Some(ColorValue {
4953                        theme_color: Some("dk1".to_owned()),
4954                        ..ColorValue::default()
4955                    }),
4956                    ..RunProperties::default()
4957                },
4958            )],
4959        );
4960        assert!(xml.contains(r#"<a:schemeClr val="dk1"/>"#), "{xml}");
4961        assert!(!xml.contains(r#"<a:schemeClr val="tx1"/>"#), "{xml}");
4962    }
4963
4964    #[test]
4965    fn an_unchanged_transformed_theme_colour_survives_byte_identical() {
4966        let xml = rebuilt_paragraph(
4967            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill></a:rPr><a:t>amongst</a:t></a:r></a:p>"#,
4968            &[(
4969                "among",
4970                RunProperties {
4971                    color: Some(ColorValue {
4972                        rgb: Some("8FAADC".to_owned()),
4973                        ..ColorValue::default()
4974                    }),
4975                    ..RunProperties::default()
4976                },
4977            )],
4978        );
4979        assert!(
4980            xml.contains(
4981                r#"<a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill>"#
4982            ),
4983            "{xml}"
4984        );
4985        assert!(!xml.contains("8FAADC"), "{xml}");
4986    }
4987
4988    #[test]
4989    fn an_edit_spanning_a_styled_line_break_keeps_the_break_properties() {
4990        let xml = rebuilt_paragraph(
4991            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"/><a:t>First line</a:t></a:r><a:br><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/><a:ea typeface="MS Gothic"/><a:cs typeface="Arial"/></a:rPr></a:br><a:r><a:rPr lang="en-US"/><a:t>Second line</a:t></a:r></a:p>"#,
4992            &[(
4993                "First line changed\nSecond line changed",
4994                RunProperties {
4995                    color: Some(ColorValue {
4996                        rgb: Some("8FAADC".to_owned()),
4997                        ..ColorValue::default()
4998                    }),
4999                    font_family: Some("Arial".to_owned()),
5000                    ..RunProperties::default()
5001                },
5002            )],
5003        );
5004        assert!(
5005            xml.contains(
5006                r#"<a:br><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/><a:ea typeface="MS Gothic"/><a:cs typeface="Arial"/></a:rPr></a:br>"#
5007            ),
5008            "{xml}"
5009        );
5010    }
5011
5012    #[test]
5013    fn an_inserted_line_break_does_not_inherit_a_neighbouring_run_properties() {
5014        let xml = rebuilt_paragraph(
5015            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:r><a:rPr lang="en-US" strike="sngStrike"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill><a:hlinkClick r:id="rId2"/></a:rPr><a:t>HelloWorld</a:t></a:r></a:p>"#,
5016            &[("Hello\nWorld", RunProperties::default())],
5017        );
5018        assert!(xml.contains("<a:br/>"), "{xml}");
5019    }
5020
5021    #[test]
5022    fn a_field_survives_an_edit_around_it_and_degrades_once_its_text_changes() {
5023        let xml = rebuilt_paragraph(
5024            FIELD_PARAGRAPH,
5025            &[("1 out of 2025", RunProperties::default())],
5026        );
5027        assert!(
5028            xml.contains(
5029                r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t> out of </a:t></a:r><a:r><a:rPr lang="en-US"/><a:t>2025</a:t></a:r>"#
5030            ),
5031            "{xml}"
5032        );
5033        assert!(!xml.contains("datetime1"), "{xml}");
5034
5035        let xml = rebuilt_paragraph(FIELD_PARAGRAPH, &[("1X of 2024", RunProperties::default())]);
5036        assert!(
5037            xml.contains(
5038                r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t>X</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> of </a:t></a:r><a:fld id="{B}" type="datetime1"><a:rPr lang="en-US"/><a:t>2024</a:t></a:fld>"#
5039            ),
5040            "{xml}"
5041        );
5042    }
5043
5044    #[test]
5045    fn text_typed_at_the_end_of_a_link_stays_inside_it() {
5046        let xml = rebuilt_paragraph(
5047            LINKED_PARAGRAPH,
5048            &[("See the docsX today", RunProperties::default())],
5049        );
5050        assert!(
5051            xml.contains(&format!("<a:r>{LINK_PROPERTIES}<a:t>the docsX</a:t></a:r>")),
5052            "{xml}"
5053        );
5054
5055        let bold = RunProperties {
5056            bold: Some(true),
5057            ..RunProperties::default()
5058        };
5059        let xml = rebuilt_paragraph(
5060            LINKED_PARAGRAPH,
5061            &[
5062                ("See ", RunProperties::default()),
5063                ("the docs", RunProperties::default()),
5064                ("X", bold),
5065                (" today", RunProperties::default()),
5066            ],
5067        );
5068        assert!(
5069            xml.contains(&format!(
5070                r#"<a:r>{LINK_PROPERTIES}<a:t>the docs</a:t></a:r><a:r><a:rPr b="1" lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr><a:t>X</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> today</a:t></a:r>"#
5071            )),
5072            "{xml}"
5073        );
5074    }
5075
5076    #[test]
5077    fn deleting_a_linked_run_drops_its_link() {
5078        let xml = rebuilt_paragraph(
5079            LINKED_PARAGRAPH,
5080            &[("See  today", RunProperties::default())],
5081        );
5082        assert!(!xml.contains("hlinkClick"), "{xml}");
5083        assert!(
5084            xml.contains(
5085                r#"<a:r><a:rPr dirty="0" lang="en-US"/><a:t>See </a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> today</a:t></a:r>"#
5086            ),
5087            "{xml}"
5088        );
5089    }
5090
5091    #[test]
5092    fn a_plain_duplicate_at_the_front_does_not_steal_the_field() {
5093        let xml = rebuilt_paragraph(
5094            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r><a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld></a:p>"#,
5095            &[("1", RunProperties::default())],
5096        );
5097        assert!(
5098            xml.contains(
5099                r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld>"#
5100            ),
5101            "{xml}"
5102        );
5103        assert_eq!(xml.matches("<a:r>").count(), 0, "{xml}");
5104    }
5105
5106    #[test]
5107    fn a_plain_duplicate_at_the_back_does_not_steal_the_field() {
5108        let xml = rebuilt_paragraph(
5109            br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"/><a:t>X</a:t></a:r><a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r></a:p>"#,
5110            &[
5111                ("X", RunProperties::default()),
5112                ("1", RunProperties::default()),
5113            ],
5114        );
5115        assert!(
5116            xml.contains(
5117                r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld>"#
5118            ),
5119            "{xml}"
5120        );
5121        assert_eq!(xml.matches("<a:r>").count(), 1, "{xml}");
5122        assert!(
5123            !xml.contains(r#"<a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r>"#),
5124            "{xml}"
5125        );
5126    }
5127
5128    #[test]
5129    fn adjustments_without_preset_geometry_error_instead_of_no_opting() {
5130        let limits = ParseLimits::default();
5131        let mut budget = ParseBudget::new(&limits);
5132        let part = "ppt/slides/slide1.xml";
5133        let mut root = parse_xml(
5134            br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="custom"/></p:nvSpPr><p:spPr><a:custGeom><a:pathLst><a:path w="10" h="10"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:close/></a:path></a:pathLst></a:custGeom></p:spPr></p:sp></p:spTree></p:cSld></p:sld>"#,
5135            part,
5136            &mut budget,
5137        )
5138        .unwrap();
5139
5140        let package = PptxPackage::default();
5141        let mut replacements = HashMap::new();
5142        let mut new_parts = Vec::new();
5143        let mut sink = PartSink {
5144            package: &package,
5145            replacements: &mut replacements,
5146            new_parts: &mut new_parts,
5147        };
5148        let error = patch_slide(
5149            &mut root,
5150            &[ShapeWrite::Patch {
5151                source_index: 0,
5152                patch: Box::new(ShapePatch {
5153                    adjust_values: Some(BTreeMap::from([("adj".to_owned(), 0.25)])),
5154                    ..ShapePatch::default()
5155                }),
5156            }],
5157            None,
5158            None,
5159            part,
5160            ShapeElements::WithConnectors,
5161            &mut sink,
5162            &mut budget,
5163        )
5164        .unwrap_err();
5165        assert!(
5166            matches!(error, crate::PptxError::Write { ref message, .. } if message.contains("preset geometry")),
5167            "{error:?}"
5168        );
5169    }
5170}