Skip to main content

pptx_edit/
save.rs

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