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