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