Skip to main content

pptx_edit/
save.rs

1//! Baseline-diff write-back: the live CRDT state is compared against a
2//! freshly seeded copy of the source package and only differences are written.
3
4use std::collections::HashMap;
5
6use ooxml_drawingml::{ColorValue, ShapeFill};
7use pptx_parse::{
8    Bullet, CommentAuthorWrite, CommentFlavor, CommentSlide, CommentWrite, CommentsWrite,
9    DeckWrite, InheritedTransform, NotesWrite, ParagraphWrite, Placeholder, PptxPackage,
10    RunProperties, RunWrite, ShapeAdd, ShapeNode, ShapePatch, ShapeTransform, ShapeWrite,
11    SlideLayout, SlideMaster, SlideWrite, TextTarget, TextWrite,
12};
13
14use crate::comments::{derived_guid, seeded_comment_id};
15use crate::deck::{seed_doc, snapshot_doc};
16use crate::{
17    BOOTSTRAP_CLIENT_ID, CommentSnapshot, DeckSession, DeckSnapshot, EditError, EditResult,
18    ParagraphSnapshot, ShapeKind, ShapeSnapshot, SlideSnapshot, StorySnapshot, TextRunSnapshot,
19    doc_with_client_id,
20};
21
22/// Source shapes and inherited geometry.
23struct SlideContext<'a> {
24    layout: Option<&'a SlideLayout>,
25    master: Option<&'a SlideMaster>,
26    source_shapes: &'a [ShapeNode],
27}
28
29impl<'a> SlideContext<'a> {
30    fn new(package: &'a PptxPackage, snapshot: &SlideSnapshot) -> Self {
31        let source_shapes = snapshot
32            .source_part_path
33            .as_deref()
34            .and_then(|path| package.slides.iter().find(|slide| slide.part_path == path))
35            .map(|slide| slide.shapes.as_slice())
36            .unwrap_or_default();
37        let layout = snapshot
38            .layout_part_path
39            .as_deref()
40            .and_then(|path| {
41                package
42                    .layouts
43                    .iter()
44                    .find(|layout| layout.part_path == path)
45            })
46            .or_else(|| package.layouts.first());
47        let master = layout
48            .and_then(|layout| layout.master_part_path.as_deref())
49            .and_then(|path| {
50                package
51                    .masters
52                    .iter()
53                    .find(|master| master.part_path == path)
54            })
55            .or_else(|| {
56                layout.and_then(|layout| {
57                    package.masters.iter().find(|master| {
58                        master
59                            .layout_part_paths
60                            .iter()
61                            .any(|path| path == &layout.part_path)
62                    })
63                })
64            })
65            .or_else(|| package.masters.first());
66        Self {
67            layout,
68            master,
69            source_shapes,
70        }
71    }
72}
73
74impl DeckSession {
75    /// Serializes the deck with all edits applied. Untouched slides keep their
76    /// exact source part bytes; edited slides are patched at the XML level.
77    pub fn save(&self) -> EditResult<Vec<u8>> {
78        if !self.package.has_parts() {
79            return Err(EditError::Write(
80                "this session carries no source file bytes; open it from the \
81                 file, or from the update plus the source file, to save"
82                    .to_owned(),
83            ));
84        }
85        let current = self.snapshot()?;
86        let baseline = baseline_snapshot(&self.package)?;
87        if current == baseline {
88            return pptx_parse::write_pptx(&self.package)
89                .map_err(|error| EditError::Write(error.to_string()));
90        }
91        let deck = deck_write(&current, &baseline, &self.package)?;
92        pptx_parse::write_pptx_with_edits(&self.package, &deck)
93            .map_err(|error| EditError::Write(error.to_string()))
94    }
95}
96
97fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
98    let doc = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
99    seed_doc(&doc, package, "")?;
100    snapshot_doc(&doc, package)
101}
102
103fn deck_write(
104    current: &DeckSnapshot,
105    baseline: &DeckSnapshot,
106    package: &PptxPackage,
107) -> EditResult<DeckWrite> {
108    let baseline_slides: HashMap<&str, &SlideSnapshot> = baseline
109        .slides
110        .iter()
111        .map(|slide| (slide.id.as_str(), slide))
112        .collect();
113    let mut slides = Vec::with_capacity(current.slides.len());
114    for slide in &current.slides {
115        let write = match baseline_slides.get(slide.id.as_str()) {
116            Some(base) if *base == slide => SlideWrite::Keep {
117                part_path: source_part_path(slide)?,
118            },
119            Some(base) => SlideWrite::Patch {
120                part_path: source_part_path(slide)?,
121                shapes: {
122                    let context = SlideContext::new(package, slide);
123                    shape_writes(&slide.shapes, &base.shapes, &context, context.source_shapes)?
124                },
125            },
126            None => SlideWrite::Add {
127                name: slide.name.clone(),
128                layout_part_path: slide.layout_part_path.clone(),
129                shapes: slide
130                    .shapes
131                    .iter()
132                    .map(shape_add)
133                    .collect::<EditResult<Vec<_>>>()?,
134            },
135        };
136        slides.push(write);
137    }
138    Ok(DeckWrite {
139        slides,
140        comments: comments_write(current, baseline, package),
141        notes: notes_write(current, baseline),
142    })
143}
144
145fn notes_write(current: &DeckSnapshot, baseline: &DeckSnapshot) -> Option<NotesWrite> {
146    let baseline_notes: HashMap<&str, &str> = baseline
147        .slides
148        .iter()
149        .map(|slide| (slide.id.as_str(), slide.notes.as_str()))
150        .collect();
151    let mut per_slide = Vec::new();
152    for (index, slide) in current.slides.iter().enumerate() {
153        let changed = baseline_notes
154            .get(slide.id.as_str())
155            .is_none_or(|base| *base != slide.notes.as_str());
156        if !changed {
157            continue;
158        }
159        let target = match slide.source_part_path.clone() {
160            Some(part_path) => CommentSlide::Existing(part_path),
161            None => CommentSlide::Added(index),
162        };
163        per_slide.push((target, slide.notes.clone()));
164    }
165    (!per_slide.is_empty()).then_some(NotesWrite { per_slide })
166}
167
168fn comments_write(
169    current: &DeckSnapshot,
170    baseline: &DeckSnapshot,
171    package: &PptxPackage,
172) -> Option<CommentsWrite> {
173    if current.comments == baseline.comments && current.comment_flavor == baseline.comment_flavor {
174        return None;
175    }
176    let baseline_live: Vec<&CommentSnapshot> = baseline
177        .comments
178        .iter()
179        .filter(|comment| {
180            current
181                .slides
182                .iter()
183                .any(|slide| slide.id == comment.slide_id)
184        })
185        .collect();
186    if !current.comments.is_empty()
187        && current.comments.len() < baseline.comments.len()
188        && current.comment_flavor == baseline.comment_flavor
189        && current.comments.iter().eq(baseline_live)
190    {
191        return None;
192    }
193    let flavor = current.comment_flavor;
194    let live: Vec<&CommentSnapshot> = current
195        .comments
196        .iter()
197        .filter(|comment| {
198            current
199                .slides
200                .iter()
201                .any(|slide| slide.id == comment.slide_id)
202        })
203        .collect();
204
205    let source: HashMap<_, _> = package
206        .comments
207        .iter()
208        .enumerate()
209        .filter(|(_, comment)| comment.flavor == flavor)
210        .map(|(index, comment)| (seeded_comment_id(index, &comment.id), comment))
211        .collect();
212    let mut authors: Vec<CommentAuthorWrite> = if live.is_empty() || source.is_empty() {
213        Vec::new()
214    } else {
215        package
216            .comment_authors
217            .iter()
218            .map(|author| CommentAuthorWrite {
219                id: author.id.clone(),
220                name: author.name.clone(),
221                initials: author.initials.clone(),
222                last_index: author.last_index.unwrap_or_default(),
223                color_index: author.color_index.unwrap_or_default(),
224                user_id: author.user_id.clone(),
225                provider_id: author.provider_id.clone(),
226            })
227            .collect()
228    };
229    let mut per_slide: Vec<(CommentSlide, Vec<CommentWrite>)> = current
230        .slides
231        .iter()
232        .enumerate()
233        .map(|(index, slide)| {
234            let target = match slide.source_part_path.clone() {
235                Some(part_path) => CommentSlide::Existing(part_path),
236                None => CommentSlide::Added(index),
237            };
238            (target, Vec::new())
239        })
240        .collect();
241
242    for comment in live.iter().filter(|comment| comment.parent_id.is_none()) {
243        let Some(index) = current
244            .slides
245            .iter()
246            .position(|slide| slide.id == comment.slide_id)
247        else {
248            continue;
249        };
250        let mut write = preserved_comment(&mut authors, flavor, comment, &source);
251        if flavor == CommentFlavor::Modern {
252            for reply in live
253                .iter()
254                .filter(|reply| reply.parent_id.as_deref() == Some(comment.id.as_str()))
255            {
256                write
257                    .replies
258                    .push(preserved_comment(&mut authors, flavor, reply, &source));
259            }
260        }
261        if let Some((_, slide_comments)) = per_slide.get_mut(index) {
262            slide_comments.push(write);
263        }
264    }
265
266    Some(CommentsWrite {
267        flavor,
268        authors,
269        per_slide: per_slide
270            .into_iter()
271            .enumerate()
272            .filter_map(|(index, entry)| {
273                let slide_id = &current.slides[index].id;
274                let unchanged = flavor == baseline.comment_flavor
275                    && current
276                        .comments
277                        .iter()
278                        .filter(|comment| &comment.slide_id == slide_id)
279                        .eq(baseline
280                            .comments
281                            .iter()
282                            .filter(|comment| &comment.slide_id == slide_id));
283                (!unchanged).then_some(entry)
284            })
285            .collect(),
286    })
287}
288
289fn preserved_comment(
290    authors: &mut Vec<CommentAuthorWrite>,
291    flavor: CommentFlavor,
292    comment: &CommentSnapshot,
293    source: &HashMap<String, &pptx_parse::Comment>,
294) -> CommentWrite {
295    let Some(original) = source.get(&comment.id) else {
296        for author in authors.iter_mut() {
297            author.last_index = source
298                .values()
299                .filter(|item| item.author_id == author.id)
300                .filter_map(|item| item.id.rsplit_once(':')?.1.parse::<u32>().ok())
301                .fold(author.last_index, u32::max);
302        }
303        return charge_author(authors, flavor, comment);
304    };
305    let index = original
306        .id
307        .rsplit_once(':')
308        .and_then(|(_, index)| index.parse().ok())
309        .unwrap_or(0);
310    let mut write = comment_write(comment, original.author_id.clone(), index);
311    write.id = original.id.clone();
312    if comment.resolved == (original.status.as_deref() == Some("resolved")) {
313        write.status = original.status.clone();
314    } else if !comment.resolved {
315        write.status = Some("active".to_owned());
316    }
317    write
318}
319
320fn charge_author(
321    authors: &mut Vec<CommentAuthorWrite>,
322    flavor: CommentFlavor,
323    comment: &CommentSnapshot,
324) -> CommentWrite {
325    let slot = match authors
326        .iter()
327        .position(|known| known.name == comment.author && known.initials == comment.initials)
328    {
329        Some(slot) => slot,
330        None => {
331            let slot = authors.len();
332            let id = match flavor {
333                CommentFlavor::Legacy => authors
334                    .iter()
335                    .filter_map(|author| author.id.parse::<u32>().ok())
336                    .max()
337                    .map_or(0, |id| id + 1)
338                    .to_string(),
339                CommentFlavor::Modern => {
340                    derived_guid(&format!("author:{}:{}", comment.author, comment.initials))
341                }
342            };
343            authors.push(CommentAuthorWrite {
344                id,
345                name: comment.author.clone(),
346                initials: comment.initials.clone(),
347                last_index: 0,
348                color_index: slot as u32,
349                user_id: None,
350                provider_id: None,
351            });
352            slot
353        }
354    };
355    authors[slot].last_index += 1;
356    comment_write(comment, authors[slot].id.clone(), authors[slot].last_index)
357}
358
359fn comment_write(comment: &CommentSnapshot, author_id: String, index: u32) -> CommentWrite {
360    CommentWrite {
361        id: derived_guid(&comment.id),
362        author_id,
363        index,
364        text: comment.text.clone(),
365        created: comment.created.clone(),
366        x_emu: comment.x_emu,
367        y_emu: comment.y_emu,
368        status: comment.resolved.then(|| "resolved".to_owned()),
369        replies: Vec::new(),
370    }
371}
372
373fn source_part_path(slide: &SlideSnapshot) -> EditResult<String> {
374    slide
375        .source_part_path
376        .clone()
377        .ok_or_else(|| EditError::Write(format!("slide {} has no source part", slide.id)))
378}
379
380fn shape_writes(
381    current: &[ShapeSnapshot],
382    baseline: &[ShapeSnapshot],
383    context: &SlideContext<'_>,
384    source: &[ShapeNode],
385) -> EditResult<Vec<ShapeWrite>> {
386    let baseline_shapes: HashMap<&str, &ShapeSnapshot> = baseline
387        .iter()
388        .map(|shape| (shape.id.as_str(), shape))
389        .collect();
390    let mut writes = Vec::with_capacity(current.len());
391    for shape in current {
392        let write = match baseline_shapes.get(shape.id.as_str()) {
393            Some(base) if *base == shape => ShapeWrite::Keep {
394                source_index: addressed_source_index(shape, source)?,
395            },
396            Some(base) => {
397                let index = addressed_source_index(shape, source)?;
398                ShapeWrite::Patch {
399                    source_index: index,
400                    patch: Box::new(shape_patch(
401                        shape,
402                        base,
403                        context,
404                        group_children(source, index),
405                    )?),
406                }
407            }
408            None => ShapeWrite::Add(Box::new(shape_add(shape)?)),
409        };
410        writes.push(write);
411    }
412    Ok(writes)
413}
414
415/// Checks the source identity before resolving an ordinal.
416fn addressed_source_index(shape: &ShapeSnapshot, source: &[ShapeNode]) -> EditResult<usize> {
417    let index = source_index(&shape.id)?;
418    match source.get(index) {
419        Some(node) if node.id() == shape.source_id => Ok(index),
420        _ => Err(EditError::Write(format!(
421            "shape {} no longer addresses source shape {}: this update was seeded by a \
422             different parse of the file and has to be re-seeded from it",
423            shape.id, shape.source_id
424        ))),
425    }
426}
427
428fn group_children(source: &[ShapeNode], index: usize) -> &[ShapeNode] {
429    match source.get(index) {
430        Some(ShapeNode::Group(group)) => &group.children,
431        _ => &[],
432    }
433}
434
435fn source_index(shape_id: &str) -> EditResult<usize> {
436    shape_id
437        .rfind(":shape:")
438        .map(|position| &shape_id[position + ":shape:".len()..])
439        .and_then(|path| path.rsplit('.').next())
440        .and_then(|segment| segment.parse().ok())
441        .ok_or_else(|| EditError::Write(format!("shape {shape_id} has no source index")))
442}
443
444fn shape_patch(
445    shape: &ShapeSnapshot,
446    base: &ShapeSnapshot,
447    context: &SlideContext<'_>,
448    source_children: &[ShapeNode],
449) -> EditResult<ShapePatch> {
450    let mut patch = ShapePatch::default();
451    let moved = (shape.x, shape.y) != (base.x, base.y);
452    let resized = (shape.width, shape.height) != (base.width, base.height);
453    if moved || resized {
454        patch.offset = moved.then_some((shape.x, shape.y));
455        patch.extent = resized.then_some((shape.width, shape.height));
456        let source_inherited = base.width <= 0 || base.height <= 0;
457        patch.inherited = source_inherited.then(|| {
458            inherited_transform(shape, context)
459                .map(|transform| InheritedTransform {
460                    x: transform.x,
461                    y: transform.y,
462                    width: transform.width,
463                    height: transform.height,
464                    rotation_deg: transform.rotation_deg,
465                    flip_horizontal: transform.flip_h,
466                    flip_vertical: transform.flip_v,
467                })
468                .unwrap_or(InheritedTransform {
469                    x: shape.x,
470                    y: shape.y,
471                    width: shape.width,
472                    height: shape.height,
473                    rotation_deg: shape.rotation_deg,
474                    flip_horizontal: shape.flip_h,
475                    flip_vertical: shape.flip_v,
476                })
477        });
478    }
479    if shape.fill != base.fill {
480        patch.fill = Some(
481            shape
482                .fill
483                .clone()
484                .unwrap_or_else(|| ShapeFill::named("none")),
485        );
486    }
487    if shape.outline != base.outline {
488        patch.outline = Some(shape.outline.clone().unwrap_or_default());
489    }
490    if shape.adjust_values != base.adjust_values {
491        patch.adjust_values = Some(shape.adjust_values.clone());
492    }
493    let baseline_stories: HashMap<&str, &StorySnapshot> = base
494        .text_stories
495        .iter()
496        .map(|story| (story.id.as_str(), story))
497        .collect();
498    for story in &shape.text_stories {
499        let base_story = baseline_stories.get(story.id.as_str()).copied();
500        if base_story == Some(story) {
501            continue;
502        }
503        patch.texts.push(TextWrite {
504            target: text_target(&story.id, &shape.id)?,
505            paragraphs: paragraph_writes(story, base_story)?,
506        });
507    }
508    if shape.children != base.children {
509        patch.children = shape_writes(&shape.children, &base.children, context, source_children)?;
510    }
511    Ok(patch)
512}
513
514/// The transform a placeholder inherits: the layout's matching placeholder,
515/// then the master's. The shape's own parsed node cannot contribute — a
516/// positive extent there would already be in the snapshot.
517fn inherited_transform<'a>(
518    shape: &ShapeSnapshot,
519    context: &SlideContext<'a>,
520) -> Option<&'a ShapeTransform> {
521    let layout = shape.placeholder.as_ref().and_then(|placeholder| {
522        context
523            .layout
524            .and_then(|layout| find_placeholder(&layout.shapes, placeholder))
525    });
526    let master = shape.placeholder.as_ref().and_then(|placeholder| {
527        context
528            .master
529            .and_then(|master| find_placeholder(&master.shapes, placeholder))
530    });
531    [layout, master]
532        .into_iter()
533        .flatten()
534        .map(node_transform)
535        .find(|transform| transform.width > 0 && transform.height > 0)
536}
537
538fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
539    for node in nodes {
540        if node_placeholder(node).is_some_and(|value| placeholders_match(value, target)) {
541            return Some(node);
542        }
543        if let ShapeNode::Group(group) = node
544            && let Some(found) = find_placeholder(&group.children, target)
545        {
546            return Some(found);
547        }
548    }
549    None
550}
551
552fn placeholders_match(left: &Placeholder, right: &Placeholder) -> bool {
553    match (left.index, right.index) {
554        (Some(left), Some(right)) => left == right,
555        _ => {
556            normalize_placeholder_type(left.placeholder_type.as_deref())
557                == normalize_placeholder_type(right.placeholder_type.as_deref())
558        }
559    }
560}
561
562fn normalize_placeholder_type(value: Option<&str>) -> &str {
563    match value.unwrap_or("body") {
564        "ctrTitle" => "title",
565        "obj" => "body",
566        value => value,
567    }
568}
569
570fn node_placeholder(node: &ShapeNode) -> Option<&Placeholder> {
571    node_base(node).placeholder.as_ref()
572}
573
574fn node_transform(node: &ShapeNode) -> &ShapeTransform {
575    &node_base(node).transform
576}
577
578fn node_base(node: &ShapeNode) -> &pptx_parse::ShapeBase {
579    match node {
580        ShapeNode::Shape(shape) => &shape.base,
581        ShapeNode::Picture(shape) => &shape.base,
582        ShapeNode::GraphicFrame(shape) => &shape.base,
583        ShapeNode::Group(shape) => &shape.base,
584    }
585}
586
587fn text_target(story_id: &str, shape_id: &str) -> EditResult<TextTarget> {
588    let suffix = story_id
589        .strip_prefix("story:")
590        .and_then(|rest| rest.strip_prefix(shape_id))
591        .and_then(|rest| rest.strip_prefix(':'))
592        .ok_or_else(|| EditError::Write(format!("story {story_id} does not match its shape")))?;
593    if let Some(cell) = suffix.strip_prefix("table:") {
594        let (row, cell) = cell
595            .split_once(':')
596            .and_then(|(row, cell)| Some((row.parse().ok()?, cell.parse().ok()?)))
597            .ok_or_else(|| EditError::Write(format!("story {story_id} has no table address")))?;
598        return Ok(TextTarget::TableCell { row, cell });
599    }
600    Ok(TextTarget::Body)
601}
602
603fn paragraph_writes(
604    story: &StorySnapshot,
605    baseline: Option<&StorySnapshot>,
606) -> EditResult<Vec<ParagraphWrite>> {
607    let baseline_paragraphs: HashMap<&str, &ParagraphSnapshot> = baseline
608        .into_iter()
609        .flat_map(|story| &story.paragraphs)
610        .map(|paragraph| (paragraph.id.as_str(), paragraph))
611        .collect();
612    let prefix = format!("para:{}:", story.id);
613    let mut writes = Vec::with_capacity(story.paragraphs.len());
614    for paragraph in &story.paragraphs {
615        let source_index = paragraph
616            .id
617            .strip_prefix(&prefix)
618            .and_then(|index| index.parse::<usize>().ok());
619        let base = baseline_paragraphs.get(paragraph.id.as_str()).copied();
620        if base == Some(paragraph) && source_index.is_some() {
621            writes.push(ParagraphWrite {
622                source_index,
623                rebuild: false,
624                properties_changed: false,
625                alignment: None,
626                level: 0,
627                bullet: None,
628                runs: Vec::new(),
629            });
630            continue;
631        }
632        let properties_changed = match base {
633            Some(base) => {
634                base.alignment != paragraph.alignment
635                    || base.level != paragraph.level
636                    || base.bullet_json != paragraph.bullet_json
637            }
638            None => true,
639        };
640        let bullet = paragraph
641            .bullet_json
642            .as_deref()
643            .map(serde_json::from_str::<Bullet>)
644            .transpose()
645            .map_err(|error| EditError::Json(error.to_string()))?;
646        writes.push(ParagraphWrite {
647            source_index,
648            rebuild: true,
649            properties_changed,
650            alignment: paragraph.alignment.clone(),
651            level: paragraph.level,
652            bullet,
653            runs: paragraph.runs.iter().map(run_write).collect(),
654        });
655    }
656    Ok(writes)
657}
658
659fn run_write(run: &TextRunSnapshot) -> RunWrite {
660    RunWrite {
661        text: run.text.clone(),
662        properties: RunProperties {
663            font_size_pt: run.style.font_size_pt,
664            spacing_pt: run.style.spacing_pt,
665            baseline_pct: run.style.baseline_pct,
666            bold: run.style.bold,
667            italic: run.style.italic,
668            underline: run.style.underline.clone(),
669            font_family: run.style.font_family.clone(),
670            color: run.style.color.as_deref().map(color_from_hex),
671            language: None,
672            hyperlink_relationship_id: None,
673        },
674    }
675}
676
677fn color_from_hex(color: &str) -> ColorValue {
678    ColorValue {
679        rgb: Some(
680            color
681                .strip_prefix('#')
682                .unwrap_or(color)
683                .to_ascii_uppercase(),
684        ),
685        ..ColorValue::default()
686    }
687}
688
689fn shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
690    if shape.kind != ShapeKind::Shape {
691        return Err(EditError::Write(format!(
692            "shape {} cannot be written as a new shape",
693            shape.id
694        )));
695    }
696    let paragraphs = shape
697        .text_stories
698        .first()
699        .map(|story| paragraph_writes(story, None))
700        .transpose()?;
701    Ok(ShapeAdd {
702        name: shape.name.clone(),
703        geometry: shape.geometry.clone(),
704        x: shape.x,
705        y: shape.y,
706        width: shape.width,
707        height: shape.height,
708        adjust_values: shape.adjust_values.clone(),
709        fill: shape.fill.clone(),
710        outline: shape.outline.clone(),
711        paragraphs,
712    })
713}