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::{Bullet, PptxPackage, RunProperties, ShapeElements, SlideReference};
16use crate::xml::{
17    DRAWINGML_NS, PRESENTATIONML_NS, ParseBudget, ParseLimits, XmlElement, XmlNode,
18    alternate_content_branch_index, parse_xml, serialize_xml,
19};
20
21const OFFICE_RELATIONSHIPS_NS: &str =
22    "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
23const SLIDE_RELATIONSHIP_TYPE: &str =
24    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
25const SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
26    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
27const SLIDE_CONTENT_TYPE: &str =
28    "application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
29const IMAGE_RELATIONSHIP_TYPE: &str =
30    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
31const MIN_SLIDE_ID: u32 = 256;
32const MAX_SLIDE_ID: u32 = 2_147_483_647;
33
34/// The desired final deck, expressed against the parsed source package.
35pub struct DeckWrite {
36    pub slides: Vec<SlideWrite>,
37    pub comments: Option<CommentsWrite>,
38    pub notes: Option<NotesWrite>,
39}
40
41/// Changed speaker notes; empty text clears the body placeholder.
42pub struct NotesWrite {
43    pub per_slide: Vec<(CommentSlide, String)>,
44}
45
46const NOTES_SLIDE_RELATIONSHIP_TYPE: &str =
47    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
48
49pub enum SlideWrite {
50    /// Copy the source part through untouched.
51    Keep { part_path: String },
52    /// Patch the source part; `shapes` is the full top-level list in final order.
53    Patch {
54        part_path: String,
55        shapes: Vec<ShapeWrite>,
56    },
57    /// Mint a new slide part.
58    Add {
59        name: Option<String>,
60        layout_part_path: Option<String>,
61        shapes: Vec<ShapeAdd>,
62    },
63}
64
65pub enum ShapeWrite {
66    /// Keep the source shape element untouched. `source_index` counts shape
67    /// elements at the same nesting level, in document order.
68    Keep {
69        source_index: usize,
70    },
71    Patch {
72        source_index: usize,
73        patch: Box<ShapePatch>,
74    },
75    Add(Box<ShapeAdd>),
76}
77
78#[derive(Default)]
79pub struct ShapePatch {
80    /// The halves the edit changed. Transform pieces the source already
81    /// spells out and the edit did not touch are left as they are.
82    pub offset: Option<(i64, i64)>,
83    pub extent: Option<(i64, i64)>,
84    /// Fills in transform pieces the source never spelled out.
85    pub inherited: Option<InheritedTransform>,
86    /// The source spells out no transform of its own, so `inherited` also
87    /// sets the rotation and flips of an `a:xfrm` it already has.
88    pub materializes: bool,
89    pub fill: Option<ShapeFill>,
90    /// A default outline clears the stroke.
91    pub outline: Option<ShapeOutline>,
92    pub adjust_values: Option<BTreeMap<String, f64>>,
93    pub texts: Vec<TextWrite>,
94    /// Non-empty only when a group's child list must be rebuilt.
95    pub children: Vec<ShapeWrite>,
96}
97
98/// The transform a shape inherits from its layout or master placeholder.
99#[derive(Clone, Copy)]
100pub struct InheritedTransform {
101    pub x: i64,
102    pub y: i64,
103    pub width: i64,
104    pub height: i64,
105    pub rotation_deg: f64,
106    pub flip_horizontal: bool,
107    pub flip_vertical: bool,
108}
109
110pub struct TextWrite {
111    pub target: TextTarget,
112    pub paragraphs: Vec<ParagraphWrite>,
113}
114
115pub enum TextTarget {
116    Body,
117    TableCell { row: usize, cell: usize },
118}
119
120pub struct ParagraphWrite {
121    /// Index of the paragraph in the source text body, when it survives.
122    pub source_index: Option<usize>,
123    /// `false` keeps the source paragraph verbatim.
124    pub rebuild: bool,
125    pub properties_changed: bool,
126    pub alignment: Option<String>,
127    pub level: u32,
128    pub bullet: Option<Bullet>,
129    pub runs: Vec<RunWrite>,
130}
131
132pub struct RunWrite {
133    pub text: String,
134    pub properties: RunProperties,
135}
136
137pub struct ShapeAdd {
138    pub name: String,
139    pub geometry: String,
140    pub x: i64,
141    pub y: i64,
142    pub width: i64,
143    pub height: i64,
144    pub adjust_values: BTreeMap<String, f64>,
145    pub fill: Option<ShapeFill>,
146    pub outline: Option<ShapeOutline>,
147    pub paragraphs: Option<Vec<ParagraphWrite>>,
148    /// A picture instead of an autoshape; mints its own media part and relationship.
149    pub picture: Option<PictureAdd>,
150}
151
152pub struct PictureAdd {
153    pub media_bytes: Vec<u8>,
154    pub content_type: String,
155}
156
157/// Writes the package with `deck` applied. Slides marked [`SlideWrite::Keep`]
158/// and every non-slide part keep their exact source bytes.
159pub fn write_pptx_with_edits(
160    package: &PptxPackage,
161    deck: &DeckWrite,
162) -> Result<Vec<u8>, PptxError> {
163    let limits = ParseLimits::default();
164    let mut budget = ParseBudget::new(&limits);
165
166    let final_paths: HashSet<&str> = deck
167        .slides
168        .iter()
169        .filter_map(|slide| match slide {
170            SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
171                Some(part_path.as_str())
172            }
173            SlideWrite::Add { .. } => None,
174        })
175        .collect();
176    let removed: Vec<&SlideReference> = package
177        .presentation
178        .slides
179        .iter()
180        .filter(|reference| !final_paths.contains(reference.part_path.as_str()))
181        .collect();
182
183    let mut replacements: HashMap<String, Vec<u8>> = HashMap::new();
184    let mut new_parts: Vec<(String, Vec<u8>)> = Vec::new();
185
186    let mut minted = Vec::new();
187    let mut next_slide_number = next_slide_number(package);
188    let mut minted_slide_id: Option<u32> = None;
189    let mut next_relationship = next_relationship_number(package);
190    let mut structural = false;
191    let mut final_order: Vec<FinalSlide<'_>> = Vec::new();
192    let mut minted_by_slide: HashMap<usize, usize> = HashMap::new();
193    {
194        let mut sink = PartSink {
195            package,
196            replacements: &mut replacements,
197            new_parts: &mut new_parts,
198        };
199
200        for slide in &deck.slides {
201            if let SlideWrite::Patch { part_path, shapes } = slide {
202                let bytes = package
203                    .part_bytes(part_path)
204                    .ok_or_else(|| PptxError::MissingPart(part_path.clone()))?;
205                let mut root = parse_xml(bytes, part_path, &mut budget)?;
206                let original_root = root.clone();
207                let theme = crate::slide_theme(package, Some(part_path), None);
208                patch_slide(
209                    &mut root,
210                    shapes,
211                    Some(&theme),
212                    part_path,
213                    package.shape_elements,
214                    &mut sink,
215                    &mut budget,
216                )?;
217                if root != original_root {
218                    sink.store(part_path, serialize_xml(&root));
219                }
220            }
221        }
222
223        for (slide_index, slide) in deck.slides.iter().enumerate() {
224            match slide {
225                SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
226                    let reference = package
227                        .presentation
228                        .slides
229                        .iter()
230                        .find(|reference| &reference.part_path == part_path)
231                        .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
232                    final_order.push(FinalSlide::Existing(reference));
233                }
234                SlideWrite::Add {
235                    name,
236                    layout_part_path,
237                    shapes,
238                } => {
239                    structural = true;
240                    let part_path = format!("ppt/slides/slide{next_slide_number}.xml");
241                    next_slide_number += 1;
242                    let relationship_id = format!("rId{next_relationship}");
243                    next_relationship += 1;
244                    let slide_id = match minted_slide_id {
245                        None => next_slide_id(package)?,
246                        Some(previous) => previous
247                            .checked_add(1)
248                            .filter(|id| *id <= MAX_SLIDE_ID)
249                            .ok_or_else(|| {
250                                write_error(
251                                    &package.presentation.part_path,
252                                    "the slide id space is exhausted",
253                                )
254                            })?,
255                    };
256                    minted_slide_id = Some(slide_id);
257                    let layout = layout_part_path
258                        .clone()
259                        .filter(|path| {
260                            package
261                                .layouts
262                                .iter()
263                                .any(|layout| &layout.part_path == path)
264                        })
265                        .or_else(|| {
266                            package
267                                .layouts
268                                .first()
269                                .map(|layout| layout.part_path.clone())
270                        });
271                    if let Some(layout) = &layout {
272                        sink.store(
273                            &slide_relationships_path(&part_path),
274                            slide_relationships_xml(&part_path, layout),
275                        );
276                    }
277                    let xml =
278                        slide_xml(name.as_deref(), shapes, &part_path, &mut sink, &mut budget)?;
279                    sink.store(&part_path, xml);
280                    minted.push(MintedSlide {
281                        part_path,
282                        relationship_id,
283                        slide_id,
284                    });
285                    minted_by_slide.insert(slide_index, minted.len() - 1);
286                    final_order.push(FinalSlide::Added(minted.len() - 1));
287                }
288            }
289        }
290    }
291    let original_paths: Vec<&str> = package
292        .presentation
293        .slides
294        .iter()
295        .map(|reference| reference.part_path.as_str())
296        .collect();
297    let kept_paths: Vec<&str> = final_order
298        .iter()
299        .filter_map(|slide| match slide {
300            FinalSlide::Existing(reference) => Some(reference.part_path.as_str()),
301            FinalSlide::Added(_) => None,
302        })
303        .collect();
304    structural = structural || kept_paths != original_paths;
305
306    let mut removed_paths = HashSet::new();
307    for reference in &removed {
308        removed_paths.insert(reference.part_path.clone());
309        removed_paths.insert(slide_relationships_path(&reference.part_path));
310    }
311    let orphans = orphaned_parts(package, &removed);
312    for orphan in &orphans {
313        removed_paths.insert(orphan.clone());
314        removed_paths.insert(slide_relationships_path(orphan));
315    }
316
317    if structural {
318        patch_structure(
319            package,
320            &final_order,
321            &minted,
322            &removed,
323            &orphans,
324            &mut replacements,
325            &mut budget,
326        )?;
327    }
328    if let Some(comments) = &deck.comments {
329        patch_comment_parts(
330            package,
331            comments,
332            &MintedSlides {
333                slides: &minted,
334                by_slide_index: &minted_by_slide,
335            },
336            &mut replacements,
337            &mut new_parts,
338            &mut removed_paths,
339            &mut budget,
340        )?;
341    }
342    if let Some(notes) = &deck.notes {
343        patch_notes_parts(
344            package,
345            notes,
346            &MintedSlides {
347                slides: &minted,
348                by_slide_index: &minted_by_slide,
349            },
350            &mut replacements,
351            &mut new_parts,
352            &mut removed_paths,
353            &mut budget,
354        )?;
355    }
356    let mut parts = Vec::with_capacity(package.parts.len() + new_parts.len());
357    for part in &package.parts {
358        if removed_paths.contains(&part.path) {
359            continue;
360        }
361        match replacements.remove(&part.path) {
362            Some(bytes) => parts.push((part.path.clone(), bytes)),
363            None => parts.push((part.path.clone(), part.bytes.clone())),
364        }
365    }
366    parts.extend(new_parts);
367    ooxml_opc::rezip_parts_preserving(&parts, package.source_container.as_bytes())
368        .map_err(PptxError::Container)
369}
370
371struct MintedSlide {
372    part_path: String,
373    relationship_id: String,
374    slide_id: u32,
375}
376
377enum FinalSlide<'a> {
378    Existing(&'a SlideReference),
379    Added(usize),
380}
381
382fn write_error(part: &str, message: impl Into<String>) -> PptxError {
383    PptxError::Write {
384        part: part.to_owned(),
385        message: message.into(),
386    }
387}
388
389fn next_slide_number(package: &PptxPackage) -> usize {
390    let paths: HashSet<&str> = package
391        .parts
392        .iter()
393        .map(|part| part.path.as_str())
394        .collect();
395    let mut number = package
396        .parts
397        .iter()
398        .filter_map(|part| {
399            part.path
400                .strip_prefix("ppt/slides/slide")?
401                .strip_suffix(".xml")?
402                .parse::<usize>()
403                .ok()
404        })
405        .max()
406        .unwrap_or(0)
407        + 1;
408    while paths.contains(format!("ppt/slides/slide{number}.xml").as_str())
409        || paths.contains(format!("ppt/slides/_rels/slide{number}.xml.rels").as_str())
410    {
411        number += 1;
412    }
413    number
414}
415
416fn next_slide_id(package: &PptxPackage) -> Result<u32, PptxError> {
417    let next = package
418        .presentation
419        .slides
420        .iter()
421        .map(|reference| reference.id)
422        .max()
423        .unwrap_or(0)
424        .max(MIN_SLIDE_ID - 1)
425        .checked_add(1)
426        .filter(|id| *id <= MAX_SLIDE_ID);
427    next.ok_or_else(|| {
428        write_error(
429            &package.presentation.part_path,
430            "the slide id space is exhausted",
431        )
432    })
433}
434
435fn next_relationship_number(package: &PptxPackage) -> usize {
436    package
437        .relationships
438        .get(&package.presentation.part_path)
439        .into_iter()
440        .flatten()
441        .filter_map(|relationship| relationship.id.strip_prefix("rId")?.parse::<usize>().ok())
442        .max()
443        .unwrap_or(0)
444        + 1
445}
446
447fn slide_relationships_path(part_path: &str) -> String {
448    match part_path.rsplit_once('/') {
449        Some((directory, name)) => format!("{directory}/_rels/{name}.rels"),
450        None => format!("_rels/{part_path}.rels"),
451    }
452}
453
454/// Parts that were reachable from the package root only through the removed
455/// slides (notes slides, media used nowhere else, ...), transitively.
456fn orphaned_parts(package: &PptxPackage, removed: &[&SlideReference]) -> Vec<String> {
457    if removed.is_empty() {
458        return Vec::new();
459    }
460    let excluded: HashSet<&str> = removed
461        .iter()
462        .map(|reference| reference.part_path.as_str())
463        .collect();
464    let before = reachable_parts(package, &HashSet::new());
465    let after = reachable_parts(package, &excluded);
466    before
467        .into_iter()
468        .filter(|part| !after.contains(part) && !excluded.contains(part.as_str()))
469        .collect()
470}
471
472fn reachable_parts(package: &PptxPackage, excluded: &HashSet<&str>) -> HashSet<String> {
473    let mut reachable = HashSet::new();
474    let mut queue = vec![String::new()];
475    while let Some(source) = queue.pop() {
476        for relationship in package.relationships.get(&source).into_iter().flatten() {
477            let Some(target) = &relationship.resolved_target else {
478                continue;
479            };
480            if excluded.contains(target.as_str()) {
481                continue;
482            }
483            if reachable.insert(target.clone()) {
484                queue.push(target.clone());
485            }
486        }
487    }
488    reachable
489}
490
491fn relative_target(from_part: &str, to_part: &str) -> String {
492    let from: Vec<&str> = from_part.split('/').collect();
493    let to: Vec<&str> = to_part.split('/').collect();
494    let from_directory = &from[..from.len().saturating_sub(1)];
495    let to_directory = &to[..to.len().saturating_sub(1)];
496    let shared = from_directory
497        .iter()
498        .zip(to_directory)
499        .take_while(|(a, b)| a == b)
500        .count();
501    let mut segments: Vec<String> = vec!["..".to_owned(); from_directory.len() - shared];
502    segments.extend(to[shared..].iter().map(|segment| (*segment).to_owned()));
503    segments.join("/")
504}
505
506// --- structural parts -------------------------------------------------------
507
508fn patch_structure(
509    package: &PptxPackage,
510    final_order: &[FinalSlide<'_>],
511    minted: &[MintedSlide],
512    removed: &[&SlideReference],
513    orphans: &[String],
514    replacements: &mut HashMap<String, Vec<u8>>,
515    budget: &mut ParseBudget<'_>,
516) -> Result<(), PptxError> {
517    let presentation_path = package.presentation.part_path.as_str();
518    let bytes = package
519        .part_bytes(presentation_path)
520        .ok_or_else(|| PptxError::MissingPart(presentation_path.to_owned()))?;
521    let mut root = parse_xml(bytes, presentation_path, budget)?;
522    patch_slide_id_list(&mut root, final_order, minted, presentation_path)?;
523    prune_slide_references(&mut root, removed);
524    replacements.insert(presentation_path.to_owned(), serialize_xml(&root));
525
526    let relationships_path = slide_relationships_path(presentation_path);
527    let bytes = package
528        .part_bytes(&relationships_path)
529        .ok_or_else(|| PptxError::MissingPart(relationships_path.clone()))?;
530    let mut root = parse_xml(bytes, &relationships_path, budget)?;
531    let removed_ids: HashSet<&str> = removed
532        .iter()
533        .map(|reference| reference.relationship_id.as_str())
534        .collect();
535    root.children.retain(|child| match child {
536        XmlNode::Element(element) if element.local_name() == "Relationship" => element
537            .attribute("Id")
538            .is_none_or(|id| !removed_ids.contains(id)),
539        _ => true,
540    });
541    for slide in minted {
542        root.children.push(XmlNode::Element(
543            XmlElement::new("Relationship")
544                .with_attribute("Id", slide.relationship_id.clone())
545                .with_attribute("Type", SLIDE_RELATIONSHIP_TYPE)
546                .with_attribute(
547                    "Target",
548                    relative_target(presentation_path, &slide.part_path),
549                ),
550        ));
551    }
552    replacements.insert(relationships_path, serialize_xml(&root));
553
554    let content_types_path = "[Content_Types].xml";
555    let bytes = match replacements.get(content_types_path) {
556        Some(bytes) => bytes.clone(),
557        None => package
558            .part_bytes(content_types_path)
559            .ok_or_else(|| PptxError::MissingPart(content_types_path.to_owned()))?
560            .to_vec(),
561    };
562    let mut root = parse_xml(&bytes, content_types_path, budget)?;
563    let removed_names: HashSet<String> = removed
564        .iter()
565        .map(|reference| format!("/{}", reference.part_path))
566        .chain(orphans.iter().map(|orphan| format!("/{orphan}")))
567        .collect();
568    root.children.retain(|child| match child {
569        XmlNode::Element(element) if element.local_name() == "Override" => element
570            .attribute("PartName")
571            .is_none_or(|name| !removed_names.contains(name)),
572        _ => true,
573    });
574    for slide in minted {
575        root.children.push(XmlNode::Element(
576            XmlElement::new("Override")
577                .with_attribute("PartName", format!("/{}", slide.part_path))
578                .with_attribute("ContentType", SLIDE_CONTENT_TYPE),
579        ));
580    }
581    replacements.insert(content_types_path.to_owned(), serialize_xml(&root));
582    Ok(())
583}
584
585const PACKAGE_RELATIONSHIPS_NS: &str =
586    "http://schemas.openxmlformats.org/package/2006/relationships";
587
588struct MintedSlides<'a> {
589    slides: &'a [MintedSlide],
590    by_slide_index: &'a HashMap<usize, usize>,
591}
592
593impl MintedSlides<'_> {
594    fn get(&self, slide_index: usize) -> Option<&MintedSlide> {
595        self.by_slide_index
596            .get(&slide_index)
597            .and_then(|at| self.slides.get(*at))
598    }
599}
600
601fn patch_comment_parts(
602    package: &PptxPackage,
603    write: &CommentsWrite,
604    minted: &MintedSlides<'_>,
605    replacements: &mut HashMap<String, Vec<u8>>,
606    new_parts: &mut Vec<(String, Vec<u8>)>,
607    removed_paths: &mut HashSet<String>,
608    budget: &mut ParseBudget<'_>,
609) -> Result<(), PptxError> {
610    let mut taken: HashSet<String> = package
611        .parts
612        .iter()
613        .map(|part| part.path.clone())
614        .chain(
615            package
616                .relationships
617                .values()
618                .flatten()
619                .filter(|relationship| relationship.is_type(write.comments_relationship_type()))
620                .filter_map(|relationship| relationship.resolved_target.clone()),
621        )
622        .collect();
623
624    let mut sink = PartSink {
625        package,
626        replacements,
627        new_parts,
628    };
629    for (slide, comments) in &write.per_slide {
630        let (slide_part_path, slide_id) = match slide {
631            CommentSlide::Existing(part_path) => {
632                let reference = package
633                    .presentation
634                    .slides
635                    .iter()
636                    .find(|reference| &reference.part_path == part_path)
637                    .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
638                (part_path.clone(), reference.id)
639            }
640            CommentSlide::Added(index) => {
641                let Some(slide) = minted.get(*index) else {
642                    continue;
643                };
644                (slide.part_path.clone(), slide.slide_id)
645            }
646        };
647        let relationships_path = slide_relationships_path(&slide_part_path);
648        let existing = existing_comment_part(package, &slide_part_path, write);
649
650        if comments.is_empty() {
651            if let Some(part_path) = existing {
652                removed_paths.insert(part_path.clone());
653                sink.forget(&part_path);
654                remove_content_type_override(&mut sink, &part_path, budget)?;
655                remove_relationship(
656                    &mut sink,
657                    &relationships_path,
658                    write.comments_relationship_type(),
659                    budget,
660                )?;
661            }
662            continue;
663        }
664
665        let is_existing = existing.is_some();
666        let part_path = match existing {
667            Some(part_path) => part_path,
668            None => mint_comment_part_path(write, &slide_part_path, &mut taken),
669        };
670        removed_paths.remove(&part_path);
671        let bytes = match sink.current(&part_path) {
672            Some(bytes) => crate::comment_patch::patch_comments_xml(
673                &bytes, &part_path, write, comments, slide_id, budget,
674            )?,
675            None => comments_xml(write, comments, slide_id),
676        };
677        sink.store(&part_path, bytes);
678        if !is_existing {
679            set_content_type_override(
680                &mut sink,
681                &part_path,
682                write.comments_content_type(),
683                budget,
684            )?;
685            set_relationship(
686                &mut sink,
687                &relationships_path,
688                write.comments_relationship_type(),
689                &relative_target(&slide_part_path, &part_path),
690                budget,
691            )?;
692        }
693    }
694
695    let presentation_relationships = slide_relationships_path(&package.presentation.part_path);
696    let authors_path = existing_authors_part(package, write)
697        .unwrap_or_else(|| write.authors_part_path().to_owned());
698    if write.authors.is_empty() {
699        removed_paths.insert(authors_path.clone());
700        sink.forget(&authors_path);
701        remove_content_type_override(&mut sink, &authors_path, budget)?;
702        remove_relationship(
703            &mut sink,
704            &presentation_relationships,
705            write.authors_relationship_type(),
706            budget,
707        )?;
708    } else {
709        removed_paths.remove(&authors_path);
710        let existing = sink.current(&authors_path);
711        let bytes = match &existing {
712            Some(bytes) => {
713                crate::comment_patch::patch_authors_xml(bytes, &authors_path, write, budget)?
714            }
715            None => authors_xml(write),
716        };
717        sink.store(&authors_path, bytes);
718        if existing.is_none() {
719            set_content_type_override(
720                &mut sink,
721                &authors_path,
722                write.authors_content_type(),
723                budget,
724            )?;
725            set_relationship(
726                &mut sink,
727                &presentation_relationships,
728                write.authors_relationship_type(),
729                &relative_target(&package.presentation.part_path, &authors_path),
730                budget,
731            )?;
732        }
733    }
734
735    if package.comment_flavor != Some(write.flavor) {
736        drop_other_flavor(package, write, &mut sink, removed_paths, budget)?;
737    }
738    Ok(())
739}
740
741fn patch_notes_parts(
742    package: &PptxPackage,
743    write: &NotesWrite,
744    minted: &MintedSlides<'_>,
745    replacements: &mut HashMap<String, Vec<u8>>,
746    new_parts: &mut Vec<(String, Vec<u8>)>,
747    removed_paths: &mut HashSet<String>,
748    budget: &mut ParseBudget<'_>,
749) -> Result<(), PptxError> {
750    let mut taken: HashSet<String> = package
751        .parts
752        .iter()
753        .map(|part| part.path.clone())
754        .chain(
755            package
756                .relationships
757                .values()
758                .flatten()
759                .filter(|relationship| relationship.is_type(NOTES_SLIDE_RELATIONSHIP_TYPE))
760                .filter_map(|relationship| relationship.resolved_target.clone()),
761        )
762        .collect();
763
764    let mut sink = PartSink {
765        package,
766        replacements,
767        new_parts,
768    };
769    for (slide, text) in &write.per_slide {
770        let slide_part_path = match slide {
771            CommentSlide::Existing(part_path) => part_path.clone(),
772            CommentSlide::Added(index) => {
773                let Some(slide) = minted.get(*index) else {
774                    continue;
775                };
776                slide.part_path.clone()
777            }
778        };
779        let relationships_path = slide_relationships_path(&slide_part_path);
780        let existing = existing_notes_part(package, &slide_part_path);
781
782        if text.is_empty() && existing.is_none() {
783            continue;
784        }
785
786        let is_existing = existing.is_some();
787        let part_path = match existing {
788            Some(part_path) => part_path,
789            None => mint_notes_part_path(&slide_part_path, &mut taken),
790        };
791        removed_paths.remove(&part_path);
792        let bytes = match sink.current(&part_path) {
793            Some(bytes) => crate::notes::patch_notes_xml(&bytes, &part_path, text, budget)?,
794            None => crate::notes::notes_slide_xml(text),
795        };
796        sink.store(&part_path, bytes);
797        if !is_existing {
798            set_content_type_override(&mut sink, &part_path, crate::notes::CT_NOTES_SLIDE, budget)?;
799            set_relationship(
800                &mut sink,
801                &relationships_path,
802                NOTES_SLIDE_RELATIONSHIP_TYPE,
803                &relative_target(&slide_part_path, &part_path),
804                budget,
805            )?;
806            let notes_relationships = slide_relationships_path(&part_path);
807            set_relationship(
808                &mut sink,
809                &notes_relationships,
810                SLIDE_RELATIONSHIP_TYPE,
811                &relative_target(&part_path, &slide_part_path),
812                budget,
813            )?;
814            if let Some(master) = package
815                .relationships
816                .get(&package.presentation.part_path)
817                .into_iter()
818                .flatten()
819                .find(|relationship| relationship.has_type("/notesMaster"))
820                .and_then(|relationship| relationship.resolved_target.as_deref())
821            {
822                set_relationship(
823                    &mut sink,
824                    &notes_relationships,
825                    &format!("{OFFICE_RELATIONSHIPS_NS}/notesMaster"),
826                    &relative_target(&part_path, master),
827                    budget,
828                )?;
829            }
830        }
831    }
832    Ok(())
833}
834
835fn existing_notes_part(package: &PptxPackage, slide_part_path: &str) -> Option<String> {
836    package
837        .relationships
838        .get(slide_part_path)?
839        .iter()
840        .find(|relationship| relationship.is_type(NOTES_SLIDE_RELATIONSHIP_TYPE))
841        .and_then(|relationship| relationship.resolved_target.clone())
842}
843
844fn mint_notes_part_path(slide_part_path: &str, taken: &mut HashSet<String>) -> String {
845    let preferred = slide_number(slide_part_path);
846    let mut number = preferred;
847    loop {
848        let candidate = format!("ppt/notesSlides/notesSlide{number}.xml");
849        if taken.insert(candidate.clone()) {
850            return candidate;
851        }
852        number += 1;
853    }
854}
855
856fn mint_comment_part_path(
857    write: &CommentsWrite,
858    slide_part_path: &str,
859    taken: &mut HashSet<String>,
860) -> String {
861    let preferred = slide_number(slide_part_path);
862    let mut number = preferred;
863    loop {
864        let candidate = write.comments_part_path(number);
865        if taken.insert(candidate.clone()) {
866            return candidate;
867        }
868        number += 1;
869    }
870}
871
872fn drop_other_flavor(
873    package: &PptxPackage,
874    write: &CommentsWrite,
875    sink: &mut PartSink<'_>,
876    removed_paths: &mut HashSet<String>,
877    budget: &mut ParseBudget<'_>,
878) -> Result<(), PptxError> {
879    let other = CommentsWrite {
880        flavor: match write.flavor {
881            CommentFlavor::Legacy => CommentFlavor::Modern,
882            CommentFlavor::Modern => CommentFlavor::Legacy,
883        },
884        authors: Vec::new(),
885        per_slide: Vec::new(),
886    };
887    for reference in &package.presentation.slides {
888        let Some(part_path) = existing_comment_part(package, &reference.part_path, &other) else {
889            continue;
890        };
891        removed_paths.insert(part_path.clone());
892        sink.forget(&part_path);
893        remove_content_type_override(sink, &part_path, budget)?;
894        remove_relationship(
895            sink,
896            &slide_relationships_path(&reference.part_path),
897            other.comments_relationship_type(),
898            budget,
899        )?;
900    }
901    let authors_path = existing_authors_part(package, &other)
902        .unwrap_or_else(|| other.authors_part_path().to_owned());
903    if package.part_bytes(&authors_path).is_some() {
904        removed_paths.insert(authors_path.clone());
905        sink.forget(&authors_path);
906        remove_content_type_override(sink, &authors_path, budget)?;
907        remove_relationship(
908            sink,
909            &slide_relationships_path(&package.presentation.part_path),
910            other.authors_relationship_type(),
911            budget,
912        )?;
913    }
914    Ok(())
915}
916
917fn existing_authors_part(package: &PptxPackage, write: &CommentsWrite) -> Option<String> {
918    package
919        .relationships
920        .get(&package.presentation.part_path)?
921        .iter()
922        .find(|relationship| relationship.is_type(write.authors_relationship_type()))
923        .and_then(|relationship| relationship.resolved_target.clone())
924}
925
926fn existing_comment_part(
927    package: &PptxPackage,
928    slide_part_path: &str,
929    write: &CommentsWrite,
930) -> Option<String> {
931    package
932        .relationships
933        .get(slide_part_path)?
934        .iter()
935        .find(|relationship| relationship.is_type(write.comments_relationship_type()))
936        .and_then(|relationship| relationship.resolved_target.clone())
937}
938
939fn slide_number(slide_part_path: &str) -> usize {
940    slide_part_path
941        .rsplit_once("/slide")
942        .and_then(|(_, tail)| tail.strip_suffix(".xml"))
943        .and_then(|digits| digits.parse().ok())
944        .unwrap_or(1)
945}
946
947struct PartSink<'a> {
948    package: &'a PptxPackage,
949    replacements: &'a mut HashMap<String, Vec<u8>>,
950    new_parts: &'a mut Vec<(String, Vec<u8>)>,
951}
952
953impl PartSink<'_> {
954    fn current(&self, path: &str) -> Option<Vec<u8>> {
955        if let Some(bytes) = self.replacements.get(path) {
956            return Some(bytes.clone());
957        }
958        if let Some((_, bytes)) = self.new_parts.iter().find(|(name, _)| name == path) {
959            return Some(bytes.clone());
960        }
961        self.package.part_bytes(path).map(<[u8]>::to_vec)
962    }
963
964    fn store(&mut self, path: &str, bytes: Vec<u8>) {
965        if self.package.part_bytes(path).is_some() {
966            self.replacements.insert(path.to_owned(), bytes);
967            return;
968        }
969        match self.new_parts.iter_mut().find(|(name, _)| name == path) {
970            Some((_, existing)) => *existing = bytes,
971            None => self.new_parts.push((path.to_owned(), bytes)),
972        }
973    }
974
975    fn forget(&mut self, path: &str) {
976        self.replacements.remove(path);
977        self.new_parts.retain(|(name, _)| name != path);
978    }
979}
980
981fn set_relationship(
982    sink: &mut PartSink<'_>,
983    relationships_path: &str,
984    relationship_type: &str,
985    target: &str,
986    budget: &mut ParseBudget<'_>,
987) -> Result<(), PptxError> {
988    let mut root = match sink.current(relationships_path) {
989        Some(bytes) => parse_xml(&bytes, relationships_path, budget)?,
990        None => XmlElement::new("Relationships").with_attribute("xmlns", PACKAGE_RELATIONSHIPS_NS),
991    };
992    let existing = root.children.iter_mut().find_map(|child| match child {
993        XmlNode::Element(element)
994            if element.local_name() == "Relationship"
995                && element.attribute("Type") == Some(relationship_type) =>
996        {
997            Some(element)
998        }
999        _ => None,
1000    });
1001    match existing {
1002        Some(element) => element.set_attribute("Target", target),
1003        None => {
1004            let id = format!("rId{}", max_relationship_id(&root) + 1);
1005            root.children.push(XmlNode::Element(
1006                XmlElement::new("Relationship")
1007                    .with_attribute("Id", id)
1008                    .with_attribute("Type", relationship_type)
1009                    .with_attribute("Target", target),
1010            ));
1011        }
1012    }
1013    sink.store(relationships_path, serialize_xml(&root));
1014    Ok(())
1015}
1016
1017fn remove_relationship(
1018    sink: &mut PartSink<'_>,
1019    relationships_path: &str,
1020    relationship_type: &str,
1021    budget: &mut ParseBudget<'_>,
1022) -> Result<(), PptxError> {
1023    let Some(bytes) = sink.current(relationships_path) else {
1024        return Ok(());
1025    };
1026    let mut root = parse_xml(&bytes, relationships_path, budget)?;
1027    root.children.retain(|child| match child {
1028        XmlNode::Element(element) if element.local_name() == "Relationship" => {
1029            element.attribute("Type") != Some(relationship_type)
1030        }
1031        _ => true,
1032    });
1033    sink.store(relationships_path, serialize_xml(&root));
1034    Ok(())
1035}
1036
1037fn max_relationship_id(root: &XmlElement) -> usize {
1038    root.children_named("Relationship")
1039        .filter_map(|element| element.attribute("Id"))
1040        .filter_map(|id| id.strip_prefix("rId"))
1041        .filter_map(|number| number.parse::<usize>().ok())
1042        .max()
1043        .unwrap_or(0)
1044}
1045
1046fn set_content_type_override(
1047    sink: &mut PartSink<'_>,
1048    part_path: &str,
1049    content_type: &str,
1050    budget: &mut ParseBudget<'_>,
1051) -> Result<(), PptxError> {
1052    let path = "[Content_Types].xml";
1053    let bytes = sink
1054        .current(path)
1055        .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
1056    let mut root = parse_xml(&bytes, path, budget)?;
1057    let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
1058    let override_name = qualified(prefix, "Override");
1059    let name = format!("/{part_path}");
1060    let existing = root.children.iter_mut().find_map(|child| match child {
1061        XmlNode::Element(element)
1062            if element.local_name() == "Override"
1063                && element.attribute("PartName") == Some(name.as_str()) =>
1064        {
1065            Some(element)
1066        }
1067        _ => None,
1068    });
1069    match existing {
1070        Some(element) => element.set_attribute("ContentType", content_type),
1071        None => root.children.push(XmlNode::Element(
1072            XmlElement::new(override_name)
1073                .with_attribute("PartName", name)
1074                .with_attribute("ContentType", content_type),
1075        )),
1076    }
1077    sink.store(path, serialize_xml(&root));
1078    Ok(())
1079}
1080
1081fn remove_content_type_override(
1082    sink: &mut PartSink<'_>,
1083    part_path: &str,
1084    budget: &mut ParseBudget<'_>,
1085) -> Result<(), PptxError> {
1086    let path = "[Content_Types].xml";
1087    let Some(bytes) = sink.current(path) else {
1088        return Ok(());
1089    };
1090    let mut root = parse_xml(&bytes, path, budget)?;
1091    let name = format!("/{part_path}");
1092    root.children.retain(|child| match child {
1093        XmlNode::Element(element) if element.local_name() == "Override" => {
1094            element.attribute("PartName") != Some(name.as_str())
1095        }
1096        _ => true,
1097    });
1098    sink.store(path, serialize_xml(&root));
1099    Ok(())
1100}
1101
1102/// Drops references to removed slides from custom shows (`r:id`) and
1103/// section lists (`p14:sldId` inside `extLst`).
1104fn prune_slide_references(root: &mut XmlElement, removed: &[&SlideReference]) {
1105    if removed.is_empty() {
1106        return;
1107    }
1108    let removed_relationship_ids: HashSet<&str> = removed
1109        .iter()
1110        .map(|reference| reference.relationship_id.as_str())
1111        .collect();
1112    let removed_slide_ids: HashSet<String> = removed
1113        .iter()
1114        .map(|reference| reference.id.to_string())
1115        .collect();
1116    for child in root.children.iter_mut() {
1117        let XmlNode::Element(element) = child else {
1118            continue;
1119        };
1120        match element.local_name() {
1121            "custShowLst" => remove_matching_descendants(element, &|candidate| {
1122                candidate.local_name() == "sld"
1123                    && candidate.attributes.iter().any(|(key, value)| {
1124                        key.ends_with(":id") && removed_relationship_ids.contains(value.as_str())
1125                    })
1126            }),
1127            "extLst" => remove_matching_descendants(element, &|candidate| {
1128                candidate.local_name() == "sldId"
1129                    && candidate
1130                        .attribute("id")
1131                        .is_some_and(|id| removed_slide_ids.contains(id))
1132            }),
1133            _ => {}
1134        }
1135    }
1136}
1137
1138fn remove_matching_descendants(element: &mut XmlElement, matches: &dyn Fn(&XmlElement) -> bool) {
1139    element.children.retain(|child| match child {
1140        XmlNode::Element(candidate) => !matches(candidate),
1141        _ => true,
1142    });
1143    for child in element.children.iter_mut() {
1144        if let XmlNode::Element(child) = child {
1145            remove_matching_descendants(child, matches);
1146        }
1147    }
1148}
1149
1150fn patch_slide_id_list(
1151    root: &mut XmlElement,
1152    final_order: &[FinalSlide<'_>],
1153    minted: &[MintedSlide],
1154    part: &str,
1155) -> Result<(), PptxError> {
1156    let presentation_prefix = resolve_prefix(root, PRESENTATIONML_NS, "p");
1157    let relationship_prefix = resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r");
1158    let list_name = qualified(&presentation_prefix, "sldIdLst");
1159    if root.child_mut("sldIdLst").is_none() {
1160        let position = root
1161            .children
1162            .iter()
1163            .position(|child| {
1164                matches!(child, XmlNode::Element(element) if element.local_name() == "sldMasterIdLst")
1165            })
1166            .map(|index| index + 1)
1167            .unwrap_or(0);
1168        root.children.insert(
1169            position,
1170            XmlNode::Element(XmlElement::new(list_name.clone())),
1171        );
1172    }
1173    let relationship_attribute = qualified(&relationship_prefix, "id");
1174    let list = root
1175        .child_mut("sldIdLst")
1176        .ok_or_else(|| write_error(part, "missing slide id list"))?;
1177    let mut existing: HashMap<String, XmlElement> = HashMap::new();
1178    for child in std::mem::take(&mut list.children) {
1179        if let XmlNode::Element(element) = child
1180            && element.local_name() == "sldId"
1181        {
1182            let relationship_id = element.attribute(&relationship_attribute).or_else(|| {
1183                element
1184                    .attributes
1185                    .iter()
1186                    .find(|(key, _)| key.ends_with(":id"))
1187                    .map(|(_, value)| value.as_str())
1188            });
1189            if let Some(id) = relationship_id {
1190                existing.insert(id.to_owned(), element);
1191            }
1192        }
1193    }
1194    let entry_name = qualified(&presentation_prefix, "sldId");
1195    for slide in final_order {
1196        let element = match slide {
1197            FinalSlide::Existing(reference) => existing
1198                .remove(&reference.relationship_id)
1199                .unwrap_or_else(|| {
1200                    XmlElement::new(entry_name.clone())
1201                        .with_attribute("id", reference.id.to_string())
1202                        .with_attribute(
1203                            relationship_attribute.clone(),
1204                            reference.relationship_id.clone(),
1205                        )
1206                }),
1207            FinalSlide::Added(index) => {
1208                let slide = &minted[*index];
1209                XmlElement::new(entry_name.clone())
1210                    .with_attribute("id", slide.slide_id.to_string())
1211                    .with_attribute(
1212                        relationship_attribute.clone(),
1213                        slide.relationship_id.clone(),
1214                    )
1215            }
1216        };
1217        list.children.push(XmlNode::Element(element));
1218    }
1219    Ok(())
1220}
1221
1222// --- namespace prefixes -----------------------------------------------------
1223
1224fn find_prefix(root: &XmlElement, namespace: &str) -> Option<String> {
1225    root.attributes.iter().find_map(|(key, value)| {
1226        if value != namespace {
1227            return None;
1228        }
1229        if key == "xmlns" {
1230            Some(String::new())
1231        } else {
1232            key.strip_prefix("xmlns:").map(str::to_owned)
1233        }
1234    })
1235}
1236
1237fn resolve_prefix(root: &mut XmlElement, namespace: &str, fallback: &str) -> String {
1238    if let Some(prefix) = find_prefix(root, namespace) {
1239        return prefix;
1240    }
1241    root.set_attribute(format!("xmlns:{fallback}"), namespace);
1242    fallback.to_owned()
1243}
1244
1245fn qualified(prefix: &str, local: &str) -> String {
1246    if prefix.is_empty() {
1247        local.to_owned()
1248    } else {
1249        format!("{prefix}:{local}")
1250    }
1251}
1252
1253struct Prefixes {
1254    drawing: String,
1255    presentation: String,
1256    relationship: String,
1257}
1258
1259impl Prefixes {
1260    fn from_root(root: &mut XmlElement) -> Self {
1261        Self {
1262            drawing: resolve_prefix(root, DRAWINGML_NS, "a"),
1263            presentation: resolve_prefix(root, PRESENTATIONML_NS, "p"),
1264            relationship: resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r"),
1265        }
1266    }
1267
1268    fn drawing(&self, local: &str) -> String {
1269        qualified(&self.drawing, local)
1270    }
1271
1272    fn presentation(&self, local: &str) -> String {
1273        qualified(&self.presentation, local)
1274    }
1275
1276    fn relationship(&self, local: &str) -> String {
1277        qualified(&self.relationship, local)
1278    }
1279}
1280
1281// --- slide patching ---------------------------------------------------------
1282
1283/// Resolves source ordinals with the original parser filter.
1284fn patch_slide(
1285    root: &mut XmlElement,
1286    shapes: &[ShapeWrite],
1287    theme: Option<&Theme>,
1288    part: &str,
1289    elements: ShapeElements,
1290    sink: &mut PartSink<'_>,
1291    budget: &mut ParseBudget<'_>,
1292) -> Result<(), PptxError> {
1293    let prefixes = Prefixes::from_root(root);
1294    let mut next_shape_id = max_shape_id(root).checked_add(1);
1295    let tree = root
1296        .child_mut("cSld")
1297        .and_then(|common| common.child_mut("spTree"))
1298        .ok_or_else(|| write_error(part, "slide has no shape tree"))?;
1299    patch_shape_children(
1300        tree,
1301        shapes,
1302        &mut next_shape_id,
1303        theme,
1304        &prefixes,
1305        part,
1306        elements,
1307        sink,
1308        budget,
1309    )
1310}
1311
1312fn max_shape_id(root: &XmlElement) -> u32 {
1313    root.descendants_named("cNvPr")
1314        .iter()
1315        .filter_map(|element| element.attribute("id")?.parse::<u32>().ok())
1316        .max()
1317        .unwrap_or(1)
1318}
1319
1320fn alloc_shape_id(next_shape_id: &mut Option<u32>, part: &str) -> Result<u32, PptxError> {
1321    let shape_id =
1322        next_shape_id.ok_or_else(|| write_error(part, "the shape id space is exhausted"))?;
1323    if shape_id == 0 {
1324        return Err(write_error(part, "the shape id space is exhausted"));
1325    }
1326    *next_shape_id = shape_id.checked_add(1);
1327    Ok(shape_id)
1328}
1329
1330/// A parsed shape's source element: a direct child of the tree, or one the
1331/// `mc:AlternateContent` at `position` contributes through `path`.
1332struct ShapeSlot {
1333    position: usize,
1334    path: Vec<usize>,
1335}
1336
1337/// Rebuilds the shape run in place: non-shape siblings keep their slots
1338/// relative to the shape that follows them, and elements trailing the last
1339/// shape (`p:extLst` in particular) stay last.
1340#[allow(clippy::too_many_arguments)]
1341fn patch_shape_children(
1342    parent: &mut XmlElement,
1343    writes: &[ShapeWrite],
1344    next_shape_id: &mut Option<u32>,
1345    theme: Option<&Theme>,
1346    prefixes: &Prefixes,
1347    part: &str,
1348    elements: ShapeElements,
1349    sink: &mut PartSink<'_>,
1350    budget: &mut ParseBudget<'_>,
1351) -> Result<(), PptxError> {
1352    let mut slots: Vec<Option<XmlNode>> = std::mem::take(&mut parent.children)
1353        .into_iter()
1354        .map(Some)
1355        .collect();
1356    let mut shape_slots = shape_slots(&slots, elements);
1357    let mut kept = vec![false; shape_slots.len()];
1358    for write in writes {
1359        if let ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } = write
1360            && let Some(flag) = kept.get_mut(*source_index)
1361        {
1362            *flag = true;
1363        }
1364    }
1365    // A wrapper is not a shape element, so the sibling flush carries it through whatever became of
1366    // the shapes inside it: a deleted one leaves the branch, an emptied wrapper leaves the file.
1367    let mut nested: BTreeMap<usize, (usize, Vec<Vec<usize>>)> = BTreeMap::new();
1368    for (slot, kept) in shape_slots.iter().zip(&kept) {
1369        if slot.path.is_empty() {
1370            continue;
1371        }
1372        let entry = nested.entry(slot.position).or_default();
1373        entry.0 += 1;
1374        if !kept {
1375            entry.1.push(slot.path.clone());
1376        }
1377    }
1378    for (position, (total, mut paths)) in nested {
1379        if paths.len() == total && branch_holds_only(&slots[position], &paths) {
1380            slots[position] = None;
1381            continue;
1382        }
1383        paths.sort();
1384        for path in paths.iter().rev() {
1385            let Some(node) = slots[position].as_mut() else {
1386                continue;
1387            };
1388            remove_at_path(node, path);
1389            for slot in shape_slots
1390                .iter_mut()
1391                .filter(|slot| slot.position == position)
1392            {
1393                shift_path(&mut slot.path, path);
1394            }
1395        }
1396    }
1397    let mut children = Vec::with_capacity(slots.len());
1398    let mut hosts: BTreeMap<usize, usize> = BTreeMap::new();
1399    let first_ext_list = |slots: &[Option<XmlNode>]| {
1400        slots
1401            .iter()
1402            .position(|slot| {
1403                matches!(slot, Some(XmlNode::Element(element)) if element.local_name() == "extLst")
1404            })
1405            .unwrap_or(slots.len())
1406    };
1407    let prologue_end = shape_slots
1408        .first()
1409        .map(|slot| slot.position)
1410        .unwrap_or_else(|| first_ext_list(&slots));
1411    emit_sibling_slots(&mut slots, &mut children, prologue_end, elements);
1412    let last_kept = writes
1413        .iter()
1414        .rposition(|write| !matches!(write, ShapeWrite::Add(_)));
1415    for (index, write) in writes.iter().enumerate() {
1416        match write {
1417            ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } => {
1418                let slot = shape_slots.get(*source_index).ok_or_else(|| {
1419                    write_error(part, format!("shape index {source_index} is not available"))
1420                })?;
1421                emit_sibling_slots(&mut slots, &mut children, slot.position, elements);
1422                let target = emit_source_element(&mut slots, &mut children, &mut hosts, slot)
1423                    .ok_or_else(|| {
1424                        write_error(
1425                            part,
1426                            format!("shape index {source_index} was already written"),
1427                        )
1428                    })?;
1429                if let ShapeWrite::Patch { patch, .. } = write {
1430                    let element =
1431                        element_at_path(&mut children[target], &slot.path).ok_or_else(|| {
1432                            write_error(
1433                                part,
1434                                format!("shape index {source_index} is not available"),
1435                            )
1436                        })?;
1437                    patch_shape(
1438                        element,
1439                        patch,
1440                        next_shape_id,
1441                        theme,
1442                        prefixes,
1443                        part,
1444                        elements,
1445                        sink,
1446                        budget,
1447                    )?;
1448                }
1449            }
1450            ShapeWrite::Add(add) => {
1451                // A trailing add goes on top: after every remaining sibling
1452                // except a final extLst.
1453                if last_kept.is_none_or(|kept| index > kept) {
1454                    let flush_end = first_ext_list(&slots);
1455                    emit_sibling_slots(&mut slots, &mut children, flush_end, elements);
1456                }
1457                children.push(XmlNode::Element(add_shape_element(
1458                    add,
1459                    next_shape_id,
1460                    prefixes,
1461                    part,
1462                    sink,
1463                    budget,
1464                )?));
1465            }
1466        }
1467    }
1468    for slot in slots.into_iter().flatten() {
1469        if !matches!(&slot, XmlNode::Element(element) if elements.contains(element.local_name())) {
1470            children.push(slot);
1471        }
1472    }
1473    parent.children = children;
1474    Ok(())
1475}
1476
1477/// Source ordinals in the order [`common_slide_data`] parses them.
1478fn shape_slots(slots: &[Option<XmlNode>], elements: ShapeElements) -> Vec<ShapeSlot> {
1479    let mut found = Vec::new();
1480    for (position, slot) in slots.iter().enumerate() {
1481        let Some(XmlNode::Element(element)) = slot else {
1482            continue;
1483        };
1484        if elements.contains(element.local_name()) {
1485            found.push(ShapeSlot {
1486                position,
1487                path: Vec::new(),
1488            });
1489        } else if element.local_name() == "AlternateContent" {
1490            collect_branch_slots(element, position, &mut Vec::new(), elements, &mut found);
1491        }
1492    }
1493    found
1494}
1495
1496fn collect_branch_slots(
1497    alternate: &XmlElement,
1498    position: usize,
1499    path: &mut Vec<usize>,
1500    elements: ShapeElements,
1501    found: &mut Vec<ShapeSlot>,
1502) {
1503    let Some(index) = alternate_content_branch_index(alternate) else {
1504        return;
1505    };
1506    let XmlNode::Element(branch) = &alternate.children[index] else {
1507        return;
1508    };
1509    path.push(index);
1510    for (index, child) in branch.children.iter().enumerate() {
1511        let XmlNode::Element(child) = child else {
1512            continue;
1513        };
1514        path.push(index);
1515        if elements.contains(child.local_name()) {
1516            found.push(ShapeSlot {
1517                position,
1518                path: path.clone(),
1519            });
1520        } else if child.local_name() == "AlternateContent" {
1521            collect_branch_slots(child, position, path, elements, found);
1522        }
1523        path.pop();
1524    }
1525    path.pop();
1526}
1527
1528/// Moves a shape's source element into `children`, or reuses the
1529/// `mc:AlternateContent` already moved there for an earlier nested shape.
1530fn emit_source_element(
1531    slots: &mut [Option<XmlNode>],
1532    children: &mut Vec<XmlNode>,
1533    hosts: &mut BTreeMap<usize, usize>,
1534    slot: &ShapeSlot,
1535) -> Option<usize> {
1536    if let Some(host) = hosts.get(&slot.position) {
1537        return Some(*host);
1538    }
1539    let XmlNode::Element(element) = slots[slot.position].take()? else {
1540        return None;
1541    };
1542    children.push(XmlNode::Element(element));
1543    if !slot.path.is_empty() {
1544        hosts.insert(slot.position, children.len() - 1);
1545    }
1546    Some(children.len() - 1)
1547}
1548
1549/// Whether the branch `slot` reads holds nothing but the elements at `paths`.
1550fn branch_holds_only(slot: &Option<XmlNode>, paths: &[Vec<usize>]) -> bool {
1551    let Some(XmlNode::Element(alternate)) = slot else {
1552        return true;
1553    };
1554    let Some(index) = alternate_content_branch_index(alternate) else {
1555        return true;
1556    };
1557    let XmlNode::Element(branch) = &alternate.children[index] else {
1558        return true;
1559    };
1560    branch.children.iter().enumerate().all(|(child, node)| {
1561        !matches!(node, XmlNode::Element(_)) || paths.iter().any(|path| path == &[index, child])
1562    })
1563}
1564
1565fn remove_at_path(node: &mut XmlNode, path: &[usize]) {
1566    let Some((last, parents)) = path.split_last() else {
1567        return;
1568    };
1569    if let Some(parent) = element_at_path(node, parents)
1570        && *last < parent.children.len()
1571    {
1572        parent.children.remove(*last);
1573    }
1574}
1575
1576/// Closes the gap [`remove_at_path`] leaves in the paths of its later siblings.
1577fn shift_path(path: &mut [usize], removed: &[usize]) {
1578    let Some((last, parents)) = removed.split_last() else {
1579        return;
1580    };
1581    if path.len() > parents.len() && path.starts_with(parents) && path[parents.len()] > *last {
1582        path[parents.len()] -= 1;
1583    }
1584}
1585
1586fn element_at_path<'a>(node: &'a mut XmlNode, path: &[usize]) -> Option<&'a mut XmlElement> {
1587    let XmlNode::Element(element) = node else {
1588        return None;
1589    };
1590    let mut current = element;
1591    for index in path {
1592        match current.children.get_mut(*index)? {
1593            XmlNode::Element(child) => current = child,
1594            XmlNode::Text(_) => return None,
1595        }
1596    }
1597    Some(current)
1598}
1599
1600/// Emits the not-yet-written non-shape nodes that precede `position`.
1601fn emit_sibling_slots(
1602    slots: &mut [Option<XmlNode>],
1603    children: &mut Vec<XmlNode>,
1604    position: usize,
1605    elements: ShapeElements,
1606) {
1607    for slot in slots.iter_mut().take(position) {
1608        let is_other = !matches!(
1609            slot,
1610            Some(XmlNode::Element(element)) if elements.contains(element.local_name())
1611        );
1612        if is_other && let Some(node) = slot.take() {
1613            children.push(node);
1614        }
1615    }
1616}
1617
1618#[allow(clippy::too_many_arguments)]
1619fn patch_shape(
1620    element: &mut XmlElement,
1621    patch: &ShapePatch,
1622    next_shape_id: &mut Option<u32>,
1623    theme: Option<&Theme>,
1624    prefixes: &Prefixes,
1625    part: &str,
1626    elements: ShapeElements,
1627    sink: &mut PartSink<'_>,
1628    budget: &mut ParseBudget<'_>,
1629) -> Result<(), PptxError> {
1630    if patch.offset.is_some() || patch.extent.is_some() {
1631        patch_transform(element, patch, prefixes, part)?;
1632    }
1633    if let Some(fill) = &patch.fill {
1634        let properties = shape_properties_mut(element, part)?;
1635        set_fill(properties, fill, prefixes, part)?;
1636    }
1637    if let Some(outline) = &patch.outline {
1638        let properties = shape_properties_mut(element, part)?;
1639        set_outline(properties, outline, prefixes);
1640    }
1641    if let Some(adjust_values) = &patch.adjust_values {
1642        let properties = shape_properties_mut(element, part)?;
1643        set_adjust_values(properties, adjust_values, prefixes, part)?;
1644    }
1645    for text in &patch.texts {
1646        patch_text(element, text, theme, prefixes, part)?;
1647    }
1648    if !patch.children.is_empty() {
1649        patch_shape_children(
1650            element,
1651            &patch.children,
1652            next_shape_id,
1653            theme,
1654            prefixes,
1655            part,
1656            elements,
1657            sink,
1658            budget,
1659        )?;
1660    }
1661    Ok(())
1662}
1663
1664fn shape_properties_mut<'a>(
1665    element: &'a mut XmlElement,
1666    part: &str,
1667) -> Result<&'a mut XmlElement, PptxError> {
1668    let container = match element.local_name() {
1669        "grpSp" => "grpSpPr",
1670        _ => "spPr",
1671    };
1672    element
1673        .child_mut(container)
1674        .ok_or_else(|| write_error(part, "shape has no properties element"))
1675}
1676
1677/// Writes the edited transform halves. Pieces the source spells out and the
1678/// edit did not change are left untouched; pieces the source lacks come from
1679/// the inherited transform, and so do the rotation and flips when the
1680/// `a:xfrm` is new or the patch materializes it.
1681fn patch_transform(
1682    element: &mut XmlElement,
1683    patch: &ShapePatch,
1684    prefixes: &Prefixes,
1685    part: &str,
1686) -> Result<(), PptxError> {
1687    let offset = patch
1688        .offset
1689        .or(patch.inherited.map(|inherited| (inherited.x, inherited.y)));
1690    let extent = patch.extent.or(patch
1691        .inherited
1692        .map(|inherited| (inherited.width, inherited.height)));
1693    let (container, transform_name) = match element.local_name() {
1694        "graphicFrame" => (None, prefixes.presentation("xfrm")),
1695        "grpSp" => (Some("grpSpPr"), prefixes.drawing("xfrm")),
1696        _ => (Some("spPr"), prefixes.drawing("xfrm")),
1697    };
1698    let parent = match container {
1699        Some(name) => element
1700            .child_mut(name)
1701            .ok_or_else(|| write_error(part, "shape has no properties element"))?,
1702        None => element,
1703    };
1704    let created = parent.child_mut("xfrm").is_none();
1705    if created {
1706        if offset.is_none() || extent.is_none() {
1707            return Err(write_error(
1708                part,
1709                "cannot write half a transform onto a shape without one",
1710            ));
1711        }
1712        let position = parent
1713            .children
1714            .iter()
1715            .position(|child| {
1716                matches!(child, XmlNode::Element(element) if element.local_name() != "nvGraphicFramePr")
1717            })
1718            .unwrap_or(parent.children.len());
1719        parent
1720            .children
1721            .insert(position, XmlNode::Element(XmlElement::new(transform_name)));
1722    }
1723    let transform = parent.child_mut("xfrm").expect("transform ensured above");
1724    if (created || patch.materializes)
1725        && let Some(inherited) = patch.inherited
1726    {
1727        if inherited.rotation_deg != 0.0 {
1728            transform.set_attribute("rot", format_fixed(inherited.rotation_deg * 60_000.0));
1729        }
1730        if inherited.flip_horizontal {
1731            transform.set_attribute("flipH", "1");
1732        }
1733        if inherited.flip_vertical {
1734            transform.set_attribute("flipV", "1");
1735        }
1736    }
1737    if let Some((x, y)) = offset
1738        && (patch.offset.is_some() || transform.child_mut("off").is_none())
1739    {
1740        if transform.child_mut("off").is_none() {
1741            transform.children.insert(
1742                0,
1743                XmlNode::Element(XmlElement::new(prefixes.drawing("off"))),
1744            );
1745        }
1746        let element = transform.child_mut("off").expect("offset ensured above");
1747        element.set_attribute("x", x.to_string());
1748        element.set_attribute("y", y.to_string());
1749    }
1750    if let Some((width, height)) = extent
1751        && (patch.extent.is_some() || transform.child_mut("ext").is_none())
1752    {
1753        if transform.child_mut("ext").is_none() {
1754            let position = transform
1755                .children
1756                .iter()
1757                .position(
1758                    |child| matches!(child, XmlNode::Element(element) if element.local_name() == "off"),
1759                )
1760                .map(|index| index + 1)
1761                .unwrap_or(transform.children.len());
1762            transform.children.insert(
1763                position,
1764                XmlNode::Element(XmlElement::new(prefixes.drawing("ext"))),
1765            );
1766        }
1767        let element = transform.child_mut("ext").expect("extent ensured above");
1768        element.set_attribute("cx", width.to_string());
1769        element.set_attribute("cy", height.to_string());
1770    }
1771    Ok(())
1772}
1773
1774const FILL_ELEMENTS: [&str; 6] = [
1775    "noFill",
1776    "solidFill",
1777    "gradFill",
1778    "blipFill",
1779    "pattFill",
1780    "grpFill",
1781];
1782const POST_FILL_ELEMENTS: [&str; 6] = ["ln", "effectLst", "effectDag", "scene3d", "sp3d", "extLst"];
1783
1784fn set_fill(
1785    properties: &mut XmlElement,
1786    fill: &ShapeFill,
1787    prefixes: &Prefixes,
1788    part: &str,
1789) -> Result<(), PptxError> {
1790    let element = fill_element(fill, prefixes, part)?;
1791    let existing = properties.children.iter().position(|child| {
1792        matches!(child, XmlNode::Element(element) if FILL_ELEMENTS.contains(&element.local_name()))
1793    });
1794    match existing {
1795        Some(index) => properties.children[index] = XmlNode::Element(element),
1796        None => {
1797            let position = properties
1798                .children
1799                .iter()
1800                .position(|child| {
1801                    matches!(
1802                        child,
1803                        XmlNode::Element(element)
1804                            if POST_FILL_ELEMENTS.contains(&element.local_name())
1805                    )
1806                })
1807                .unwrap_or(properties.children.len());
1808            properties
1809                .children
1810                .insert(position, XmlNode::Element(element));
1811        }
1812    }
1813    Ok(())
1814}
1815
1816fn fill_element(
1817    fill: &ShapeFill,
1818    prefixes: &Prefixes,
1819    part: &str,
1820) -> Result<XmlElement, PptxError> {
1821    match fill.fill_type.as_str() {
1822        "none" => Ok(XmlElement::new(prefixes.drawing("noFill"))),
1823        "solid" => {
1824            let color = fill
1825                .color
1826                .as_ref()
1827                .and_then(|color| color_element(color, prefixes))
1828                .ok_or_else(|| write_error(part, "solid fill without a serializable color"))?;
1829            Ok(XmlElement::new(prefixes.drawing("solidFill")).with_child(color))
1830        }
1831        "gradient" => {
1832            let gradient = fill
1833                .gradient
1834                .as_ref()
1835                .ok_or_else(|| write_error(part, "gradient fill without stops"))?;
1836            gradient_element(gradient, prefixes)
1837                .ok_or_else(|| write_error(part, "gradient stop without a color"))
1838        }
1839        other => Err(write_error(
1840            part,
1841            format!("unsupported fill type {other:?}"),
1842        )),
1843    }
1844}
1845
1846fn gradient_element(gradient: &GradientFill, prefixes: &Prefixes) -> Option<XmlElement> {
1847    let mut stops = XmlElement::new(prefixes.drawing("gsLst"));
1848    for stop in &gradient.stops {
1849        stops = stops.with_child(
1850            XmlElement::new(prefixes.drawing("gs"))
1851                .with_attribute("pos", format_fixed(stop.position))
1852                .with_child(color_element(&stop.color, prefixes)?),
1853        );
1854    }
1855    let mut element = XmlElement::new(prefixes.drawing("gradFill")).with_child(stops);
1856    match gradient.gradient_type.as_str() {
1857        "linear" => {
1858            element = element.with_child(
1859                XmlElement::new(prefixes.drawing("lin"))
1860                    .with_attribute(
1861                        "ang",
1862                        format_fixed(gradient.angle.unwrap_or_default() * 60_000.0),
1863                    )
1864                    .with_attribute("scaled", "1"),
1865            );
1866        }
1867        kind => {
1868            let path = match kind {
1869                "radial" => "circle",
1870                "rectangular" => "rect",
1871                _ => "shape",
1872            };
1873            element = element
1874                .with_child(XmlElement::new(prefixes.drawing("path")).with_attribute("path", path));
1875        }
1876    }
1877    Some(element)
1878}
1879
1880fn color_element(color: &ColorValue, prefixes: &Prefixes) -> Option<XmlElement> {
1881    let mut element = if let Some(rgb) = &color.rgb {
1882        XmlElement::new(prefixes.drawing("srgbClr")).with_attribute("val", rgb.clone())
1883    } else if let Some(theme) = &color.theme_color {
1884        XmlElement::new(prefixes.drawing("schemeClr"))
1885            .with_attribute("val", denormalize_scheme_color(theme))
1886    } else {
1887        return None;
1888    };
1889    let fractions = [
1890        ("alpha", color.alpha),
1891        ("lumMod", color.luminance_modulation),
1892        ("lumOff", color.luminance_offset),
1893        ("satMod", color.saturation_modulation),
1894    ];
1895    for (name, value) in fractions {
1896        if let Some(value) = value {
1897            element = element.with_child(
1898                XmlElement::new(prefixes.drawing(name))
1899                    .with_attribute("val", format_fixed(value * 100_000.0)),
1900            );
1901        }
1902    }
1903    let modifiers = [("tint", &color.theme_tint), ("shade", &color.theme_shade)];
1904    for (name, value) in modifiers {
1905        if let Some(byte) = value
1906            .as_deref()
1907            .and_then(|value| u8::from_str_radix(value, 16).ok())
1908        {
1909            element = element.with_child(
1910                XmlElement::new(prefixes.drawing(name))
1911                    .with_attribute("val", format_fixed(f64::from(byte) / 255.0 * 100_000.0)),
1912            );
1913        }
1914    }
1915    Some(element)
1916}
1917
1918fn denormalize_scheme_color(value: &str) -> String {
1919    match value {
1920        "text1" => "tx1",
1921        "text2" => "tx2",
1922        "background1" => "bg1",
1923        "background2" => "bg2",
1924        value => value,
1925    }
1926    .to_owned()
1927}
1928
1929fn format_fixed(value: f64) -> String {
1930    (value.round() as i64).to_string()
1931}
1932
1933fn set_outline(properties: &mut XmlElement, outline: &ShapeOutline, prefixes: &Prefixes) {
1934    if let Some(line) = properties.child_mut("ln")
1935        && let Some(mut original) = crate::drawing::parse_outline_element(line)
1936    {
1937        original.width = outline.width;
1938        if original == *outline {
1939            if let Some(width) = outline.width {
1940                line.set_attribute("w", format_fixed(width));
1941            } else {
1942                line.attributes.remove("w");
1943            }
1944            return;
1945        }
1946    }
1947    let element = outline_element(outline, prefixes);
1948    let existing = properties.children.iter().position(
1949        |child| matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
1950    );
1951    match existing {
1952        Some(index) => properties.children[index] = XmlNode::Element(element),
1953        None => {
1954            let position = properties
1955                .children
1956                .iter()
1957                .position(|child| {
1958                    matches!(
1959                        child,
1960                        XmlNode::Element(element)
1961                            if POST_FILL_ELEMENTS[1..].contains(&element.local_name())
1962                    )
1963                })
1964                .unwrap_or(properties.children.len());
1965            properties
1966                .children
1967                .insert(position, XmlNode::Element(element));
1968        }
1969    }
1970}
1971
1972fn outline_element(outline: &ShapeOutline, prefixes: &Prefixes) -> XmlElement {
1973    let mut element = XmlElement::new(prefixes.drawing("ln"));
1974    if *outline == ShapeOutline::default() {
1975        return element.with_child(XmlElement::new(prefixes.drawing("noFill")));
1976    }
1977    if let Some(width) = outline.width {
1978        element.set_attribute("w", format_fixed(width));
1979    }
1980    if let Some(cap) = &outline.cap {
1981        element.set_attribute("cap", cap.clone());
1982    }
1983    if let Some(color) = outline
1984        .color
1985        .as_ref()
1986        .and_then(|color| color_element(color, prefixes))
1987    {
1988        element =
1989            element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
1990    } else if let Some(gradient) = outline
1991        .gradient
1992        .as_ref()
1993        .and_then(|gradient| gradient_element(gradient, prefixes))
1994    {
1995        element = element.with_child(gradient);
1996    }
1997    if let Some(style) = &outline.style {
1998        element = element.with_child(
1999            XmlElement::new(prefixes.drawing("prstDash")).with_attribute("val", style.clone()),
2000        );
2001    }
2002    if let Some(join) = &outline.join {
2003        element = element.with_child(XmlElement::new(prefixes.drawing(join)));
2004    }
2005    let ends = [
2006        ("headEnd", &outline.head_end),
2007        ("tailEnd", &outline.tail_end),
2008    ];
2009    for (name, end) in ends {
2010        if let Some(end) = end {
2011            let mut end_element = XmlElement::new(prefixes.drawing(name))
2012                .with_attribute("type", end.end_type.clone());
2013            if let Some(width) = &end.width {
2014                end_element.set_attribute("w", width.clone());
2015            }
2016            if let Some(length) = &end.length {
2017                end_element.set_attribute("len", length.clone());
2018            }
2019            element = element.with_child(end_element);
2020        }
2021    }
2022    element
2023}
2024
2025fn set_adjust_values(
2026    properties: &mut XmlElement,
2027    adjust_values: &BTreeMap<String, f64>,
2028    prefixes: &Prefixes,
2029    part: &str,
2030) -> Result<(), PptxError> {
2031    let Some(geometry) = properties.child_mut("prstGeom") else {
2032        return Err(write_error(
2033            part,
2034            "cannot write adjustments onto a shape without preset geometry",
2035        ));
2036    };
2037    let list_name = prefixes.drawing("avLst");
2038    if geometry.child_mut("avLst").is_none() {
2039        geometry
2040            .children
2041            .insert(0, XmlNode::Element(XmlElement::new(list_name)));
2042    }
2043    let list = geometry.child_mut("avLst").expect("list ensured above");
2044    // Guides absent from the model (unevaluated formulas) are kept as-is.
2045    for (name, value) in adjust_values {
2046        let formula = format!("val {}", format_fixed(value * 100_000.0));
2047        let existing = list.children.iter_mut().find_map(|child| match child {
2048            XmlNode::Element(element)
2049                if element.local_name() == "gd" && element.attribute("name") == Some(name) =>
2050            {
2051                Some(element)
2052            }
2053            _ => None,
2054        });
2055        match existing {
2056            Some(guide) => guide.set_attribute("fmla", formula),
2057            None => list.children.push(XmlNode::Element(
2058                XmlElement::new(prefixes.drawing("gd"))
2059                    .with_attribute("name", name.clone())
2060                    .with_attribute("fmla", formula),
2061            )),
2062        }
2063    }
2064    Ok(())
2065}
2066
2067// --- text -------------------------------------------------------------------
2068
2069fn patch_text(
2070    element: &mut XmlElement,
2071    text: &TextWrite,
2072    theme: Option<&Theme>,
2073    prefixes: &Prefixes,
2074    part: &str,
2075) -> Result<(), PptxError> {
2076    let body = match &text.target {
2077        TextTarget::Body => element.child_mut("txBody"),
2078        TextTarget::TableCell { row, cell } => element
2079            .child_mut("graphic")
2080            .and_then(|graphic| graphic.child_mut("graphicData"))
2081            .and_then(|data| data.child_mut("tbl"))
2082            .and_then(|table| nth_child_mut(table, "tr", *row))
2083            .and_then(|table_row| nth_child_mut(table_row, "tc", *cell))
2084            .and_then(|table_cell| table_cell.child_mut("txBody")),
2085    };
2086    let body = body.ok_or_else(|| write_error(part, "text target has no body"))?;
2087    rebuild_paragraphs(body, &text.paragraphs, theme, prefixes);
2088    Ok(())
2089}
2090
2091fn nth_child_mut<'a>(
2092    parent: &'a mut XmlElement,
2093    local: &str,
2094    index: usize,
2095) -> Option<&'a mut XmlElement> {
2096    parent
2097        .children
2098        .iter_mut()
2099        .filter_map(|child| match child {
2100            XmlNode::Element(element) if element.local_name() == local => Some(element),
2101            _ => None,
2102        })
2103        .nth(index)
2104}
2105
2106fn rebuild_paragraphs(
2107    body: &mut XmlElement,
2108    paragraphs: &[ParagraphWrite],
2109    theme: Option<&Theme>,
2110    prefixes: &Prefixes,
2111) {
2112    let mut preamble = Vec::new();
2113    let mut originals: Vec<Option<XmlElement>> = Vec::new();
2114    for child in std::mem::take(&mut body.children) {
2115        match child {
2116            XmlNode::Element(element) if element.local_name() == "p" => {
2117                originals.push(Some(element));
2118            }
2119            XmlNode::Text(text) if text.trim().is_empty() => {}
2120            other => preamble.push(other),
2121        }
2122    }
2123    let mut children = preamble;
2124    for paragraph in paragraphs {
2125        let source = paragraph
2126            .source_index
2127            .and_then(|index| originals.get_mut(index).and_then(Option::take));
2128        let element = match source {
2129            Some(element) if !paragraph.rebuild => element,
2130            source => build_paragraph(paragraph, source, theme, prefixes),
2131        };
2132        children.push(XmlNode::Element(element));
2133    }
2134    if children
2135        .iter()
2136        .all(|child| !matches!(child, XmlNode::Element(element) if element.local_name() == "p"))
2137    {
2138        children.push(XmlNode::Element(XmlElement::new(prefixes.drawing("p"))));
2139    }
2140    body.children = children;
2141}
2142
2143fn is_run_element(local: &str) -> bool {
2144    matches!(local, "r" | "br" | "fld")
2145}
2146
2147/// One piece of the paragraph's target text: a line break or a styled span.
2148struct RunSegment<'a> {
2149    line_break: bool,
2150    text: &'a str,
2151    properties: &'a RunProperties,
2152}
2153
2154fn run_segments(runs: &[RunWrite]) -> Vec<RunSegment<'_>> {
2155    let mut segments = Vec::new();
2156    for run in runs {
2157        for (index, piece) in run.text.split('\n').enumerate() {
2158            if index > 0 {
2159                segments.push(RunSegment {
2160                    line_break: true,
2161                    text: "",
2162                    properties: &run.properties,
2163                });
2164            }
2165            if !piece.is_empty() {
2166                segments.push(RunSegment {
2167                    line_break: false,
2168                    text: piece,
2169                    properties: &run.properties,
2170                });
2171            }
2172        }
2173    }
2174    segments
2175}
2176
2177/// Matches text and modeled styling against a source run.
2178fn segment_matches(element: &XmlElement, segment: &RunSegment<'_>, theme: Option<&Theme>) -> bool {
2179    if element.local_name() == "br" {
2180        return segment.line_break;
2181    }
2182    if segment.line_break {
2183        return false;
2184    }
2185    let text = element
2186        .child("t")
2187        .map(XmlElement::text_content)
2188        .unwrap_or_default();
2189    if text != segment.text {
2190        return false;
2191    }
2192    let source = parse_run_properties(element.child("rPr"));
2193    let target = segment.properties;
2194    source.baseline_pct == target.baseline_pct
2195        && source.bold == target.bold
2196        && source.italic == target.italic
2197        && source.font_size_pt == target.font_size_pt
2198        && source.spacing_pt == target.spacing_pt
2199        && source.underline == target.underline
2200        && source.caps == target.caps
2201        && source.font_family == target.font_family
2202        && resolve_color_value_to_hex_with_theme(source.color.as_ref(), theme)
2203            == resolve_color_value_to_hex_with_theme(target.color.as_ref(), theme)
2204}
2205
2206fn build_paragraph(
2207    write: &ParagraphWrite,
2208    source: Option<XmlElement>,
2209    theme: Option<&Theme>,
2210    prefixes: &Prefixes,
2211) -> XmlElement {
2212    let (mut paragraph, source_runs) = match source {
2213        Some(mut element) => {
2214            let mut runs = Vec::new();
2215            let mut kept = Vec::new();
2216            for child in std::mem::take(&mut element.children) {
2217                match child {
2218                    XmlNode::Element(child) if is_run_element(child.local_name()) => {
2219                        runs.push(child);
2220                    }
2221                    XmlNode::Text(text) if text.trim().is_empty() => {}
2222                    other => kept.push(other),
2223                }
2224            }
2225            element.children = kept;
2226            (element, runs)
2227        }
2228        None => (XmlElement::new(prefixes.drawing("p")), Vec::new()),
2229    };
2230    let fresh = write.source_index.is_none();
2231    if write.properties_changed || fresh {
2232        apply_paragraph_properties(&mut paragraph, write, prefixes);
2233    }
2234    let segments = run_segments(&write.runs);
2235    let mut front = 0;
2236    let mut back = 0;
2237    while front < source_runs.len()
2238        && front < segments.len()
2239        && segment_matches(&source_runs[front], &segments[front], theme)
2240    {
2241        let steals = front + 1 < source_runs.len() - back
2242            && segment_matches(&source_runs[front + 1], &segments[front], theme)
2243            && source_span_len(&source_runs, front + 1..source_runs.len() - back)
2244                > target_span_len(&segments, front + 1..segments.len() - back);
2245        if steals {
2246            break;
2247        }
2248        front += 1;
2249    }
2250    while back < source_runs.len() - front
2251        && back < segments.len() - front
2252        && segment_matches(
2253            &source_runs[source_runs.len() - 1 - back],
2254            &segments[segments.len() - 1 - back],
2255            theme,
2256        )
2257    {
2258        let src = source_runs.len() - 1 - back;
2259        let seg = segments.len() - 1 - back;
2260        let steals = src > front
2261            && segment_matches(&source_runs[src - 1], &segments[seg], theme)
2262            && source_span_len(&source_runs, front..src) > target_span_len(&segments, front..seg);
2263        if steals {
2264            break;
2265        }
2266        back += 1;
2267    }
2268    let tail_start = source_runs.len() - back;
2269    let rebuilt = span_elements(
2270        &segments[front..segments.len() - back],
2271        &source_runs,
2272        front..tail_start,
2273        theme,
2274        prefixes,
2275    );
2276    let mut runs: Vec<XmlNode> = Vec::with_capacity(segments.len());
2277    let mut source_runs = source_runs.into_iter();
2278    for element in source_runs.by_ref().take(front) {
2279        runs.push(XmlNode::Element(element));
2280    }
2281    runs.extend(rebuilt);
2282    for element in source_runs.skip(tail_start - front) {
2283        runs.push(XmlNode::Element(element));
2284    }
2285    let position = paragraph
2286        .children
2287        .iter()
2288        .position(|child| {
2289            matches!(child, XmlNode::Element(element) if element.local_name() == "endParaRPr")
2290        })
2291        .unwrap_or(paragraph.children.len());
2292    paragraph.children.splice(position..position, runs);
2293    paragraph
2294}
2295
2296/// Target slice rebuilt onto one source run.
2297struct TargetRange {
2298    end: usize,
2299    source: usize,
2300    /// Source text kept unchanged, in order.
2301    verbatim: bool,
2302    /// Intact `a:fld` kept as a field.
2303    field: bool,
2304}
2305
2306/// Rebuilds the span onto source runs, preserving unmodeled markup.
2307fn span_elements(
2308    segments: &[RunSegment<'_>],
2309    source_runs: &[XmlElement],
2310    span: Range<usize>,
2311    theme: Option<&Theme>,
2312    prefixes: &Prefixes,
2313) -> Vec<XmlNode> {
2314    if source_runs.is_empty() {
2315        return segments
2316            .iter()
2317            .map(|segment| XmlNode::Element(segment_element(segment, None, theme, prefixes)))
2318            .collect();
2319    }
2320    let ranges = align_span(segments, source_runs, span);
2321    let mut output = Vec::new();
2322    let mut offset = 0;
2323    let mut range_index = 0;
2324    let mut range_start = 0;
2325    for segment in segments {
2326        let mut remaining = segment.text;
2327        loop {
2328            while ranges[range_index].end <= offset {
2329                range_start = ranges[range_index].end;
2330                range_index += 1;
2331            }
2332            let range = &ranges[range_index];
2333            let source = &source_runs[range.source];
2334            if segment.line_break {
2335                output.push(XmlNode::Element(segment_element(
2336                    segment,
2337                    source.child("rPr"),
2338                    theme,
2339                    prefixes,
2340                )));
2341                offset += 1;
2342                break;
2343            }
2344            let length = remaining.len().min(range.end - offset);
2345            let piece = RunSegment {
2346                text: &remaining[..length],
2347                ..*segment
2348            };
2349            let element = if range.field && offset == range_start && offset + length == range.end {
2350                field_element(source, &piece, theme, prefixes)
2351            } else {
2352                segment_element(&piece, source.child("rPr"), theme, prefixes)
2353            };
2354            output.push(XmlNode::Element(element));
2355            offset += length;
2356            remaining = &remaining[length..];
2357            if remaining.is_empty() {
2358                break;
2359            }
2360        }
2361    }
2362    output
2363}
2364
2365fn run_text(run: &XmlElement) -> String {
2366    if run.local_name() == "br" {
2367        return "\n".to_owned();
2368    }
2369    run.child("t")
2370        .map(XmlElement::text_content)
2371        .unwrap_or_default()
2372}
2373
2374fn segment_text<'a>(segment: &RunSegment<'a>) -> &'a str {
2375    if segment.line_break {
2376        "\n"
2377    } else {
2378        segment.text
2379    }
2380}
2381
2382fn source_span_len(runs: &[XmlElement], span: Range<usize>) -> usize {
2383    runs[span].iter().map(|run| run_text(run).len()).sum()
2384}
2385
2386fn target_span_len(segments: &[RunSegment<'_>], span: Range<usize>) -> usize {
2387    segments[span]
2388        .iter()
2389        .map(|segment| segment_text(segment).len())
2390        .sum()
2391}
2392
2393fn push_range(ranges: &mut Vec<TargetRange>, end: usize, source: usize, verbatim: bool) {
2394    if end <= ranges.last().map_or(0, |last| last.end) {
2395        return;
2396    }
2397    match ranges.last_mut() {
2398        Some(last) if last.source == source && last.verbatim == verbatim => last.end = end,
2399        _ => ranges.push(TargetRange {
2400            end,
2401            source,
2402            verbatim,
2403            field: false,
2404        }),
2405    }
2406}
2407
2408fn align_span(
2409    segments: &[RunSegment<'_>],
2410    source_runs: &[XmlElement],
2411    span: Range<usize>,
2412) -> Vec<TargetRange> {
2413    let runs = &source_runs[span.clone()];
2414    let mut source_text = String::new();
2415    let mut source_ends = Vec::with_capacity(runs.len());
2416    for run in runs {
2417        source_text.push_str(&run_text(run));
2418        source_ends.push(source_text.len());
2419    }
2420    let target_text: String = segments.iter().map(segment_text).collect();
2421    let run_at = |offset: usize| {
2422        span.start
2423            + source_ends
2424                .iter()
2425                .position(|&end| end > offset)
2426                .unwrap_or(source_ends.len().saturating_sub(1))
2427    };
2428    let prefix: usize = source_text
2429        .chars()
2430        .zip(target_text.chars())
2431        .take_while(|(left, right)| left == right)
2432        .map(|(value, _)| value.len_utf8())
2433        .sum();
2434    let suffix: usize = source_text[prefix..]
2435        .chars()
2436        .rev()
2437        .zip(target_text[prefix..].chars().rev())
2438        .take_while(|(left, right)| left == right)
2439        .map(|(value, _)| value.len_utf8())
2440        .sum();
2441    let source_end = source_text.len() - suffix;
2442    let target_end = target_text.len() - suffix;
2443
2444    let mut ranges: Vec<TargetRange> = Vec::new();
2445    for (index, &end) in source_ends.iter().enumerate() {
2446        push_range(&mut ranges, end.min(prefix), span.start + index, true);
2447    }
2448    let seed = span.start.saturating_sub(1);
2449    let (mut source_offset, mut target_offset) = (prefix, prefix);
2450    let mut replaced: Option<usize> = None;
2451    for op in char_diff(
2452        &source_text[prefix..source_end],
2453        &target_text[prefix..target_end],
2454    ) {
2455        match op {
2456            DiffOp::Match(length) => {
2457                let mut done = 0;
2458                while done < length {
2459                    let run = run_at(source_offset + done);
2460                    let piece =
2461                        (source_ends[run - span.start] - (source_offset + done)).min(length - done);
2462                    push_range(&mut ranges, target_offset + done + piece, run, true);
2463                    done += piece;
2464                }
2465                source_offset += length;
2466                target_offset += length;
2467                replaced = None;
2468            }
2469            DiffOp::Delete(length) => {
2470                if replaced.is_none() {
2471                    replaced = Some(run_at(source_offset));
2472                }
2473                source_offset += length;
2474            }
2475            DiffOp::Insert(length) => {
2476                let source = replaced
2477                    .take()
2478                    .or_else(|| ranges.last().map(|range| range.source))
2479                    .unwrap_or(seed);
2480                push_range(&mut ranges, target_offset + length, source, false);
2481                target_offset += length;
2482            }
2483        }
2484    }
2485    for (index, &end) in source_ends.iter().enumerate() {
2486        if end > source_end {
2487            push_range(
2488                &mut ranges,
2489                target_end + end - source_end,
2490                span.start + index,
2491                true,
2492            );
2493        }
2494    }
2495    let mut kept = vec![false; runs.len()];
2496    for range in &ranges {
2497        if range.verbatim {
2498            kept[range.source - span.start] = true;
2499        }
2500    }
2501    for field_idx in 0..runs.len() {
2502        if runs[field_idx].local_name() != "fld" || kept[field_idx] {
2503            continue;
2504        }
2505        let field_text = run_text(&runs[field_idx]);
2506        if field_text.is_empty() {
2507            continue;
2508        }
2509        let mut donor: Option<usize> = None;
2510        for candidate in [
2511            field_idx.checked_sub(1),
2512            field_idx.checked_add(1).filter(|next| *next < runs.len()),
2513        ]
2514        .into_iter()
2515        .flatten()
2516        {
2517            if runs[candidate].local_name() == "r"
2518                && kept[candidate]
2519                && run_text(&runs[candidate]) == field_text
2520            {
2521                donor = Some(candidate);
2522                break;
2523            }
2524        }
2525        if let Some(donor) = donor {
2526            for range in &mut ranges {
2527                if range.verbatim && range.source == span.start + donor {
2528                    range.source = span.start + field_idx;
2529                }
2530            }
2531            kept[donor] = false;
2532            kept[field_idx] = true;
2533        }
2534    }
2535
2536    let mut verbatim_ranges = vec![0usize; runs.len()];
2537    for range in ranges.iter().filter(|range| range.verbatim) {
2538        verbatim_ranges[range.source - span.start] += 1;
2539    }
2540    let mut merged: Vec<TargetRange> = Vec::with_capacity(ranges.len());
2541    let mut start = 0;
2542    for mut range in ranges {
2543        range.field = range.verbatim && {
2544            let relative = range.source - span.start;
2545            let run_start = relative
2546                .checked_sub(1)
2547                .map_or(0, |previous| source_ends[previous]);
2548            runs[relative].local_name() == "fld"
2549                && verbatim_ranges[relative] == 1
2550                && range.end - start == source_ends[relative] - run_start
2551        };
2552        start = range.end;
2553        match merged.last_mut() {
2554            Some(last) if last.source == range.source && !last.field && !range.field => {
2555                last.end = range.end
2556            }
2557            _ => merged.push(range),
2558        }
2559    }
2560    merged
2561}
2562
2563/// Diff step in bytes.
2564enum DiffOp {
2565    Match(usize),
2566    Delete(usize),
2567    Insert(usize),
2568}
2569
2570const DIFF_CELL_LIMIT: usize = 1 << 20;
2571
2572fn push_op(ops: &mut Vec<DiffOp>, op: DiffOp) {
2573    match (ops.last_mut(), op) {
2574        (Some(DiffOp::Match(last)), DiffOp::Match(length))
2575        | (Some(DiffOp::Delete(last)), DiffOp::Delete(length))
2576        | (Some(DiffOp::Insert(last)), DiffOp::Insert(length)) => *last += length,
2577        (_, op) => ops.push(op),
2578    }
2579}
2580
2581/// LCS alignment; ties delete first, oversized counts as replacement.
2582fn char_diff(source: &str, target: &str) -> Vec<DiffOp> {
2583    let source_chars: Vec<char> = source.chars().collect();
2584    let target_chars: Vec<char> = target.chars().collect();
2585    let (rows, columns) = (source_chars.len(), target_chars.len());
2586    let mut ops = Vec::new();
2587    if rows == 0 || columns == 0 || rows.saturating_mul(columns) > DIFF_CELL_LIMIT {
2588        if rows > 0 {
2589            push_op(&mut ops, DiffOp::Delete(source.len()));
2590        }
2591        if columns > 0 {
2592            push_op(&mut ops, DiffOp::Insert(target.len()));
2593        }
2594        return ops;
2595    }
2596    let width = columns + 1;
2597    let mut table = vec![0u16; (rows + 1) * width];
2598    for row in (0..rows).rev() {
2599        for column in (0..columns).rev() {
2600            table[row * width + column] = if source_chars[row] == target_chars[column] {
2601                table[(row + 1) * width + column + 1] + 1
2602            } else {
2603                table[(row + 1) * width + column].max(table[row * width + column + 1])
2604            };
2605        }
2606    }
2607    let (mut row, mut column) = (0, 0);
2608    while row < rows && column < columns {
2609        if source_chars[row] == target_chars[column] {
2610            push_op(&mut ops, DiffOp::Match(source_chars[row].len_utf8()));
2611            row += 1;
2612            column += 1;
2613        } else if table[(row + 1) * width + column] >= table[row * width + column + 1] {
2614            push_op(&mut ops, DiffOp::Delete(source_chars[row].len_utf8()));
2615            row += 1;
2616        } else {
2617            push_op(&mut ops, DiffOp::Insert(target_chars[column].len_utf8()));
2618            column += 1;
2619        }
2620    }
2621    for value in &source_chars[row..] {
2622        push_op(&mut ops, DiffOp::Delete(value.len_utf8()));
2623    }
2624    for value in &target_chars[column..] {
2625        push_op(&mut ops, DiffOp::Insert(value.len_utf8()));
2626    }
2627    ops
2628}
2629
2630fn segment_properties(
2631    segment: &RunSegment<'_>,
2632    template: Option<&XmlElement>,
2633    theme: Option<&Theme>,
2634    prefixes: &Prefixes,
2635) -> Option<XmlElement> {
2636    match template.filter(|template| !segment.line_break || template.child("gradFill").is_some()) {
2637        Some(template) => {
2638            let mut base = template.clone();
2639            apply_run_properties(&mut base, segment.properties, theme, prefixes);
2640            Some(base)
2641        }
2642        None => run_properties_element(segment.properties, prefixes),
2643    }
2644}
2645
2646fn segment_element(
2647    segment: &RunSegment<'_>,
2648    template: Option<&XmlElement>,
2649    theme: Option<&Theme>,
2650    prefixes: &Prefixes,
2651) -> XmlElement {
2652    let mut element =
2653        XmlElement::new(prefixes.drawing(if segment.line_break { "br" } else { "r" }));
2654    if let Some(properties) = segment_properties(segment, template, theme, prefixes) {
2655        element = element.with_child(properties);
2656    }
2657    if segment.line_break {
2658        element
2659    } else {
2660        element.with_child(XmlElement::new(prefixes.drawing("t")).with_text(segment.text))
2661    }
2662}
2663
2664/// Intact `a:fld` keeps its binding.
2665fn field_element(
2666    source: &XmlElement,
2667    segment: &RunSegment<'_>,
2668    theme: Option<&Theme>,
2669    prefixes: &Prefixes,
2670) -> XmlElement {
2671    let mut element = XmlElement::new(source.name.clone());
2672    element.attributes = source.attributes.clone();
2673    if let Some(properties) = segment_properties(segment, source.child("rPr"), theme, prefixes) {
2674        element.children.push(XmlNode::Element(properties));
2675    }
2676    element.children.extend(
2677        source
2678            .child_elements()
2679            .filter(|child| !matches!(child.local_name(), "rPr" | "t"))
2680            .cloned()
2681            .map(XmlNode::Element),
2682    );
2683    element.children.push(XmlNode::Element(
2684        XmlElement::new(prefixes.drawing("t")).with_text(segment.text),
2685    ));
2686    element
2687}
2688
2689const POST_LATIN_ELEMENTS: [&str; 7] = [
2690    "ea",
2691    "cs",
2692    "sym",
2693    "hlinkClick",
2694    "hlinkMouseOver",
2695    "rtl",
2696    "extLst",
2697];
2698
2699/// Updates modeled styling while preserving unmodeled XML.
2700fn apply_run_properties(
2701    base: &mut XmlElement,
2702    properties: &RunProperties,
2703    theme: Option<&Theme>,
2704    prefixes: &Prefixes,
2705) {
2706    match properties.font_size_pt {
2707        Some(size) => base.set_attribute("sz", format_fixed(size * 100.0)),
2708        None => {
2709            base.attributes.remove("sz");
2710        }
2711    }
2712    match properties.spacing_pt {
2713        Some(spacing) => base.set_attribute("spc", format_fixed(spacing * 100.0)),
2714        None => {
2715            base.attributes.remove("spc");
2716        }
2717    }
2718    match properties.baseline_pct {
2719        Some(baseline) => base.set_attribute("baseline", format_fixed(baseline * 1000.0)),
2720        None => {
2721            base.attributes.remove("baseline");
2722        }
2723    }
2724    match properties.caps {
2725        Some(caps) => base.set_attribute("cap", caps.as_attribute()),
2726        None => {
2727            base.attributes.remove("cap");
2728        }
2729    }
2730    let toggles = [("b", properties.bold), ("i", properties.italic)];
2731    for (name, value) in toggles {
2732        match value {
2733            Some(value) => base.set_attribute(name, if value { "1" } else { "0" }),
2734            None => {
2735                base.attributes.remove(name);
2736            }
2737        }
2738    }
2739    match &properties.underline {
2740        Some(underline) => base.set_attribute("u", underline.clone()),
2741        None => {
2742            base.attributes.remove("u");
2743        }
2744    }
2745    let keeps_gradient = properties.color.as_ref().is_some_and(|color| {
2746        base.child("gradFill")
2747            .and_then(crate::drawing::run_gradient_color)
2748            .is_some_and(|authored| {
2749                &authored == color
2750                    || resolve_color_value_to_hex_with_theme(Some(&authored), theme).is_some_and(
2751                        |authored| {
2752                            Some(authored)
2753                                == resolve_color_value_to_hex_with_theme(Some(color), theme)
2754                        },
2755                    )
2756            })
2757    });
2758    // A colour write replaces the whole fill choice; clearing the colour only
2759    // drops an explicit solidFill so an unmodeled noFill/gradFill survives.
2760    let removed_fills: &[&str] = if keeps_gradient {
2761        &[]
2762    } else if properties.color.is_some() {
2763        &FILL_ELEMENTS
2764    } else {
2765        &["solidFill"]
2766    };
2767    base.children.retain(|child| {
2768        !matches!(
2769            child,
2770            XmlNode::Element(element) if removed_fills.contains(&element.local_name())
2771        )
2772    });
2773    if let Some(color) = properties
2774        .color
2775        .as_ref()
2776        .filter(|_| !keeps_gradient)
2777        .and_then(|color| color_element(color, prefixes))
2778    {
2779        let position = base
2780            .children
2781            .iter()
2782            .position(
2783                |child| !matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
2784            )
2785            .unwrap_or(base.children.len());
2786        base.children.insert(
2787            position,
2788            XmlNode::Element(XmlElement::new(prefixes.drawing("solidFill")).with_child(color)),
2789        );
2790    }
2791    base.children.retain(|child| {
2792        !matches!(
2793            child,
2794            XmlNode::Element(element) if element.local_name() == "latin"
2795        )
2796    });
2797    if let Some(family) = &properties.font_family {
2798        let position = base
2799            .children
2800            .iter()
2801            .position(|child| {
2802                matches!(
2803                    child,
2804                    XmlNode::Element(element)
2805                        if POST_LATIN_ELEMENTS.contains(&element.local_name())
2806                )
2807            })
2808            .unwrap_or(base.children.len());
2809        base.children.insert(
2810            position,
2811            XmlNode::Element(
2812                XmlElement::new(prefixes.drawing("latin"))
2813                    .with_attribute("typeface", family.clone()),
2814            ),
2815        );
2816    }
2817}
2818
2819fn apply_paragraph_properties(
2820    paragraph: &mut XmlElement,
2821    write: &ParagraphWrite,
2822    prefixes: &Prefixes,
2823) {
2824    let needs_properties = write.alignment.is_some() || write.level > 0 || write.bullet.is_some();
2825    if paragraph.child_mut("pPr").is_none() {
2826        if !needs_properties {
2827            return;
2828        }
2829        paragraph.children.insert(
2830            0,
2831            XmlNode::Element(XmlElement::new(prefixes.drawing("pPr"))),
2832        );
2833    }
2834    let properties = paragraph.child_mut("pPr").expect("ensured above");
2835    match &write.alignment {
2836        Some(alignment) => properties.set_attribute("algn", alignment.clone()),
2837        None => {
2838            properties.attributes.remove("algn");
2839        }
2840    }
2841    if write.level > 0 {
2842        properties.set_attribute("lvl", write.level.to_string());
2843    } else {
2844        properties.attributes.remove("lvl");
2845    }
2846    properties.children.retain(|child| {
2847        !matches!(
2848            child,
2849            XmlNode::Element(element)
2850                if matches!(element.local_name(), "buNone" | "buChar" | "buAutoNum")
2851        )
2852    });
2853    let bullet = match &write.bullet {
2854        Some(Bullet::Character { value }) => {
2855            Some(XmlElement::new(prefixes.drawing("buChar")).with_attribute("char", value.clone()))
2856        }
2857        Some(Bullet::AutoNumber {
2858            scheme,
2859            start_at,
2860            restart,
2861        }) => {
2862            let mut element = XmlElement::new(prefixes.drawing("buAutoNum"))
2863                .with_attribute("type", scheme.clone());
2864            if *restart || *start_at != 1 {
2865                element.set_attribute("startAt", start_at.to_string());
2866            }
2867            Some(element)
2868        }
2869        Some(Bullet::None) => Some(XmlElement::new(prefixes.drawing("buNone"))),
2870        None => None,
2871    };
2872    if let Some(bullet) = bullet {
2873        let position = properties
2874            .children
2875            .iter()
2876            .position(|child| {
2877                matches!(
2878                    child,
2879                    XmlNode::Element(element)
2880                        if matches!(element.local_name(), "tabLst" | "defRPr" | "extLst")
2881                )
2882            })
2883            .unwrap_or(properties.children.len());
2884        properties
2885            .children
2886            .insert(position, XmlNode::Element(bullet));
2887    }
2888}
2889
2890fn run_properties_element(properties: &RunProperties, prefixes: &Prefixes) -> Option<XmlElement> {
2891    let mut element = XmlElement::new(prefixes.drawing("rPr"));
2892    let mut present = false;
2893    if let Some(language) = &properties.language {
2894        element.set_attribute("lang", language.clone());
2895        present = true;
2896    }
2897    if let Some(size) = properties.font_size_pt {
2898        element.set_attribute("sz", format_fixed(size * 100.0));
2899        present = true;
2900    }
2901    if let Some(spacing) = properties.spacing_pt {
2902        element.set_attribute("spc", format_fixed(spacing * 100.0));
2903        present = true;
2904    }
2905    if let Some(baseline) = properties.baseline_pct {
2906        element.set_attribute("baseline", format_fixed(baseline * 1000.0));
2907        present = true;
2908    }
2909    if let Some(bold) = properties.bold {
2910        element.set_attribute("b", if bold { "1" } else { "0" });
2911        present = true;
2912    }
2913    if let Some(italic) = properties.italic {
2914        element.set_attribute("i", if italic { "1" } else { "0" });
2915        present = true;
2916    }
2917    if let Some(underline) = &properties.underline {
2918        element.set_attribute("u", underline.clone());
2919        present = true;
2920    }
2921    if let Some(caps) = properties.caps {
2922        element.set_attribute("cap", caps.as_attribute());
2923        present = true;
2924    }
2925    if let Some(color) = properties
2926        .color
2927        .as_ref()
2928        .and_then(|color| color_element(color, prefixes))
2929    {
2930        element =
2931            element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
2932        present = true;
2933    }
2934    if let Some(family) = &properties.font_family {
2935        element = element.with_child(
2936            XmlElement::new(prefixes.drawing("latin")).with_attribute("typeface", family.clone()),
2937        );
2938        present = true;
2939    }
2940    present.then_some(element)
2941}
2942
2943// --- new shapes and slides --------------------------------------------------
2944
2945fn shape_element(
2946    add: &ShapeAdd,
2947    next_shape_id: &mut Option<u32>,
2948    prefixes: &Prefixes,
2949    part: &str,
2950) -> Result<XmlElement, PptxError> {
2951    if matches!(add.geometry.as_str(), "custom" | "group") {
2952        return Err(write_error(
2953            part,
2954            format!("unsupported geometry {:?} for a new shape", add.geometry),
2955        ));
2956    }
2957    let shape_id = alloc_shape_id(next_shape_id, part)?;
2958    let non_visual = XmlElement::new(prefixes.presentation("nvSpPr"))
2959        .with_child(
2960            XmlElement::new(prefixes.presentation("cNvPr"))
2961                .with_attribute("id", shape_id.to_string())
2962                .with_attribute("name", add.name.clone()),
2963        )
2964        .with_child(XmlElement::new(prefixes.presentation("cNvSpPr")))
2965        .with_child(XmlElement::new(prefixes.presentation("nvPr")));
2966    let transform = XmlElement::new(prefixes.drawing("xfrm"))
2967        .with_child(
2968            XmlElement::new(prefixes.drawing("off"))
2969                .with_attribute("x", add.x.to_string())
2970                .with_attribute("y", add.y.to_string()),
2971        )
2972        .with_child(
2973            XmlElement::new(prefixes.drawing("ext"))
2974                .with_attribute("cx", add.width.to_string())
2975                .with_attribute("cy", add.height.to_string()),
2976        );
2977    let mut adjust_list = XmlElement::new(prefixes.drawing("avLst"));
2978    for (name, value) in &add.adjust_values {
2979        adjust_list = adjust_list.with_child(
2980            XmlElement::new(prefixes.drawing("gd"))
2981                .with_attribute("name", name.clone())
2982                .with_attribute("fmla", format!("val {}", format_fixed(value * 100_000.0))),
2983        );
2984    }
2985    let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
2986        .with_attribute("prst", add.geometry.clone())
2987        .with_child(adjust_list);
2988    let mut properties = XmlElement::new(prefixes.presentation("spPr"))
2989        .with_child(transform)
2990        .with_child(geometry);
2991    if let Some(fill) = &add.fill {
2992        properties = properties.with_child(fill_element(fill, prefixes, part)?);
2993    }
2994    if let Some(outline) = &add.outline {
2995        properties = properties.with_child(outline_element(outline, prefixes));
2996    }
2997    let mut shape = XmlElement::new(prefixes.presentation("sp"))
2998        .with_child(non_visual)
2999        .with_child(properties);
3000    if let Some(paragraphs) = &add.paragraphs {
3001        let mut body = XmlElement::new(prefixes.presentation("txBody"))
3002            .with_child(XmlElement::new(prefixes.drawing("bodyPr")))
3003            .with_child(XmlElement::new(prefixes.drawing("lstStyle")));
3004        for paragraph in paragraphs {
3005            body = body.with_child(build_paragraph(paragraph, None, None, prefixes));
3006        }
3007        if paragraphs.is_empty() {
3008            body = body.with_child(XmlElement::new(prefixes.drawing("p")));
3009        }
3010        shape = shape.with_child(body);
3011    }
3012    Ok(shape)
3013}
3014
3015/// A new shape, or a picture that also needs a media part and relationship.
3016fn add_shape_element(
3017    add: &ShapeAdd,
3018    next_shape_id: &mut Option<u32>,
3019    prefixes: &Prefixes,
3020    part: &str,
3021    sink: &mut PartSink<'_>,
3022    budget: &mut ParseBudget<'_>,
3023) -> Result<XmlElement, PptxError> {
3024    match &add.picture {
3025        Some(picture) => {
3026            picture_shape_element(add, picture, next_shape_id, prefixes, part, sink, budget)
3027        }
3028        None => shape_element(add, next_shape_id, prefixes, part),
3029    }
3030}
3031
3032fn image_content_type_extension(content_type: &str) -> Option<&'static str> {
3033    match content_type {
3034        "image/png" => Some("png"),
3035        "image/jpeg" | "image/jpg" => Some("jpeg"),
3036        "image/gif" => Some("gif"),
3037        "image/bmp" => Some("bmp"),
3038        "image/tiff" => Some("tiff"),
3039        "image/webp" => Some("webp"),
3040        "image/svg+xml" => Some("svg"),
3041        _ => None,
3042    }
3043}
3044
3045fn image_extension(content_type: &str) -> Result<&'static str, PptxError> {
3046    image_content_type_extension(content_type)
3047        .ok_or_else(|| write_error("media", format!("unsupported image type {content_type:?}")))
3048}
3049
3050/// Whether [`write_pptx_with_edits`] can mint a media part for this MIME type.
3051pub fn is_supported_image_content_type(content_type: &str) -> bool {
3052    image_content_type_extension(content_type).is_some()
3053}
3054
3055fn next_media_part_path(
3056    package: &PptxPackage,
3057    new_parts: &[(String, Vec<u8>)],
3058    extension: &str,
3059) -> String {
3060    let existing: HashSet<&str> = package
3061        .parts
3062        .iter()
3063        .map(|part| part.path.as_str())
3064        .chain(new_parts.iter().map(|(path, _)| path.as_str()))
3065        .collect();
3066    let mut number = 1_u64;
3067    while existing.contains(format!("ppt/media/image{number}.{extension}").as_str()) {
3068        number += 1;
3069    }
3070    format!("ppt/media/image{number}.{extension}")
3071}
3072
3073/// Registers the extension as a package-wide default, unless already declared.
3074fn ensure_media_content_type(
3075    sink: &mut PartSink<'_>,
3076    extension: &str,
3077    part_path: &str,
3078    content_type: &str,
3079    budget: &mut ParseBudget<'_>,
3080) -> Result<(), PptxError> {
3081    let path = "[Content_Types].xml";
3082    let bytes = sink
3083        .current(path)
3084        .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
3085    let mut root = parse_xml(&bytes, path, budget)?;
3086    let default = root.children.iter().find_map(|child| match child {
3087        XmlNode::Element(element)
3088            if element.local_name() == "Default"
3089                && element
3090                    .attribute("Extension")
3091                    .is_some_and(|value| value.eq_ignore_ascii_case(extension)) =>
3092        {
3093            Some(element)
3094        }
3095        _ => None,
3096    });
3097    if let Some(default) = default {
3098        if default.attribute("ContentType") != Some(content_type) {
3099            set_content_type_override(sink, part_path, content_type, budget)?;
3100        }
3101    } else {
3102        let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
3103        root.children.push(XmlNode::Element(
3104            XmlElement::new(qualified(prefix, "Default"))
3105                .with_attribute("Extension", extension)
3106                .with_attribute("ContentType", content_type),
3107        ));
3108        sink.store(path, serialize_xml(&root));
3109    }
3110    Ok(())
3111}
3112
3113/// Unlike [`set_relationship`], always mints a fresh one rather than
3114/// replacing an existing match, since a slide can carry many images.
3115fn add_relationship(
3116    sink: &mut PartSink<'_>,
3117    relationships_path: &str,
3118    relationship_type: &str,
3119    target: &str,
3120    budget: &mut ParseBudget<'_>,
3121) -> Result<String, PptxError> {
3122    let mut root = match sink.current(relationships_path) {
3123        Some(bytes) => parse_xml(&bytes, relationships_path, budget)?,
3124        None => XmlElement::new("Relationships").with_attribute("xmlns", PACKAGE_RELATIONSHIPS_NS),
3125    };
3126    let used: HashSet<&str> = root
3127        .children_named("Relationship")
3128        .filter_map(|element| element.attribute("Id"))
3129        .collect();
3130    let mut number = 1_u64;
3131    while used.contains(format!("rId{number}").as_str()) {
3132        number += 1;
3133    }
3134    let id = format!("rId{number}");
3135    let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
3136    root.children.push(XmlNode::Element(
3137        XmlElement::new(qualified(prefix, "Relationship"))
3138            .with_attribute("Id", id.clone())
3139            .with_attribute("Type", relationship_type)
3140            .with_attribute("Target", target),
3141    ));
3142    sink.store(relationships_path, serialize_xml(&root));
3143    Ok(id)
3144}
3145
3146fn picture_shape_element(
3147    add: &ShapeAdd,
3148    picture: &PictureAdd,
3149    next_shape_id: &mut Option<u32>,
3150    prefixes: &Prefixes,
3151    slide_part_path: &str,
3152    sink: &mut PartSink<'_>,
3153    budget: &mut ParseBudget<'_>,
3154) -> Result<XmlElement, PptxError> {
3155    let extension = image_extension(&picture.content_type)?;
3156    let media_part_path = next_media_part_path(sink.package, sink.new_parts.as_slice(), extension);
3157    sink.store(&media_part_path, picture.media_bytes.clone());
3158    ensure_media_content_type(
3159        sink,
3160        extension,
3161        &media_part_path,
3162        &picture.content_type,
3163        budget,
3164    )?;
3165    let relationships_path = slide_relationships_path(slide_part_path);
3166    let target = relative_target(slide_part_path, &media_part_path);
3167    let relationship_id = add_relationship(
3168        sink,
3169        &relationships_path,
3170        IMAGE_RELATIONSHIP_TYPE,
3171        &target,
3172        budget,
3173    )?;
3174
3175    let shape_id = alloc_shape_id(next_shape_id, slide_part_path)?;
3176    let non_visual = XmlElement::new(prefixes.presentation("nvPicPr"))
3177        .with_child(
3178            XmlElement::new(prefixes.presentation("cNvPr"))
3179                .with_attribute("id", shape_id.to_string())
3180                .with_attribute("name", add.name.clone()),
3181        )
3182        .with_child(
3183            XmlElement::new(prefixes.presentation("cNvPicPr")).with_child(
3184                XmlElement::new(prefixes.drawing("picLocks")).with_attribute("noChangeAspect", "1"),
3185            ),
3186        )
3187        .with_child(XmlElement::new(prefixes.presentation("nvPr")));
3188    let blip_fill = XmlElement::new(prefixes.presentation("blipFill"))
3189        .with_child(
3190            XmlElement::new(prefixes.drawing("blip"))
3191                .with_attribute(prefixes.relationship("embed"), relationship_id),
3192        )
3193        .with_child(
3194            XmlElement::new(prefixes.drawing("stretch"))
3195                .with_child(XmlElement::new(prefixes.drawing("fillRect"))),
3196        );
3197    let transform = XmlElement::new(prefixes.drawing("xfrm"))
3198        .with_child(
3199            XmlElement::new(prefixes.drawing("off"))
3200                .with_attribute("x", add.x.to_string())
3201                .with_attribute("y", add.y.to_string()),
3202        )
3203        .with_child(
3204            XmlElement::new(prefixes.drawing("ext"))
3205                .with_attribute("cx", add.width.to_string())
3206                .with_attribute("cy", add.height.to_string()),
3207        );
3208    let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
3209        .with_attribute("prst", "rect")
3210        .with_child(XmlElement::new(prefixes.drawing("avLst")));
3211    let properties = XmlElement::new(prefixes.presentation("spPr"))
3212        .with_child(transform)
3213        .with_child(geometry);
3214    Ok(XmlElement::new(prefixes.presentation("pic"))
3215        .with_child(non_visual)
3216        .with_child(blip_fill)
3217        .with_child(properties))
3218}
3219
3220fn slide_xml(
3221    name: Option<&str>,
3222    shapes: &[ShapeAdd],
3223    part: &str,
3224    sink: &mut PartSink<'_>,
3225    budget: &mut ParseBudget<'_>,
3226) -> Result<Vec<u8>, PptxError> {
3227    let mut root = XmlElement::new("p:sld")
3228        .with_attribute("xmlns:a", DRAWINGML_NS)
3229        .with_attribute("xmlns:r", OFFICE_RELATIONSHIPS_NS)
3230        .with_attribute("xmlns:p", PRESENTATIONML_NS);
3231    let prefixes = Prefixes {
3232        drawing: "a".to_owned(),
3233        presentation: "p".to_owned(),
3234        relationship: "r".to_owned(),
3235    };
3236    let group_transform = XmlElement::new(prefixes.drawing("xfrm"))
3237        .with_child(
3238            XmlElement::new(prefixes.drawing("off"))
3239                .with_attribute("x", "0")
3240                .with_attribute("y", "0"),
3241        )
3242        .with_child(
3243            XmlElement::new(prefixes.drawing("ext"))
3244                .with_attribute("cx", "0")
3245                .with_attribute("cy", "0"),
3246        )
3247        .with_child(
3248            XmlElement::new(prefixes.drawing("chOff"))
3249                .with_attribute("x", "0")
3250                .with_attribute("y", "0"),
3251        )
3252        .with_child(
3253            XmlElement::new(prefixes.drawing("chExt"))
3254                .with_attribute("cx", "0")
3255                .with_attribute("cy", "0"),
3256        );
3257    let mut tree = XmlElement::new(prefixes.presentation("spTree"))
3258        .with_child(
3259            XmlElement::new(prefixes.presentation("nvGrpSpPr"))
3260                .with_child(
3261                    XmlElement::new(prefixes.presentation("cNvPr"))
3262                        .with_attribute("id", "1")
3263                        .with_attribute("name", ""),
3264                )
3265                .with_child(XmlElement::new(prefixes.presentation("cNvGrpSpPr")))
3266                .with_child(XmlElement::new(prefixes.presentation("nvPr"))),
3267        )
3268        .with_child(XmlElement::new(prefixes.presentation("grpSpPr")).with_child(group_transform));
3269    let mut next_shape_id = Some(2);
3270    for shape in shapes {
3271        tree = tree.with_child(add_shape_element(
3272            shape,
3273            &mut next_shape_id,
3274            &prefixes,
3275            part,
3276            sink,
3277            budget,
3278        )?);
3279    }
3280    let mut common = XmlElement::new(prefixes.presentation("cSld"));
3281    if let Some(name) = name {
3282        common.set_attribute("name", name);
3283    }
3284    root = root.with_child(common.with_child(tree)).with_child(
3285        XmlElement::new(prefixes.presentation("clrMapOvr"))
3286            .with_child(XmlElement::new(prefixes.drawing("masterClrMapping"))),
3287    );
3288    Ok(serialize_xml(&root))
3289}
3290
3291fn slide_relationships_xml(part_path: &str, layout_part_path: &str) -> Vec<u8> {
3292    let root = XmlElement::new("Relationships")
3293        .with_attribute(
3294            "xmlns",
3295            "http://schemas.openxmlformats.org/package/2006/relationships",
3296        )
3297        .with_child(
3298            XmlElement::new("Relationship")
3299                .with_attribute("Id", "rId1")
3300                .with_attribute("Type", SLIDE_LAYOUT_RELATIONSHIP_TYPE)
3301                .with_attribute("Target", relative_target(part_path, layout_part_path)),
3302        );
3303    serialize_xml(&root)
3304}
3305
3306#[cfg(test)]
3307mod tests {
3308    use super::*;
3309    use crate::ParseLimits;
3310    use crate::drawing::parse_run_properties;
3311    use crate::model::ShapeElements;
3312    use crate::xml::{ParseBudget, parse_xml};
3313
3314    #[test]
3315    fn a_new_gradient_outline_writes_its_stops_and_angle() {
3316        let package =
3317            crate::parse_pptx(include_bytes!("../tests/fixtures/gradient-outline.pptx")).unwrap();
3318        let crate::ShapeNode::Shape(shape) = &package.slides[0].shapes[0] else {
3319            panic!("shape")
3320        };
3321        let outline = shape.outline.as_ref().unwrap();
3322        let mut properties = XmlElement::new("p:spPr");
3323        let prefixes = Prefixes::from_root(&mut properties);
3324        set_outline(&mut properties, outline, &prefixes);
3325        let line = properties.child("ln").unwrap();
3326        assert_eq!(line.attribute("w"), Some("76200"));
3327        let gradient = line.child("gradFill").unwrap();
3328        assert_eq!(gradient.child("gsLst").unwrap().child_elements().count(), 3);
3329        assert_eq!(gradient.child("lin").unwrap().attribute("ang"), Some("0"));
3330        assert!(line.child("solidFill").is_none());
3331        assert_eq!(
3332            crate::drawing::parse_outline_element(line).as_ref(),
3333            Some(outline)
3334        );
3335    }
3336
3337    #[test]
3338    fn source_ordinals_cover_the_shapes_an_alternate_content_contributes() {
3339        let limits = ParseLimits::default();
3340        let mut budget = ParseBudget::new(&limits);
3341        let part = "ppt/slides/slide1.xml";
3342        let mut root = parse_xml(
3343            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>"#,
3344            part,
3345            &mut budget,
3346        )
3347        .unwrap();
3348        let parsed = crate::drawing::common_slide_data(
3349            &root,
3350            &[],
3351            part,
3352            &mut budget,
3353            ShapeElements::WithConnectors,
3354        )
3355        .unwrap();
3356        let tree = root
3357            .child_mut("cSld")
3358            .and_then(|common| common.child_mut("spTree"))
3359            .unwrap();
3360        let slots: Vec<Option<XmlNode>> = std::mem::take(&mut tree.children)
3361            .into_iter()
3362            .map(Some)
3363            .collect();
3364
3365        let found = shape_slots(&slots, ShapeElements::WithConnectors);
3366
3367        assert_eq!(found.len(), parsed.shapes.len());
3368        let names: Vec<String> = found
3369            .iter()
3370            .map(|slot| {
3371                let mut node = slots[slot.position].clone().unwrap();
3372                let element = element_at_path(&mut node, &slot.path).unwrap();
3373                element.descendants_named("cNvPr")[0]
3374                    .attribute("name")
3375                    .unwrap()
3376                    .to_owned()
3377            })
3378            .collect();
3379        assert_eq!(names, ["one", "two", "three", "four"]);
3380    }
3381
3382    #[test]
3383    fn deleting_one_of_the_shapes_a_branch_holds_leaves_the_others_in_place() {
3384        let limits = ParseLimits::default();
3385        let mut budget = ParseBudget::new(&limits);
3386        let part = "ppt/slides/slide1.xml";
3387        let mut root = parse_xml(
3388            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>"#,
3389            part,
3390            &mut budget,
3391        )
3392        .unwrap();
3393
3394        let package = PptxPackage::default();
3395        let mut replacements = HashMap::new();
3396        let mut new_parts = Vec::new();
3397        let mut sink = PartSink {
3398            package: &package,
3399            replacements: &mut replacements,
3400            new_parts: &mut new_parts,
3401        };
3402        patch_slide(
3403            &mut root,
3404            &[
3405                ShapeWrite::Keep { source_index: 0 },
3406                ShapeWrite::Patch {
3407                    source_index: 2,
3408                    patch: Box::new(ShapePatch {
3409                        fill: Some(ShapeFill {
3410                            color: Some(ColorValue::from_attribute("DC2626")),
3411                            ..ShapeFill::named("solid")
3412                        }),
3413                        ..ShapePatch::default()
3414                    }),
3415                },
3416            ],
3417            None,
3418            part,
3419            ShapeElements::WithConnectors,
3420            &mut sink,
3421            &mut budget,
3422        )
3423        .unwrap();
3424
3425        let xml = String::from_utf8(serialize_xml(&root)).unwrap();
3426        assert!(xml.contains(r#"name="one""#));
3427        assert!(!xml.contains(r#"name="two""#), "{xml}");
3428        let survivor = xml.split(r#"name="three""#).nth(1).unwrap();
3429        assert!(survivor.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
3430    }
3431
3432    #[test]
3433    fn a_branch_that_still_holds_unmodeled_markup_outlives_its_only_shape() {
3434        let limits = ParseLimits::default();
3435        let mut budget = ParseBudget::new(&limits);
3436        let part = "ppt/slides/slide1.xml";
3437        let mut root = parse_xml(
3438            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>"#,
3439            part,
3440            &mut budget,
3441        )
3442        .unwrap();
3443
3444        let package = PptxPackage::default();
3445        let mut replacements = HashMap::new();
3446        let mut new_parts = Vec::new();
3447        let mut sink = PartSink {
3448            package: &package,
3449            replacements: &mut replacements,
3450            new_parts: &mut new_parts,
3451        };
3452        patch_slide(
3453            &mut root,
3454            &[],
3455            None,
3456            part,
3457            ShapeElements::WithoutConnectors,
3458            &mut sink,
3459            &mut budget,
3460        )
3461        .unwrap();
3462
3463        let xml = String::from_utf8(serialize_xml(&root)).unwrap();
3464        assert!(!xml.contains(r#"name="one""#), "{xml}");
3465        assert!(xml.contains(r#"name="two""#), "{xml}");
3466    }
3467
3468    fn run_properties(xml: &[u8]) -> (XmlElement, Prefixes, RunProperties) {
3469        let limits = ParseLimits::default();
3470        let mut budget = ParseBudget::new(&limits);
3471        let mut root = parse_xml(xml, "ppt/slides/slide1.xml", &mut budget).unwrap();
3472        let prefixes = Prefixes::from_root(&mut root);
3473        let properties = parse_run_properties(Some(&root));
3474        (root, prefixes, properties)
3475    }
3476
3477    #[test]
3478    fn an_unedited_gradient_fill_survives_a_rewrite() {
3479        let (mut element, prefixes, properties) = run_properties(
3480            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>"#,
3481        );
3482        assert!(
3483            properties.color.is_some(),
3484            "the gradient should flatten to a colour"
3485        );
3486        let gradient = element.child("gradFill").unwrap().clone();
3487        apply_run_properties(&mut element, &properties, None, &prefixes);
3488        assert_eq!(element.child("gradFill"), Some(&gradient));
3489        let serialized = String::from_utf8(serialize_xml(&element)).unwrap();
3490        assert!(
3491            serialized.contains("gradFill"),
3492            "the authored gradient must be left alone"
3493        );
3494        assert!(
3495            !serialized.contains("solidFill"),
3496            "it must not be rewritten as a solid fill"
3497        );
3498    }
3499
3500    #[test]
3501    fn an_edited_colour_replaces_the_gradient() {
3502        let (mut element, prefixes, mut properties) = run_properties(
3503            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>"#,
3504        );
3505        properties.color = Some(ColorValue {
3506            rgb: Some("FF0000".to_owned()),
3507            ..ColorValue::default()
3508        });
3509        apply_run_properties(&mut element, &properties, None, &prefixes);
3510        let serialized = String::from_utf8(serialize_xml(&element)).unwrap();
3511        assert!(!serialized.contains("gradFill"));
3512        assert!(serialized.contains("solidFill"));
3513        assert_eq!(parse_run_properties(Some(&element)).color, properties.color);
3514    }
3515
3516    #[test]
3517    fn the_writer_recognises_exactly_what_the_parser_reads() {
3518        for local in ["sp", "cxnSp", "pic", "graphicFrame", "grpSp"] {
3519            assert!(
3520                ShapeElements::WithConnectors.contains(local),
3521                "{local} is parsed as a shape but not recognised here"
3522            );
3523        }
3524        for local in ["txBody", "nvGrpSpPr", "extLst"] {
3525            assert!(
3526                !ShapeElements::WithConnectors.contains(local),
3527                "{local} is not a shape element"
3528            );
3529        }
3530    }
3531
3532    #[test]
3533    fn the_pre_connector_set_leaves_out_only_connectors() {
3534        assert!(!ShapeElements::WithoutConnectors.contains("cxnSp"));
3535        for local in ["sp", "pic", "graphicFrame", "grpSp"] {
3536            assert!(ShapeElements::WithoutConnectors.contains(local));
3537        }
3538    }
3539
3540    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>"#;
3541    const LINK_PROPERTIES: &str =
3542        r#"<a:rPr lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr>"#;
3543    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>"#;
3544
3545    fn rebuilt_paragraph(xml: &[u8], runs: &[(&str, RunProperties)]) -> String {
3546        let limits = ParseLimits::default();
3547        let mut budget = ParseBudget::new(&limits);
3548        let mut root = parse_xml(xml, "ppt/slides/slide1.xml", &mut budget).unwrap();
3549        let prefixes = Prefixes::from_root(&mut root);
3550        let write = ParagraphWrite {
3551            source_index: Some(0),
3552            rebuild: true,
3553            properties_changed: false,
3554            alignment: None,
3555            level: 0,
3556            bullet: None,
3557            runs: runs
3558                .iter()
3559                .map(|(text, properties)| RunWrite {
3560                    text: (*text).to_owned(),
3561                    properties: properties.clone(),
3562                })
3563                .collect(),
3564        };
3565        let paragraph = build_paragraph(&write, Some(root), None, &prefixes);
3566        String::from_utf8(serialize_xml(&paragraph)).unwrap()
3567    }
3568
3569    #[test]
3570    fn an_edit_spanning_several_runs_keeps_each_survivor_on_its_source_run() {
3571        let xml = rebuilt_paragraph(
3572            LINKED_PARAGRAPH,
3573            &[("Seethe doc now", RunProperties::default())],
3574        );
3575        assert!(
3576            xml.contains(r#"<a:r><a:rPr dirty="0" lang="en-US"/><a:t>See</a:t></a:r>"#),
3577            "{xml}"
3578        );
3579        assert!(
3580            xml.contains(&format!("<a:r>{LINK_PROPERTIES}<a:t>the doc</a:t></a:r>")),
3581            "{xml}"
3582        );
3583        assert!(
3584            xml.contains(r#"<a:r><a:rPr lang="en-US"/><a:t> now</a:t></a:r>"#),
3585            "{xml}"
3586        );
3587        assert_eq!(xml.matches("<a:r>").count(), 3, "{xml}");
3588    }
3589
3590    #[test]
3591    fn a_line_break_inside_the_span_keeps_the_runs_around_it_aligned() {
3592        let xml = rebuilt_paragraph(
3593            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>"#,
3594            &[("Firs\nSecond!", RunProperties::default())],
3595        );
3596        assert!(
3597            xml.contains(
3598                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>"#
3599            ),
3600            "{xml}"
3601        );
3602    }
3603
3604    #[test]
3605    fn a_field_survives_an_edit_around_it_and_degrades_once_its_text_changes() {
3606        let xml = rebuilt_paragraph(
3607            FIELD_PARAGRAPH,
3608            &[("1 out of 2025", RunProperties::default())],
3609        );
3610        assert!(
3611            xml.contains(
3612                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>"#
3613            ),
3614            "{xml}"
3615        );
3616        assert!(!xml.contains("datetime1"), "{xml}");
3617
3618        let xml = rebuilt_paragraph(FIELD_PARAGRAPH, &[("1X of 2024", RunProperties::default())]);
3619        assert!(
3620            xml.contains(
3621                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>"#
3622            ),
3623            "{xml}"
3624        );
3625    }
3626
3627    #[test]
3628    fn text_typed_at_the_end_of_a_link_stays_inside_it() {
3629        let xml = rebuilt_paragraph(
3630            LINKED_PARAGRAPH,
3631            &[("See the docsX today", RunProperties::default())],
3632        );
3633        assert!(
3634            xml.contains(&format!("<a:r>{LINK_PROPERTIES}<a:t>the docsX</a:t></a:r>")),
3635            "{xml}"
3636        );
3637
3638        let bold = RunProperties {
3639            bold: Some(true),
3640            ..RunProperties::default()
3641        };
3642        let xml = rebuilt_paragraph(
3643            LINKED_PARAGRAPH,
3644            &[
3645                ("See ", RunProperties::default()),
3646                ("the docs", RunProperties::default()),
3647                ("X", bold),
3648                (" today", RunProperties::default()),
3649            ],
3650        );
3651        assert!(
3652            xml.contains(&format!(
3653                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>"#
3654            )),
3655            "{xml}"
3656        );
3657    }
3658
3659    #[test]
3660    fn deleting_a_linked_run_drops_its_link() {
3661        let xml = rebuilt_paragraph(
3662            LINKED_PARAGRAPH,
3663            &[("See  today", RunProperties::default())],
3664        );
3665        assert!(!xml.contains("hlinkClick"), "{xml}");
3666        assert!(
3667            xml.contains(
3668                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>"#
3669            ),
3670            "{xml}"
3671        );
3672    }
3673
3674    #[test]
3675    fn a_plain_duplicate_at_the_front_does_not_steal_the_field() {
3676        let xml = rebuilt_paragraph(
3677            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>"#,
3678            &[("1", RunProperties::default())],
3679        );
3680        assert!(
3681            xml.contains(
3682                r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld>"#
3683            ),
3684            "{xml}"
3685        );
3686        assert_eq!(xml.matches("<a:r>").count(), 0, "{xml}");
3687    }
3688
3689    #[test]
3690    fn a_plain_duplicate_at_the_back_does_not_steal_the_field() {
3691        let xml = rebuilt_paragraph(
3692            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>"#,
3693            &[
3694                ("X", RunProperties::default()),
3695                ("1", RunProperties::default()),
3696            ],
3697        );
3698        assert!(
3699            xml.contains(
3700                r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld>"#
3701            ),
3702            "{xml}"
3703        );
3704        assert_eq!(xml.matches("<a:r>").count(), 1, "{xml}");
3705        assert!(
3706            !xml.contains(r#"<a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r>"#),
3707            "{xml}"
3708        );
3709    }
3710
3711    #[test]
3712    fn adjustments_without_preset_geometry_error_instead_of_no_opting() {
3713        let limits = ParseLimits::default();
3714        let mut budget = ParseBudget::new(&limits);
3715        let part = "ppt/slides/slide1.xml";
3716        let mut root = parse_xml(
3717            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>"#,
3718            part,
3719            &mut budget,
3720        )
3721        .unwrap();
3722
3723        let package = PptxPackage::default();
3724        let mut replacements = HashMap::new();
3725        let mut new_parts = Vec::new();
3726        let mut sink = PartSink {
3727            package: &package,
3728            replacements: &mut replacements,
3729            new_parts: &mut new_parts,
3730        };
3731        let error = patch_slide(
3732            &mut root,
3733            &[ShapeWrite::Patch {
3734                source_index: 0,
3735                patch: Box::new(ShapePatch {
3736                    adjust_values: Some(BTreeMap::from([("adj".to_owned(), 0.25)])),
3737                    ..ShapePatch::default()
3738                }),
3739            }],
3740            None,
3741            part,
3742            ShapeElements::WithConnectors,
3743            &mut sink,
3744            &mut budget,
3745        )
3746        .unwrap_err();
3747        assert!(
3748            matches!(error, crate::PptxError::Write { ref message, .. } if message.contains("preset geometry")),
3749            "{error:?}"
3750        );
3751    }
3752}