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};
6
7use ooxml_drawingml::{
8    ColorValue, ShapeFill, ShapeOutline, Theme, resolve_color_value_to_hex_with_theme,
9};
10
11use crate::PptxError;
12use crate::drawing::parse_run_properties;
13use crate::model::{Bullet, PptxPackage, RunProperties, SlideReference};
14use crate::xml::{ParseBudget, ParseLimits, XmlElement, XmlNode, parse_xml, serialize_xml};
15
16const DRAWINGML_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
17const PRESENTATIONML_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
18const OFFICE_RELATIONSHIPS_NS: &str =
19    "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
20const SLIDE_RELATIONSHIP_TYPE: &str =
21    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
22const SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
23    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
24const SLIDE_CONTENT_TYPE: &str =
25    "application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
26const MIN_SLIDE_ID: u32 = 256;
27const MAX_SLIDE_ID: u32 = 2_147_483_647;
28
29/// The desired final deck, expressed against the parsed source package.
30pub struct DeckWrite {
31    pub slides: Vec<SlideWrite>,
32}
33
34pub enum SlideWrite {
35    /// Copy the source part through untouched.
36    Keep { part_path: String },
37    /// Patch the source part; `shapes` is the full top-level list in final order.
38    Patch {
39        part_path: String,
40        shapes: Vec<ShapeWrite>,
41    },
42    /// Mint a new slide part.
43    Add {
44        name: Option<String>,
45        layout_part_path: Option<String>,
46        shapes: Vec<ShapeAdd>,
47    },
48}
49
50pub enum ShapeWrite {
51    /// Keep the source shape element untouched. `source_index` counts shape
52    /// elements at the same nesting level, in document order.
53    Keep {
54        source_index: usize,
55    },
56    Patch {
57        source_index: usize,
58        patch: Box<ShapePatch>,
59    },
60    Add(Box<ShapeAdd>),
61}
62
63#[derive(Default)]
64pub struct ShapePatch {
65    /// The halves the edit changed. Transform pieces the source already
66    /// spells out and the edit did not touch are left as they are.
67    pub offset: Option<(i64, i64)>,
68    pub extent: Option<(i64, i64)>,
69    /// Fills in transform pieces the source never spelled out.
70    pub inherited: Option<InheritedTransform>,
71    pub fill: Option<ShapeFill>,
72    /// A default outline clears the stroke.
73    pub outline: Option<ShapeOutline>,
74    pub adjust_values: Option<BTreeMap<String, f64>>,
75    pub texts: Vec<TextWrite>,
76    /// Non-empty only when a group's child list must be rebuilt.
77    pub children: Vec<ShapeWrite>,
78}
79
80/// The transform a shape inherits from its layout or master placeholder.
81#[derive(Clone, Copy)]
82pub struct InheritedTransform {
83    pub x: i64,
84    pub y: i64,
85    pub width: i64,
86    pub height: i64,
87    pub rotation_deg: f64,
88    pub flip_horizontal: bool,
89    pub flip_vertical: bool,
90}
91
92pub struct TextWrite {
93    pub target: TextTarget,
94    pub paragraphs: Vec<ParagraphWrite>,
95}
96
97pub enum TextTarget {
98    Body,
99    TableCell { row: usize, cell: usize },
100}
101
102pub struct ParagraphWrite {
103    /// Index of the paragraph in the source text body, when it survives.
104    pub source_index: Option<usize>,
105    /// `false` keeps the source paragraph verbatim.
106    pub rebuild: bool,
107    pub properties_changed: bool,
108    pub alignment: Option<String>,
109    pub level: u32,
110    pub bullet: Option<Bullet>,
111    pub runs: Vec<RunWrite>,
112}
113
114pub struct RunWrite {
115    pub text: String,
116    pub properties: RunProperties,
117}
118
119pub struct ShapeAdd {
120    pub name: String,
121    pub geometry: String,
122    pub x: i64,
123    pub y: i64,
124    pub width: i64,
125    pub height: i64,
126    pub adjust_values: BTreeMap<String, f64>,
127    pub fill: Option<ShapeFill>,
128    pub outline: Option<ShapeOutline>,
129    pub paragraphs: Option<Vec<ParagraphWrite>>,
130}
131
132/// Writes the package with `deck` applied. Slides marked [`SlideWrite::Keep`]
133/// and every non-slide part keep their exact source bytes.
134pub fn write_pptx_with_edits(
135    package: &PptxPackage,
136    deck: &DeckWrite,
137) -> Result<Vec<u8>, PptxError> {
138    let limits = ParseLimits::default();
139    let mut budget = ParseBudget::new(&limits);
140
141    let final_paths: HashSet<&str> = deck
142        .slides
143        .iter()
144        .filter_map(|slide| match slide {
145            SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
146                Some(part_path.as_str())
147            }
148            SlideWrite::Add { .. } => None,
149        })
150        .collect();
151    let removed: Vec<&SlideReference> = package
152        .presentation
153        .slides
154        .iter()
155        .filter(|reference| !final_paths.contains(reference.part_path.as_str()))
156        .collect();
157
158    let mut replacements: HashMap<String, Vec<u8>> = HashMap::new();
159    let mut new_parts: Vec<(String, Vec<u8>)> = Vec::new();
160
161    for slide in &deck.slides {
162        if let SlideWrite::Patch { part_path, shapes } = slide {
163            let bytes = package
164                .part_bytes(part_path)
165                .ok_or_else(|| PptxError::MissingPart(part_path.clone()))?;
166            let mut root = parse_xml(bytes, part_path, &mut budget)?;
167            let theme = slide_theme(package, part_path);
168            patch_slide(&mut root, shapes, theme, part_path)?;
169            replacements.insert(part_path.clone(), serialize_xml(&root));
170        }
171    }
172
173    let mut minted = Vec::new();
174    let mut next_slide_number = next_slide_number(package);
175    let mut minted_slide_id: Option<u32> = None;
176    let mut next_relationship = next_relationship_number(package);
177    let mut structural = false;
178    let mut final_order: Vec<FinalSlide<'_>> = Vec::new();
179    for slide in &deck.slides {
180        match slide {
181            SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
182                let reference = package
183                    .presentation
184                    .slides
185                    .iter()
186                    .find(|reference| &reference.part_path == part_path)
187                    .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
188                final_order.push(FinalSlide::Existing(reference));
189            }
190            SlideWrite::Add {
191                name,
192                layout_part_path,
193                shapes,
194            } => {
195                structural = true;
196                let part_path = format!("ppt/slides/slide{next_slide_number}.xml");
197                next_slide_number += 1;
198                let relationship_id = format!("rId{next_relationship}");
199                next_relationship += 1;
200                let slide_id = match minted_slide_id {
201                    None => next_slide_id(package)?,
202                    Some(previous) => previous
203                        .checked_add(1)
204                        .filter(|id| *id <= MAX_SLIDE_ID)
205                        .ok_or_else(|| {
206                            write_error(
207                                &package.presentation.part_path,
208                                "the slide id space is exhausted",
209                            )
210                        })?,
211                };
212                minted_slide_id = Some(slide_id);
213                let layout = layout_part_path
214                    .clone()
215                    .filter(|path| {
216                        package
217                            .layouts
218                            .iter()
219                            .any(|layout| &layout.part_path == path)
220                    })
221                    .or_else(|| {
222                        package
223                            .layouts
224                            .first()
225                            .map(|layout| layout.part_path.clone())
226                    });
227                new_parts.push((
228                    part_path.clone(),
229                    slide_xml(name.as_deref(), shapes, &part_path)?,
230                ));
231                if let Some(layout) = &layout {
232                    new_parts.push((
233                        slide_relationships_path(&part_path),
234                        slide_relationships_xml(&part_path, layout),
235                    ));
236                }
237                minted.push(MintedSlide {
238                    part_path,
239                    relationship_id,
240                    slide_id,
241                });
242                final_order.push(FinalSlide::Added(minted.len() - 1));
243            }
244        }
245    }
246    let original_paths: Vec<&str> = package
247        .presentation
248        .slides
249        .iter()
250        .map(|reference| reference.part_path.as_str())
251        .collect();
252    let kept_paths: Vec<&str> = final_order
253        .iter()
254        .filter_map(|slide| match slide {
255            FinalSlide::Existing(reference) => Some(reference.part_path.as_str()),
256            FinalSlide::Added(_) => None,
257        })
258        .collect();
259    structural = structural || kept_paths != original_paths;
260
261    let mut removed_paths = HashSet::new();
262    for reference in &removed {
263        removed_paths.insert(reference.part_path.clone());
264        removed_paths.insert(slide_relationships_path(&reference.part_path));
265    }
266    let orphans = orphaned_parts(package, &removed);
267    for orphan in &orphans {
268        removed_paths.insert(orphan.clone());
269        removed_paths.insert(slide_relationships_path(orphan));
270    }
271
272    if structural {
273        patch_structure(
274            package,
275            &final_order,
276            &minted,
277            &removed,
278            &orphans,
279            &mut replacements,
280            &mut budget,
281        )?;
282    }
283    let mut parts = Vec::with_capacity(package.parts.len() + new_parts.len());
284    for part in &package.parts {
285        if removed_paths.contains(&part.path) {
286            continue;
287        }
288        match replacements.remove(&part.path) {
289            Some(bytes) => parts.push((part.path.clone(), bytes)),
290            None => parts.push((part.path.clone(), part.bytes.clone())),
291        }
292    }
293    parts.extend(new_parts);
294    ooxml_opc::rezip_parts(&parts).map_err(PptxError::Container)
295}
296
297struct MintedSlide {
298    part_path: String,
299    relationship_id: String,
300    slide_id: u32,
301}
302
303enum FinalSlide<'a> {
304    Existing(&'a SlideReference),
305    Added(usize),
306}
307
308fn write_error(part: &str, message: impl Into<String>) -> PptxError {
309    PptxError::Write {
310        part: part.to_owned(),
311        message: message.into(),
312    }
313}
314
315fn next_slide_number(package: &PptxPackage) -> usize {
316    let paths: HashSet<&str> = package
317        .parts
318        .iter()
319        .map(|part| part.path.as_str())
320        .collect();
321    let mut number = package
322        .parts
323        .iter()
324        .filter_map(|part| {
325            part.path
326                .strip_prefix("ppt/slides/slide")?
327                .strip_suffix(".xml")?
328                .parse::<usize>()
329                .ok()
330        })
331        .max()
332        .unwrap_or(0)
333        + 1;
334    while paths.contains(format!("ppt/slides/slide{number}.xml").as_str())
335        || paths.contains(format!("ppt/slides/_rels/slide{number}.xml.rels").as_str())
336    {
337        number += 1;
338    }
339    number
340}
341
342fn next_slide_id(package: &PptxPackage) -> Result<u32, PptxError> {
343    let next = package
344        .presentation
345        .slides
346        .iter()
347        .map(|reference| reference.id)
348        .max()
349        .unwrap_or(0)
350        .max(MIN_SLIDE_ID - 1)
351        .checked_add(1)
352        .filter(|id| *id <= MAX_SLIDE_ID);
353    next.ok_or_else(|| {
354        write_error(
355            &package.presentation.part_path,
356            "the slide id space is exhausted",
357        )
358    })
359}
360
361fn next_relationship_number(package: &PptxPackage) -> usize {
362    package
363        .relationships
364        .get(&package.presentation.part_path)
365        .into_iter()
366        .flatten()
367        .filter_map(|relationship| relationship.id.strip_prefix("rId")?.parse::<usize>().ok())
368        .max()
369        .unwrap_or(0)
370        + 1
371}
372
373fn slide_relationships_path(part_path: &str) -> String {
374    match part_path.rsplit_once('/') {
375        Some((directory, name)) => format!("{directory}/_rels/{name}.rels"),
376        None => format!("_rels/{part_path}.rels"),
377    }
378}
379
380/// Parts that were reachable from the package root only through the removed
381/// slides (notes slides, media used nowhere else, ...), transitively.
382fn orphaned_parts(package: &PptxPackage, removed: &[&SlideReference]) -> Vec<String> {
383    if removed.is_empty() {
384        return Vec::new();
385    }
386    let excluded: HashSet<&str> = removed
387        .iter()
388        .map(|reference| reference.part_path.as_str())
389        .collect();
390    let before = reachable_parts(package, &HashSet::new());
391    let after = reachable_parts(package, &excluded);
392    before
393        .into_iter()
394        .filter(|part| !after.contains(part) && !excluded.contains(part.as_str()))
395        .collect()
396}
397
398fn reachable_parts(package: &PptxPackage, excluded: &HashSet<&str>) -> HashSet<String> {
399    let mut reachable = HashSet::new();
400    let mut queue = vec![String::new()];
401    while let Some(source) = queue.pop() {
402        for relationship in package.relationships.get(&source).into_iter().flatten() {
403            let Some(target) = &relationship.resolved_target else {
404                continue;
405            };
406            if excluded.contains(target.as_str()) {
407                continue;
408            }
409            if reachable.insert(target.clone()) {
410                queue.push(target.clone());
411            }
412        }
413    }
414    reachable
415}
416
417fn relative_target(from_part: &str, to_part: &str) -> String {
418    let from: Vec<&str> = from_part.split('/').collect();
419    let to: Vec<&str> = to_part.split('/').collect();
420    let from_directory = &from[..from.len().saturating_sub(1)];
421    let to_directory = &to[..to.len().saturating_sub(1)];
422    let shared = from_directory
423        .iter()
424        .zip(to_directory)
425        .take_while(|(a, b)| a == b)
426        .count();
427    let mut segments: Vec<String> = vec!["..".to_owned(); from_directory.len() - shared];
428    segments.extend(to[shared..].iter().map(|segment| (*segment).to_owned()));
429    segments.join("/")
430}
431
432// --- structural parts -------------------------------------------------------
433
434fn patch_structure(
435    package: &PptxPackage,
436    final_order: &[FinalSlide<'_>],
437    minted: &[MintedSlide],
438    removed: &[&SlideReference],
439    orphans: &[String],
440    replacements: &mut HashMap<String, Vec<u8>>,
441    budget: &mut ParseBudget<'_>,
442) -> Result<(), PptxError> {
443    let presentation_path = package.presentation.part_path.as_str();
444    let bytes = package
445        .part_bytes(presentation_path)
446        .ok_or_else(|| PptxError::MissingPart(presentation_path.to_owned()))?;
447    let mut root = parse_xml(bytes, presentation_path, budget)?;
448    patch_slide_id_list(&mut root, final_order, minted, presentation_path)?;
449    prune_slide_references(&mut root, removed);
450    replacements.insert(presentation_path.to_owned(), serialize_xml(&root));
451
452    let relationships_path = slide_relationships_path(presentation_path);
453    let bytes = package
454        .part_bytes(&relationships_path)
455        .ok_or_else(|| PptxError::MissingPart(relationships_path.clone()))?;
456    let mut root = parse_xml(bytes, &relationships_path, budget)?;
457    let removed_ids: HashSet<&str> = removed
458        .iter()
459        .map(|reference| reference.relationship_id.as_str())
460        .collect();
461    root.children.retain(|child| match child {
462        XmlNode::Element(element) if element.local_name() == "Relationship" => element
463            .attribute("Id")
464            .is_none_or(|id| !removed_ids.contains(id)),
465        _ => true,
466    });
467    for slide in minted {
468        root.children.push(XmlNode::Element(
469            XmlElement::new("Relationship")
470                .with_attribute("Id", slide.relationship_id.clone())
471                .with_attribute("Type", SLIDE_RELATIONSHIP_TYPE)
472                .with_attribute(
473                    "Target",
474                    relative_target(presentation_path, &slide.part_path),
475                ),
476        ));
477    }
478    replacements.insert(relationships_path, serialize_xml(&root));
479
480    let content_types_path = "[Content_Types].xml";
481    let bytes = package
482        .part_bytes(content_types_path)
483        .ok_or_else(|| PptxError::MissingPart(content_types_path.to_owned()))?;
484    let mut root = parse_xml(bytes, content_types_path, budget)?;
485    let removed_names: HashSet<String> = removed
486        .iter()
487        .map(|reference| format!("/{}", reference.part_path))
488        .chain(orphans.iter().map(|orphan| format!("/{orphan}")))
489        .collect();
490    root.children.retain(|child| match child {
491        XmlNode::Element(element) if element.local_name() == "Override" => element
492            .attribute("PartName")
493            .is_none_or(|name| !removed_names.contains(name)),
494        _ => true,
495    });
496    for slide in minted {
497        root.children.push(XmlNode::Element(
498            XmlElement::new("Override")
499                .with_attribute("PartName", format!("/{}", slide.part_path))
500                .with_attribute("ContentType", SLIDE_CONTENT_TYPE),
501        ));
502    }
503    replacements.insert(content_types_path.to_owned(), serialize_xml(&root));
504    Ok(())
505}
506
507/// Drops references to removed slides from custom shows (`r:id`) and
508/// section lists (`p14:sldId` inside `extLst`).
509fn prune_slide_references(root: &mut XmlElement, removed: &[&SlideReference]) {
510    if removed.is_empty() {
511        return;
512    }
513    let removed_relationship_ids: HashSet<&str> = removed
514        .iter()
515        .map(|reference| reference.relationship_id.as_str())
516        .collect();
517    let removed_slide_ids: HashSet<String> = removed
518        .iter()
519        .map(|reference| reference.id.to_string())
520        .collect();
521    for child in root.children.iter_mut() {
522        let XmlNode::Element(element) = child else {
523            continue;
524        };
525        match element.local_name() {
526            "custShowLst" => remove_matching_descendants(element, &|candidate| {
527                candidate.local_name() == "sld"
528                    && candidate.attributes.iter().any(|(key, value)| {
529                        key.ends_with(":id") && removed_relationship_ids.contains(value.as_str())
530                    })
531            }),
532            "extLst" => remove_matching_descendants(element, &|candidate| {
533                candidate.local_name() == "sldId"
534                    && candidate
535                        .attribute("id")
536                        .is_some_and(|id| removed_slide_ids.contains(id))
537            }),
538            _ => {}
539        }
540    }
541}
542
543fn remove_matching_descendants(element: &mut XmlElement, matches: &dyn Fn(&XmlElement) -> bool) {
544    element.children.retain(|child| match child {
545        XmlNode::Element(candidate) => !matches(candidate),
546        _ => true,
547    });
548    for child in element.children.iter_mut() {
549        if let XmlNode::Element(child) = child {
550            remove_matching_descendants(child, matches);
551        }
552    }
553}
554
555fn patch_slide_id_list(
556    root: &mut XmlElement,
557    final_order: &[FinalSlide<'_>],
558    minted: &[MintedSlide],
559    part: &str,
560) -> Result<(), PptxError> {
561    let presentation_prefix = resolve_prefix(root, PRESENTATIONML_NS, "p");
562    let relationship_prefix = resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r");
563    let list_name = qualified(&presentation_prefix, "sldIdLst");
564    if root.child_mut("sldIdLst").is_none() {
565        let position = root
566            .children
567            .iter()
568            .position(|child| {
569                matches!(child, XmlNode::Element(element) if element.local_name() == "sldMasterIdLst")
570            })
571            .map(|index| index + 1)
572            .unwrap_or(0);
573        root.children.insert(
574            position,
575            XmlNode::Element(XmlElement::new(list_name.clone())),
576        );
577    }
578    let relationship_attribute = qualified(&relationship_prefix, "id");
579    let list = root
580        .child_mut("sldIdLst")
581        .ok_or_else(|| write_error(part, "missing slide id list"))?;
582    let mut existing: HashMap<String, XmlElement> = HashMap::new();
583    for child in std::mem::take(&mut list.children) {
584        if let XmlNode::Element(element) = child
585            && element.local_name() == "sldId"
586        {
587            let relationship_id = element.attribute(&relationship_attribute).or_else(|| {
588                element
589                    .attributes
590                    .iter()
591                    .find(|(key, _)| key.ends_with(":id"))
592                    .map(|(_, value)| value.as_str())
593            });
594            if let Some(id) = relationship_id {
595                existing.insert(id.to_owned(), element);
596            }
597        }
598    }
599    let entry_name = qualified(&presentation_prefix, "sldId");
600    for slide in final_order {
601        let element = match slide {
602            FinalSlide::Existing(reference) => existing
603                .remove(&reference.relationship_id)
604                .unwrap_or_else(|| {
605                    XmlElement::new(entry_name.clone())
606                        .with_attribute("id", reference.id.to_string())
607                        .with_attribute(
608                            relationship_attribute.clone(),
609                            reference.relationship_id.clone(),
610                        )
611                }),
612            FinalSlide::Added(index) => {
613                let slide = &minted[*index];
614                XmlElement::new(entry_name.clone())
615                    .with_attribute("id", slide.slide_id.to_string())
616                    .with_attribute(
617                        relationship_attribute.clone(),
618                        slide.relationship_id.clone(),
619                    )
620            }
621        };
622        list.children.push(XmlNode::Element(element));
623    }
624    Ok(())
625}
626
627// --- namespace prefixes -----------------------------------------------------
628
629fn find_prefix(root: &XmlElement, namespace: &str) -> Option<String> {
630    root.attributes.iter().find_map(|(key, value)| {
631        if value != namespace {
632            return None;
633        }
634        if key == "xmlns" {
635            Some(String::new())
636        } else {
637            key.strip_prefix("xmlns:").map(str::to_owned)
638        }
639    })
640}
641
642fn resolve_prefix(root: &mut XmlElement, namespace: &str, fallback: &str) -> String {
643    if let Some(prefix) = find_prefix(root, namespace) {
644        return prefix;
645    }
646    root.set_attribute(format!("xmlns:{fallback}"), namespace);
647    fallback.to_owned()
648}
649
650fn qualified(prefix: &str, local: &str) -> String {
651    if prefix.is_empty() {
652        local.to_owned()
653    } else {
654        format!("{prefix}:{local}")
655    }
656}
657
658struct Prefixes {
659    drawing: String,
660    presentation: String,
661}
662
663impl Prefixes {
664    fn from_root(root: &mut XmlElement) -> Self {
665        Self {
666            drawing: resolve_prefix(root, DRAWINGML_NS, "a"),
667            presentation: resolve_prefix(root, PRESENTATIONML_NS, "p"),
668        }
669    }
670
671    fn drawing(&self, local: &str) -> String {
672        qualified(&self.drawing, local)
673    }
674
675    fn presentation(&self, local: &str) -> String {
676        qualified(&self.presentation, local)
677    }
678}
679
680// --- slide patching ---------------------------------------------------------
681
682fn is_shape_element(local: &str) -> bool {
683    matches!(local, "sp" | "pic" | "graphicFrame" | "grpSp")
684}
685
686fn patch_slide(
687    root: &mut XmlElement,
688    shapes: &[ShapeWrite],
689    theme: Option<&Theme>,
690    part: &str,
691) -> Result<(), PptxError> {
692    let prefixes = Prefixes::from_root(root);
693    let mut next_shape_id = max_shape_id(root) + 1;
694    let tree = root
695        .child_mut("cSld")
696        .and_then(|common| common.child_mut("spTree"))
697        .ok_or_else(|| write_error(part, "slide has no shape tree"))?;
698    patch_shape_children(tree, shapes, &mut next_shape_id, theme, &prefixes, part)
699}
700
701/// The theme a slide's colours resolve against, via its layout's master.
702fn slide_theme<'a>(package: &'a PptxPackage, part_path: &str) -> Option<&'a Theme> {
703    let slide = package
704        .slides
705        .iter()
706        .find(|slide| slide.part_path == part_path);
707    let layout = slide
708        .and_then(|slide| slide.layout_part_path.as_deref())
709        .and_then(|path| {
710            package
711                .layouts
712                .iter()
713                .find(|layout| layout.part_path == path)
714        })
715        .or_else(|| package.layouts.first());
716    let master = layout
717        .and_then(|layout| layout.master_part_path.as_deref())
718        .and_then(|path| {
719            package
720                .masters
721                .iter()
722                .find(|master| master.part_path == path)
723        })
724        .or_else(|| {
725            layout.and_then(|layout| {
726                package.masters.iter().find(|master| {
727                    master
728                        .layout_part_paths
729                        .iter()
730                        .any(|path| path == &layout.part_path)
731                })
732            })
733        })
734        .or_else(|| package.masters.first());
735    master
736        .and_then(|master| master.theme_part_path.as_deref())
737        .and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
738        .map(|part| &part.theme)
739        .or_else(|| package.themes.first().map(|part| &part.theme))
740}
741
742fn max_shape_id(root: &XmlElement) -> u32 {
743    root.descendants_named("cNvPr")
744        .iter()
745        .filter_map(|element| element.attribute("id")?.parse::<u32>().ok())
746        .max()
747        .unwrap_or(1)
748}
749
750/// Rebuilds the shape run in place: non-shape siblings keep their slots
751/// relative to the shape that follows them, and elements trailing the last
752/// shape (`p:extLst` in particular) stay last.
753fn patch_shape_children(
754    parent: &mut XmlElement,
755    writes: &[ShapeWrite],
756    next_shape_id: &mut u32,
757    theme: Option<&Theme>,
758    prefixes: &Prefixes,
759    part: &str,
760) -> Result<(), PptxError> {
761    let mut slots: Vec<Option<XmlNode>> = std::mem::take(&mut parent.children)
762        .into_iter()
763        .map(Some)
764        .collect();
765    let shape_slots: Vec<usize> = slots
766        .iter()
767        .enumerate()
768        .filter_map(|(position, slot)| match slot {
769            Some(XmlNode::Element(element)) if is_shape_element(element.local_name()) => {
770                Some(position)
771            }
772            _ => None,
773        })
774        .collect();
775    let mut children = Vec::with_capacity(slots.len());
776    let first_ext_list = |slots: &[Option<XmlNode>]| {
777        slots
778            .iter()
779            .position(|slot| {
780                matches!(slot, Some(XmlNode::Element(element)) if element.local_name() == "extLst")
781            })
782            .unwrap_or(slots.len())
783    };
784    let prologue_end = shape_slots
785        .first()
786        .copied()
787        .unwrap_or_else(|| first_ext_list(&slots));
788    emit_sibling_slots(&mut slots, &mut children, prologue_end);
789    let last_kept = writes
790        .iter()
791        .rposition(|write| !matches!(write, ShapeWrite::Add(_)));
792    for (index, write) in writes.iter().enumerate() {
793        match write {
794            ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } => {
795                let position = *shape_slots.get(*source_index).ok_or_else(|| {
796                    write_error(part, format!("shape index {source_index} is not available"))
797                })?;
798                emit_sibling_slots(&mut slots, &mut children, position);
799                let mut element = match slots[position].take() {
800                    Some(XmlNode::Element(element)) => element,
801                    _ => {
802                        return Err(write_error(
803                            part,
804                            format!("shape index {source_index} was already written"),
805                        ));
806                    }
807                };
808                if let ShapeWrite::Patch { patch, .. } = write {
809                    patch_shape(&mut element, patch, next_shape_id, theme, prefixes, part)?;
810                }
811                children.push(XmlNode::Element(element));
812            }
813            ShapeWrite::Add(add) => {
814                // A trailing add goes on top: after every remaining sibling
815                // except a final extLst.
816                if last_kept.is_none_or(|kept| index > kept) {
817                    let flush_end = first_ext_list(&slots);
818                    emit_sibling_slots(&mut slots, &mut children, flush_end);
819                }
820                children.push(XmlNode::Element(shape_element(
821                    add,
822                    next_shape_id,
823                    prefixes,
824                    part,
825                )?));
826            }
827        }
828    }
829    for slot in slots.into_iter().flatten() {
830        if !matches!(&slot, XmlNode::Element(element) if is_shape_element(element.local_name())) {
831            children.push(slot);
832        }
833    }
834    parent.children = children;
835    Ok(())
836}
837
838/// Emits the not-yet-written non-shape nodes that precede `position`.
839fn emit_sibling_slots(slots: &mut [Option<XmlNode>], children: &mut Vec<XmlNode>, position: usize) {
840    for slot in slots.iter_mut().take(position) {
841        let is_other = !matches!(
842            slot,
843            Some(XmlNode::Element(element)) if is_shape_element(element.local_name())
844        );
845        if is_other && let Some(node) = slot.take() {
846            children.push(node);
847        }
848    }
849}
850
851fn patch_shape(
852    element: &mut XmlElement,
853    patch: &ShapePatch,
854    next_shape_id: &mut u32,
855    theme: Option<&Theme>,
856    prefixes: &Prefixes,
857    part: &str,
858) -> Result<(), PptxError> {
859    if patch.offset.is_some() || patch.extent.is_some() {
860        patch_transform(element, patch, prefixes, part)?;
861    }
862    if let Some(fill) = &patch.fill {
863        let properties = shape_properties_mut(element, part)?;
864        set_fill(properties, fill, prefixes, part)?;
865    }
866    if let Some(outline) = &patch.outline {
867        let properties = shape_properties_mut(element, part)?;
868        set_outline(properties, outline, prefixes);
869    }
870    if let Some(adjust_values) = &patch.adjust_values {
871        let properties = shape_properties_mut(element, part)?;
872        set_adjust_values(properties, adjust_values, prefixes);
873    }
874    for text in &patch.texts {
875        patch_text(element, text, theme, prefixes, part)?;
876    }
877    if !patch.children.is_empty() {
878        patch_shape_children(
879            element,
880            &patch.children,
881            next_shape_id,
882            theme,
883            prefixes,
884            part,
885        )?;
886    }
887    Ok(())
888}
889
890fn shape_properties_mut<'a>(
891    element: &'a mut XmlElement,
892    part: &str,
893) -> Result<&'a mut XmlElement, PptxError> {
894    let container = match element.local_name() {
895        "grpSp" => "grpSpPr",
896        _ => "spPr",
897    };
898    element
899        .child_mut(container)
900        .ok_or_else(|| write_error(part, "shape has no properties element"))
901}
902
903/// Writes the edited transform halves. Pieces the source spells out and the
904/// edit did not change are left untouched; pieces the source lacks are
905/// materialized from the inherited transform, rotation and flips included.
906fn patch_transform(
907    element: &mut XmlElement,
908    patch: &ShapePatch,
909    prefixes: &Prefixes,
910    part: &str,
911) -> Result<(), PptxError> {
912    let offset = patch
913        .offset
914        .or(patch.inherited.map(|inherited| (inherited.x, inherited.y)));
915    let extent = patch.extent.or(patch
916        .inherited
917        .map(|inherited| (inherited.width, inherited.height)));
918    let (container, transform_name) = match element.local_name() {
919        "graphicFrame" => (None, prefixes.presentation("xfrm")),
920        "grpSp" => (Some("grpSpPr"), prefixes.drawing("xfrm")),
921        _ => (Some("spPr"), prefixes.drawing("xfrm")),
922    };
923    let parent = match container {
924        Some(name) => element
925            .child_mut(name)
926            .ok_or_else(|| write_error(part, "shape has no properties element"))?,
927        None => element,
928    };
929    if parent.child_mut("xfrm").is_none() {
930        if offset.is_none() || extent.is_none() {
931            return Err(write_error(
932                part,
933                "cannot write half a transform onto a shape without one",
934            ));
935        }
936        let position = parent
937            .children
938            .iter()
939            .position(|child| {
940                matches!(child, XmlNode::Element(element) if element.local_name() != "nvGraphicFramePr")
941            })
942            .unwrap_or(parent.children.len());
943        let mut created = XmlElement::new(transform_name);
944        if let Some(inherited) = patch.inherited {
945            if inherited.rotation_deg != 0.0 {
946                created.set_attribute("rot", format_fixed(inherited.rotation_deg * 60_000.0));
947            }
948            if inherited.flip_horizontal {
949                created.set_attribute("flipH", "1");
950            }
951            if inherited.flip_vertical {
952                created.set_attribute("flipV", "1");
953            }
954        }
955        parent.children.insert(position, XmlNode::Element(created));
956    }
957    let transform = parent.child_mut("xfrm").expect("transform ensured above");
958    if let Some((x, y)) = offset
959        && (patch.offset.is_some() || transform.child_mut("off").is_none())
960    {
961        if transform.child_mut("off").is_none() {
962            transform.children.insert(
963                0,
964                XmlNode::Element(XmlElement::new(prefixes.drawing("off"))),
965            );
966        }
967        let element = transform.child_mut("off").expect("offset ensured above");
968        element.set_attribute("x", x.to_string());
969        element.set_attribute("y", y.to_string());
970    }
971    if let Some((width, height)) = extent
972        && (patch.extent.is_some() || transform.child_mut("ext").is_none())
973    {
974        if transform.child_mut("ext").is_none() {
975            let position = transform
976                .children
977                .iter()
978                .position(
979                    |child| matches!(child, XmlNode::Element(element) if element.local_name() == "off"),
980                )
981                .map(|index| index + 1)
982                .unwrap_or(transform.children.len());
983            transform.children.insert(
984                position,
985                XmlNode::Element(XmlElement::new(prefixes.drawing("ext"))),
986            );
987        }
988        let element = transform.child_mut("ext").expect("extent ensured above");
989        element.set_attribute("cx", width.to_string());
990        element.set_attribute("cy", height.to_string());
991    }
992    Ok(())
993}
994
995const FILL_ELEMENTS: [&str; 6] = [
996    "noFill",
997    "solidFill",
998    "gradFill",
999    "blipFill",
1000    "pattFill",
1001    "grpFill",
1002];
1003const POST_FILL_ELEMENTS: [&str; 6] = ["ln", "effectLst", "effectDag", "scene3d", "sp3d", "extLst"];
1004
1005fn set_fill(
1006    properties: &mut XmlElement,
1007    fill: &ShapeFill,
1008    prefixes: &Prefixes,
1009    part: &str,
1010) -> Result<(), PptxError> {
1011    let element = fill_element(fill, prefixes, part)?;
1012    let existing = properties.children.iter().position(|child| {
1013        matches!(child, XmlNode::Element(element) if FILL_ELEMENTS.contains(&element.local_name()))
1014    });
1015    match existing {
1016        Some(index) => properties.children[index] = XmlNode::Element(element),
1017        None => {
1018            let position = properties
1019                .children
1020                .iter()
1021                .position(|child| {
1022                    matches!(
1023                        child,
1024                        XmlNode::Element(element)
1025                            if POST_FILL_ELEMENTS.contains(&element.local_name())
1026                    )
1027                })
1028                .unwrap_or(properties.children.len());
1029            properties
1030                .children
1031                .insert(position, XmlNode::Element(element));
1032        }
1033    }
1034    Ok(())
1035}
1036
1037fn fill_element(
1038    fill: &ShapeFill,
1039    prefixes: &Prefixes,
1040    part: &str,
1041) -> Result<XmlElement, PptxError> {
1042    match fill.fill_type.as_str() {
1043        "none" => Ok(XmlElement::new(prefixes.drawing("noFill"))),
1044        "solid" => {
1045            let color = fill
1046                .color
1047                .as_ref()
1048                .and_then(|color| color_element(color, prefixes))
1049                .ok_or_else(|| write_error(part, "solid fill without a serializable color"))?;
1050            Ok(XmlElement::new(prefixes.drawing("solidFill")).with_child(color))
1051        }
1052        "gradient" => {
1053            let gradient = fill
1054                .gradient
1055                .as_ref()
1056                .ok_or_else(|| write_error(part, "gradient fill without stops"))?;
1057            let mut stops = XmlElement::new(prefixes.drawing("gsLst"));
1058            for stop in &gradient.stops {
1059                let color = color_element(&stop.color, prefixes)
1060                    .ok_or_else(|| write_error(part, "gradient stop without a color"))?;
1061                stops = stops.with_child(
1062                    XmlElement::new(prefixes.drawing("gs"))
1063                        .with_attribute("pos", format_fixed(stop.position))
1064                        .with_child(color),
1065                );
1066            }
1067            let mut element = XmlElement::new(prefixes.drawing("gradFill")).with_child(stops);
1068            match gradient.gradient_type.as_str() {
1069                "linear" => {
1070                    element = element.with_child(
1071                        XmlElement::new(prefixes.drawing("lin"))
1072                            .with_attribute(
1073                                "ang",
1074                                format_fixed(gradient.angle.unwrap_or_default() * 60_000.0),
1075                            )
1076                            .with_attribute("scaled", "1"),
1077                    );
1078                }
1079                kind => {
1080                    let path = match kind {
1081                        "radial" => "circle",
1082                        "rectangular" => "rect",
1083                        _ => "shape",
1084                    };
1085                    element = element.with_child(
1086                        XmlElement::new(prefixes.drawing("path")).with_attribute("path", path),
1087                    );
1088                }
1089            }
1090            Ok(element)
1091        }
1092        other => Err(write_error(
1093            part,
1094            format!("unsupported fill type {other:?}"),
1095        )),
1096    }
1097}
1098
1099fn color_element(color: &ColorValue, prefixes: &Prefixes) -> Option<XmlElement> {
1100    let mut element = if let Some(rgb) = &color.rgb {
1101        XmlElement::new(prefixes.drawing("srgbClr")).with_attribute("val", rgb.clone())
1102    } else if let Some(theme) = &color.theme_color {
1103        XmlElement::new(prefixes.drawing("schemeClr"))
1104            .with_attribute("val", denormalize_scheme_color(theme))
1105    } else {
1106        return None;
1107    };
1108    let fractions = [
1109        ("alpha", color.alpha),
1110        ("lumMod", color.luminance_modulation),
1111        ("lumOff", color.luminance_offset),
1112        ("satMod", color.saturation_modulation),
1113    ];
1114    for (name, value) in fractions {
1115        if let Some(value) = value {
1116            element = element.with_child(
1117                XmlElement::new(prefixes.drawing(name))
1118                    .with_attribute("val", format_fixed(value * 100_000.0)),
1119            );
1120        }
1121    }
1122    let modifiers = [("tint", &color.theme_tint), ("shade", &color.theme_shade)];
1123    for (name, value) in modifiers {
1124        if let Some(byte) = value
1125            .as_deref()
1126            .and_then(|value| u8::from_str_radix(value, 16).ok())
1127        {
1128            element = element.with_child(
1129                XmlElement::new(prefixes.drawing(name))
1130                    .with_attribute("val", format_fixed(f64::from(byte) / 255.0 * 100_000.0)),
1131            );
1132        }
1133    }
1134    Some(element)
1135}
1136
1137fn denormalize_scheme_color(value: &str) -> String {
1138    match value {
1139        "text1" => "tx1",
1140        "text2" => "tx2",
1141        "background1" => "bg1",
1142        "background2" => "bg2",
1143        value => value,
1144    }
1145    .to_owned()
1146}
1147
1148fn format_fixed(value: f64) -> String {
1149    (value.round() as i64).to_string()
1150}
1151
1152fn set_outline(properties: &mut XmlElement, outline: &ShapeOutline, prefixes: &Prefixes) {
1153    let element = outline_element(outline, prefixes);
1154    let existing = properties.children.iter().position(
1155        |child| matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
1156    );
1157    match existing {
1158        Some(index) => properties.children[index] = XmlNode::Element(element),
1159        None => {
1160            let position = properties
1161                .children
1162                .iter()
1163                .position(|child| {
1164                    matches!(
1165                        child,
1166                        XmlNode::Element(element)
1167                            if POST_FILL_ELEMENTS[1..].contains(&element.local_name())
1168                    )
1169                })
1170                .unwrap_or(properties.children.len());
1171            properties
1172                .children
1173                .insert(position, XmlNode::Element(element));
1174        }
1175    }
1176}
1177
1178fn outline_element(outline: &ShapeOutline, prefixes: &Prefixes) -> XmlElement {
1179    let mut element = XmlElement::new(prefixes.drawing("ln"));
1180    if *outline == ShapeOutline::default() {
1181        return element.with_child(XmlElement::new(prefixes.drawing("noFill")));
1182    }
1183    if let Some(width) = outline.width {
1184        element.set_attribute("w", format_fixed(width));
1185    }
1186    if let Some(cap) = &outline.cap {
1187        element.set_attribute("cap", cap.clone());
1188    }
1189    if let Some(color) = outline
1190        .color
1191        .as_ref()
1192        .and_then(|color| color_element(color, prefixes))
1193    {
1194        element =
1195            element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
1196    }
1197    if let Some(style) = &outline.style {
1198        element = element.with_child(
1199            XmlElement::new(prefixes.drawing("prstDash")).with_attribute("val", style.clone()),
1200        );
1201    }
1202    if let Some(join) = &outline.join {
1203        element = element.with_child(XmlElement::new(prefixes.drawing(join)));
1204    }
1205    let ends = [
1206        ("headEnd", &outline.head_end),
1207        ("tailEnd", &outline.tail_end),
1208    ];
1209    for (name, end) in ends {
1210        if let Some(end) = end {
1211            let mut end_element = XmlElement::new(prefixes.drawing(name))
1212                .with_attribute("type", end.end_type.clone());
1213            if let Some(width) = &end.width {
1214                end_element.set_attribute("w", width.clone());
1215            }
1216            if let Some(length) = &end.length {
1217                end_element.set_attribute("len", length.clone());
1218            }
1219            element = element.with_child(end_element);
1220        }
1221    }
1222    element
1223}
1224
1225fn set_adjust_values(
1226    properties: &mut XmlElement,
1227    adjust_values: &BTreeMap<String, f64>,
1228    prefixes: &Prefixes,
1229) {
1230    // Only preset geometries carry an editable adjustment list.
1231    let Some(geometry) = properties.child_mut("prstGeom") else {
1232        return;
1233    };
1234    let list_name = prefixes.drawing("avLst");
1235    if geometry.child_mut("avLst").is_none() {
1236        geometry
1237            .children
1238            .insert(0, XmlNode::Element(XmlElement::new(list_name)));
1239    }
1240    let list = geometry.child_mut("avLst").expect("list ensured above");
1241    // Guides absent from the model (unevaluated formulas) are kept as-is.
1242    for (name, value) in adjust_values {
1243        let formula = format!("val {}", format_fixed(value * 100_000.0));
1244        let existing = list.children.iter_mut().find_map(|child| match child {
1245            XmlNode::Element(element)
1246                if element.local_name() == "gd" && element.attribute("name") == Some(name) =>
1247            {
1248                Some(element)
1249            }
1250            _ => None,
1251        });
1252        match existing {
1253            Some(guide) => guide.set_attribute("fmla", formula),
1254            None => list.children.push(XmlNode::Element(
1255                XmlElement::new(prefixes.drawing("gd"))
1256                    .with_attribute("name", name.clone())
1257                    .with_attribute("fmla", formula),
1258            )),
1259        }
1260    }
1261}
1262
1263// --- text -------------------------------------------------------------------
1264
1265fn patch_text(
1266    element: &mut XmlElement,
1267    text: &TextWrite,
1268    theme: Option<&Theme>,
1269    prefixes: &Prefixes,
1270    part: &str,
1271) -> Result<(), PptxError> {
1272    let body = match &text.target {
1273        TextTarget::Body => element.child_mut("txBody"),
1274        TextTarget::TableCell { row, cell } => element
1275            .child_mut("graphic")
1276            .and_then(|graphic| graphic.child_mut("graphicData"))
1277            .and_then(|data| data.child_mut("tbl"))
1278            .and_then(|table| nth_child_mut(table, "tr", *row))
1279            .and_then(|table_row| nth_child_mut(table_row, "tc", *cell))
1280            .and_then(|table_cell| table_cell.child_mut("txBody")),
1281    };
1282    let body = body.ok_or_else(|| write_error(part, "text target has no body"))?;
1283    rebuild_paragraphs(body, &text.paragraphs, theme, prefixes);
1284    Ok(())
1285}
1286
1287fn nth_child_mut<'a>(
1288    parent: &'a mut XmlElement,
1289    local: &str,
1290    index: usize,
1291) -> Option<&'a mut XmlElement> {
1292    parent
1293        .children
1294        .iter_mut()
1295        .filter_map(|child| match child {
1296            XmlNode::Element(element) if element.local_name() == local => Some(element),
1297            _ => None,
1298        })
1299        .nth(index)
1300}
1301
1302fn rebuild_paragraphs(
1303    body: &mut XmlElement,
1304    paragraphs: &[ParagraphWrite],
1305    theme: Option<&Theme>,
1306    prefixes: &Prefixes,
1307) {
1308    let mut preamble = Vec::new();
1309    let mut originals: Vec<Option<XmlElement>> = Vec::new();
1310    for child in std::mem::take(&mut body.children) {
1311        match child {
1312            XmlNode::Element(element) if element.local_name() == "p" => {
1313                originals.push(Some(element));
1314            }
1315            XmlNode::Text(text) if text.trim().is_empty() => {}
1316            other => preamble.push(other),
1317        }
1318    }
1319    let mut children = preamble;
1320    for paragraph in paragraphs {
1321        let source = paragraph
1322            .source_index
1323            .and_then(|index| originals.get_mut(index).and_then(Option::take));
1324        let element = match source {
1325            Some(element) if !paragraph.rebuild => element,
1326            source => build_paragraph(paragraph, source, theme, prefixes),
1327        };
1328        children.push(XmlNode::Element(element));
1329    }
1330    if children
1331        .iter()
1332        .all(|child| !matches!(child, XmlNode::Element(element) if element.local_name() == "p"))
1333    {
1334        children.push(XmlNode::Element(XmlElement::new(prefixes.drawing("p"))));
1335    }
1336    body.children = children;
1337}
1338
1339fn is_run_element(local: &str) -> bool {
1340    matches!(local, "r" | "br" | "fld")
1341}
1342
1343/// One piece of the paragraph's target text: a line break or a styled span.
1344struct RunSegment<'a> {
1345    line_break: bool,
1346    text: &'a str,
1347    properties: &'a RunProperties,
1348}
1349
1350fn run_segments(runs: &[RunWrite]) -> Vec<RunSegment<'_>> {
1351    let mut segments = Vec::new();
1352    for run in runs {
1353        for (index, piece) in run.text.split('\n').enumerate() {
1354            if index > 0 {
1355                segments.push(RunSegment {
1356                    line_break: true,
1357                    text: "",
1358                    properties: &run.properties,
1359                });
1360            }
1361            if !piece.is_empty() {
1362                segments.push(RunSegment {
1363                    line_break: false,
1364                    text: piece,
1365                    properties: &run.properties,
1366                });
1367            }
1368        }
1369    }
1370    segments
1371}
1372
1373/// Whether a source run element already carries the segment: same text and
1374/// the same modeled styling once colours are resolved against the theme.
1375/// Reused elements keep everything the model does not carry — hyperlinks,
1376/// field bindings, spacing, strikethrough.
1377fn segment_matches(element: &XmlElement, segment: &RunSegment<'_>, theme: Option<&Theme>) -> bool {
1378    if element.local_name() == "br" {
1379        return segment.line_break;
1380    }
1381    if segment.line_break {
1382        return false;
1383    }
1384    let text = element
1385        .child("t")
1386        .map(XmlElement::text_content)
1387        .unwrap_or_default();
1388    if text != segment.text {
1389        return false;
1390    }
1391    let source = parse_run_properties(element.child("rPr"));
1392    let target = segment.properties;
1393    source.bold == target.bold
1394        && source.italic == target.italic
1395        && source.font_size_pt == target.font_size_pt
1396        && source.underline == target.underline
1397        && source.font_family == target.font_family
1398        && resolve_color_value_to_hex_with_theme(source.color.as_ref(), theme)
1399            == resolve_color_value_to_hex_with_theme(target.color.as_ref(), theme)
1400}
1401
1402fn build_paragraph(
1403    write: &ParagraphWrite,
1404    source: Option<XmlElement>,
1405    theme: Option<&Theme>,
1406    prefixes: &Prefixes,
1407) -> XmlElement {
1408    let (mut paragraph, source_runs) = match source {
1409        Some(mut element) => {
1410            let mut runs = Vec::new();
1411            let mut kept = Vec::new();
1412            for child in std::mem::take(&mut element.children) {
1413                match child {
1414                    XmlNode::Element(child) if is_run_element(child.local_name()) => {
1415                        runs.push(child);
1416                    }
1417                    XmlNode::Text(text) if text.trim().is_empty() => {}
1418                    other => kept.push(other),
1419                }
1420            }
1421            element.children = kept;
1422            (element, runs)
1423        }
1424        None => (XmlElement::new(prefixes.drawing("p")), Vec::new()),
1425    };
1426    let fresh = write.source_index.is_none();
1427    if write.properties_changed || fresh {
1428        apply_paragraph_properties(&mut paragraph, write, prefixes);
1429    }
1430    let segments = run_segments(&write.runs);
1431    let mut front = 0;
1432    while front < source_runs.len()
1433        && front < segments.len()
1434        && segment_matches(&source_runs[front], &segments[front], theme)
1435    {
1436        front += 1;
1437    }
1438    let mut back = 0;
1439    while back < source_runs.len() - front
1440        && back < segments.len() - front
1441        && segment_matches(
1442            &source_runs[source_runs.len() - 1 - back],
1443            &segments[segments.len() - 1 - back],
1444            theme,
1445        )
1446    {
1447        back += 1;
1448    }
1449    let tail_start = source_runs.len() - back;
1450    // An edit contained in a single source run keeps that run's unmodeled
1451    // markup (hyperlink, strike, spacing) by rebuilding onto its rPr.
1452    let template = (tail_start - front == 1 && source_runs[front].local_name() == "r")
1453        .then(|| source_runs[front].child("rPr").cloned())
1454        .flatten();
1455    let mut runs: Vec<XmlNode> = Vec::with_capacity(segments.len());
1456    let mut source_runs = source_runs.into_iter();
1457    for element in source_runs.by_ref().take(front) {
1458        runs.push(XmlNode::Element(element));
1459    }
1460    for segment in &segments[front..segments.len() - back] {
1461        runs.push(XmlNode::Element(segment_element(
1462            segment,
1463            template.as_ref(),
1464            prefixes,
1465        )));
1466    }
1467    for element in source_runs.skip(tail_start - front) {
1468        runs.push(XmlNode::Element(element));
1469    }
1470    let position = paragraph
1471        .children
1472        .iter()
1473        .position(|child| {
1474            matches!(child, XmlNode::Element(element) if element.local_name() == "endParaRPr")
1475        })
1476        .unwrap_or(paragraph.children.len());
1477    paragraph.children.splice(position..position, runs);
1478    paragraph
1479}
1480
1481fn segment_element(
1482    segment: &RunSegment<'_>,
1483    template: Option<&XmlElement>,
1484    prefixes: &Prefixes,
1485) -> XmlElement {
1486    if segment.line_break {
1487        let mut line_break = XmlElement::new(prefixes.drawing("br"));
1488        if let Some(properties) = run_properties_element(segment.properties, prefixes) {
1489            line_break = line_break.with_child(properties);
1490        }
1491        return line_break;
1492    }
1493    let properties = match template {
1494        Some(template) => {
1495            let mut base = template.clone();
1496            apply_run_properties(&mut base, segment.properties, prefixes);
1497            Some(base)
1498        }
1499        None => run_properties_element(segment.properties, prefixes),
1500    };
1501    let mut element = XmlElement::new(prefixes.drawing("r"));
1502    if let Some(properties) = properties {
1503        element = element.with_child(properties);
1504    }
1505    element.with_child(XmlElement::new(prefixes.drawing("t")).with_text(segment.text))
1506}
1507
1508const POST_LATIN_ELEMENTS: [&str; 7] = [
1509    "ea",
1510    "cs",
1511    "sym",
1512    "hlinkClick",
1513    "hlinkMouseOver",
1514    "rtl",
1515    "extLst",
1516];
1517
1518/// Overwrites the modeled styling on a cloned source `rPr`, leaving what the
1519/// model does not carry (hyperlinks, strike, spacing, language) in place.
1520fn apply_run_properties(base: &mut XmlElement, properties: &RunProperties, prefixes: &Prefixes) {
1521    match properties.font_size_pt {
1522        Some(size) => base.set_attribute("sz", format_fixed(size * 100.0)),
1523        None => {
1524            base.attributes.remove("sz");
1525        }
1526    }
1527    let toggles = [("b", properties.bold), ("i", properties.italic)];
1528    for (name, value) in toggles {
1529        match value {
1530            Some(value) => base.set_attribute(name, if value { "1" } else { "0" }),
1531            None => {
1532                base.attributes.remove(name);
1533            }
1534        }
1535    }
1536    match &properties.underline {
1537        Some(underline) => base.set_attribute("u", underline.clone()),
1538        None => {
1539            base.attributes.remove("u");
1540        }
1541    }
1542    // A colour write replaces the whole fill choice; clearing the colour only
1543    // drops an explicit solidFill so an unmodeled noFill/gradFill survives.
1544    let removed_fills: &[&str] = if properties.color.is_some() {
1545        &FILL_ELEMENTS
1546    } else {
1547        &["solidFill"]
1548    };
1549    base.children.retain(|child| {
1550        !matches!(
1551            child,
1552            XmlNode::Element(element) if removed_fills.contains(&element.local_name())
1553        )
1554    });
1555    if let Some(color) = properties
1556        .color
1557        .as_ref()
1558        .and_then(|color| color_element(color, prefixes))
1559    {
1560        let position = base
1561            .children
1562            .iter()
1563            .position(
1564                |child| !matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
1565            )
1566            .unwrap_or(base.children.len());
1567        base.children.insert(
1568            position,
1569            XmlNode::Element(XmlElement::new(prefixes.drawing("solidFill")).with_child(color)),
1570        );
1571    }
1572    base.children.retain(|child| {
1573        !matches!(
1574            child,
1575            XmlNode::Element(element) if element.local_name() == "latin"
1576        )
1577    });
1578    if let Some(family) = &properties.font_family {
1579        let position = base
1580            .children
1581            .iter()
1582            .position(|child| {
1583                matches!(
1584                    child,
1585                    XmlNode::Element(element)
1586                        if POST_LATIN_ELEMENTS.contains(&element.local_name())
1587                )
1588            })
1589            .unwrap_or(base.children.len());
1590        base.children.insert(
1591            position,
1592            XmlNode::Element(
1593                XmlElement::new(prefixes.drawing("latin"))
1594                    .with_attribute("typeface", family.clone()),
1595            ),
1596        );
1597    }
1598}
1599
1600fn apply_paragraph_properties(
1601    paragraph: &mut XmlElement,
1602    write: &ParagraphWrite,
1603    prefixes: &Prefixes,
1604) {
1605    let needs_properties = write.alignment.is_some() || write.level > 0 || write.bullet.is_some();
1606    if paragraph.child_mut("pPr").is_none() {
1607        if !needs_properties {
1608            return;
1609        }
1610        paragraph.children.insert(
1611            0,
1612            XmlNode::Element(XmlElement::new(prefixes.drawing("pPr"))),
1613        );
1614    }
1615    let properties = paragraph.child_mut("pPr").expect("ensured above");
1616    match &write.alignment {
1617        Some(alignment) => properties.set_attribute("algn", alignment.clone()),
1618        None => {
1619            properties.attributes.remove("algn");
1620        }
1621    }
1622    if write.level > 0 {
1623        properties.set_attribute("lvl", write.level.to_string());
1624    } else {
1625        properties.attributes.remove("lvl");
1626    }
1627    properties.children.retain(|child| {
1628        !matches!(
1629            child,
1630            XmlNode::Element(element)
1631                if matches!(element.local_name(), "buNone" | "buChar" | "buAutoNum")
1632        )
1633    });
1634    let bullet = match &write.bullet {
1635        Some(Bullet::Character { value }) => {
1636            Some(XmlElement::new(prefixes.drawing("buChar")).with_attribute("char", value.clone()))
1637        }
1638        Some(Bullet::AutoNumber { scheme, start_at }) => {
1639            let mut element = XmlElement::new(prefixes.drawing("buAutoNum"))
1640                .with_attribute("type", scheme.clone());
1641            if *start_at != 1 {
1642                element.set_attribute("startAt", start_at.to_string());
1643            }
1644            Some(element)
1645        }
1646        Some(Bullet::None) => Some(XmlElement::new(prefixes.drawing("buNone"))),
1647        None => None,
1648    };
1649    if let Some(bullet) = bullet {
1650        let position = properties
1651            .children
1652            .iter()
1653            .position(|child| {
1654                matches!(
1655                    child,
1656                    XmlNode::Element(element)
1657                        if matches!(element.local_name(), "tabLst" | "defRPr" | "extLst")
1658                )
1659            })
1660            .unwrap_or(properties.children.len());
1661        properties
1662            .children
1663            .insert(position, XmlNode::Element(bullet));
1664    }
1665}
1666
1667fn run_properties_element(properties: &RunProperties, prefixes: &Prefixes) -> Option<XmlElement> {
1668    let mut element = XmlElement::new(prefixes.drawing("rPr"));
1669    let mut present = false;
1670    if let Some(language) = &properties.language {
1671        element.set_attribute("lang", language.clone());
1672        present = true;
1673    }
1674    if let Some(size) = properties.font_size_pt {
1675        element.set_attribute("sz", format_fixed(size * 100.0));
1676        present = true;
1677    }
1678    if let Some(bold) = properties.bold {
1679        element.set_attribute("b", if bold { "1" } else { "0" });
1680        present = true;
1681    }
1682    if let Some(italic) = properties.italic {
1683        element.set_attribute("i", if italic { "1" } else { "0" });
1684        present = true;
1685    }
1686    if let Some(underline) = &properties.underline {
1687        element.set_attribute("u", underline.clone());
1688        present = true;
1689    }
1690    if let Some(color) = properties
1691        .color
1692        .as_ref()
1693        .and_then(|color| color_element(color, prefixes))
1694    {
1695        element =
1696            element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
1697        present = true;
1698    }
1699    if let Some(family) = &properties.font_family {
1700        element = element.with_child(
1701            XmlElement::new(prefixes.drawing("latin")).with_attribute("typeface", family.clone()),
1702        );
1703        present = true;
1704    }
1705    present.then_some(element)
1706}
1707
1708// --- new shapes and slides --------------------------------------------------
1709
1710fn shape_element(
1711    add: &ShapeAdd,
1712    next_shape_id: &mut u32,
1713    prefixes: &Prefixes,
1714    part: &str,
1715) -> Result<XmlElement, PptxError> {
1716    if matches!(add.geometry.as_str(), "custom" | "group") {
1717        return Err(write_error(
1718            part,
1719            format!("unsupported geometry {:?} for a new shape", add.geometry),
1720        ));
1721    }
1722    let shape_id = *next_shape_id;
1723    *next_shape_id += 1;
1724    let non_visual = XmlElement::new(prefixes.presentation("nvSpPr"))
1725        .with_child(
1726            XmlElement::new(prefixes.presentation("cNvPr"))
1727                .with_attribute("id", shape_id.to_string())
1728                .with_attribute("name", add.name.clone()),
1729        )
1730        .with_child(XmlElement::new(prefixes.presentation("cNvSpPr")))
1731        .with_child(XmlElement::new(prefixes.presentation("nvPr")));
1732    let transform = XmlElement::new(prefixes.drawing("xfrm"))
1733        .with_child(
1734            XmlElement::new(prefixes.drawing("off"))
1735                .with_attribute("x", add.x.to_string())
1736                .with_attribute("y", add.y.to_string()),
1737        )
1738        .with_child(
1739            XmlElement::new(prefixes.drawing("ext"))
1740                .with_attribute("cx", add.width.to_string())
1741                .with_attribute("cy", add.height.to_string()),
1742        );
1743    let mut adjust_list = XmlElement::new(prefixes.drawing("avLst"));
1744    for (name, value) in &add.adjust_values {
1745        adjust_list = adjust_list.with_child(
1746            XmlElement::new(prefixes.drawing("gd"))
1747                .with_attribute("name", name.clone())
1748                .with_attribute("fmla", format!("val {}", format_fixed(value * 100_000.0))),
1749        );
1750    }
1751    let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
1752        .with_attribute("prst", add.geometry.clone())
1753        .with_child(adjust_list);
1754    let mut properties = XmlElement::new(prefixes.presentation("spPr"))
1755        .with_child(transform)
1756        .with_child(geometry);
1757    if let Some(fill) = &add.fill {
1758        properties = properties.with_child(fill_element(fill, prefixes, part)?);
1759    }
1760    if let Some(outline) = &add.outline {
1761        properties = properties.with_child(outline_element(outline, prefixes));
1762    }
1763    let mut shape = XmlElement::new(prefixes.presentation("sp"))
1764        .with_child(non_visual)
1765        .with_child(properties);
1766    if let Some(paragraphs) = &add.paragraphs {
1767        let mut body = XmlElement::new(prefixes.presentation("txBody"))
1768            .with_child(XmlElement::new(prefixes.drawing("bodyPr")))
1769            .with_child(XmlElement::new(prefixes.drawing("lstStyle")));
1770        for paragraph in paragraphs {
1771            body = body.with_child(build_paragraph(paragraph, None, None, prefixes));
1772        }
1773        if paragraphs.is_empty() {
1774            body = body.with_child(XmlElement::new(prefixes.drawing("p")));
1775        }
1776        shape = shape.with_child(body);
1777    }
1778    Ok(shape)
1779}
1780
1781fn slide_xml(name: Option<&str>, shapes: &[ShapeAdd], part: &str) -> Result<Vec<u8>, PptxError> {
1782    let mut root = XmlElement::new("p:sld")
1783        .with_attribute("xmlns:a", DRAWINGML_NS)
1784        .with_attribute("xmlns:r", OFFICE_RELATIONSHIPS_NS)
1785        .with_attribute("xmlns:p", PRESENTATIONML_NS);
1786    let prefixes = Prefixes {
1787        drawing: "a".to_owned(),
1788        presentation: "p".to_owned(),
1789    };
1790    let group_transform = XmlElement::new(prefixes.drawing("xfrm"))
1791        .with_child(
1792            XmlElement::new(prefixes.drawing("off"))
1793                .with_attribute("x", "0")
1794                .with_attribute("y", "0"),
1795        )
1796        .with_child(
1797            XmlElement::new(prefixes.drawing("ext"))
1798                .with_attribute("cx", "0")
1799                .with_attribute("cy", "0"),
1800        )
1801        .with_child(
1802            XmlElement::new(prefixes.drawing("chOff"))
1803                .with_attribute("x", "0")
1804                .with_attribute("y", "0"),
1805        )
1806        .with_child(
1807            XmlElement::new(prefixes.drawing("chExt"))
1808                .with_attribute("cx", "0")
1809                .with_attribute("cy", "0"),
1810        );
1811    let mut tree = XmlElement::new(prefixes.presentation("spTree"))
1812        .with_child(
1813            XmlElement::new(prefixes.presentation("nvGrpSpPr"))
1814                .with_child(
1815                    XmlElement::new(prefixes.presentation("cNvPr"))
1816                        .with_attribute("id", "1")
1817                        .with_attribute("name", ""),
1818                )
1819                .with_child(XmlElement::new(prefixes.presentation("cNvGrpSpPr")))
1820                .with_child(XmlElement::new(prefixes.presentation("nvPr"))),
1821        )
1822        .with_child(XmlElement::new(prefixes.presentation("grpSpPr")).with_child(group_transform));
1823    let mut next_shape_id = 2;
1824    for shape in shapes {
1825        tree = tree.with_child(shape_element(shape, &mut next_shape_id, &prefixes, part)?);
1826    }
1827    let mut common = XmlElement::new(prefixes.presentation("cSld"));
1828    if let Some(name) = name {
1829        common.set_attribute("name", name);
1830    }
1831    root = root.with_child(common.with_child(tree)).with_child(
1832        XmlElement::new(prefixes.presentation("clrMapOvr"))
1833            .with_child(XmlElement::new(prefixes.drawing("masterClrMapping"))),
1834    );
1835    Ok(serialize_xml(&root))
1836}
1837
1838fn slide_relationships_xml(part_path: &str, layout_part_path: &str) -> Vec<u8> {
1839    let root = XmlElement::new("Relationships")
1840        .with_attribute(
1841            "xmlns",
1842            "http://schemas.openxmlformats.org/package/2006/relationships",
1843        )
1844        .with_child(
1845            XmlElement::new("Relationship")
1846                .with_attribute("Id", "rId1")
1847                .with_attribute("Type", SLIDE_LAYOUT_RELATIONSHIP_TYPE)
1848                .with_attribute("Target", relative_target(part_path, layout_part_path)),
1849        );
1850    serialize_xml(&root)
1851}