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, DeckWrite, InheritedTransform, ParagraphWrite, Placeholder, PptxPackage, RunProperties,
9    RunWrite, ShapeAdd, ShapeNode, ShapePatch, ShapeTransform, ShapeWrite, SlideLayout,
10    SlideMaster, SlideWrite, TextTarget, TextWrite,
11};
12
13use crate::deck::{seed_doc, snapshot_doc};
14use crate::{
15    BOOTSTRAP_CLIENT_ID, DeckSession, DeckSnapshot, EditError, EditResult, ParagraphSnapshot,
16    ShapeKind, ShapeSnapshot, SlideSnapshot, StorySnapshot, TextRunSnapshot, doc_with_client_id,
17};
18
19/// The parsed parts a slide's shapes may inherit geometry from.
20struct SlideContext<'a> {
21    layout: Option<&'a SlideLayout>,
22    master: Option<&'a SlideMaster>,
23}
24
25impl<'a> SlideContext<'a> {
26    fn new(package: &'a PptxPackage, snapshot: &SlideSnapshot) -> Self {
27        let layout = snapshot
28            .layout_part_path
29            .as_deref()
30            .and_then(|path| {
31                package
32                    .layouts
33                    .iter()
34                    .find(|layout| layout.part_path == path)
35            })
36            .or_else(|| package.layouts.first());
37        let master = layout
38            .and_then(|layout| layout.master_part_path.as_deref())
39            .and_then(|path| {
40                package
41                    .masters
42                    .iter()
43                    .find(|master| master.part_path == path)
44            })
45            .or_else(|| {
46                layout.and_then(|layout| {
47                    package.masters.iter().find(|master| {
48                        master
49                            .layout_part_paths
50                            .iter()
51                            .any(|path| path == &layout.part_path)
52                    })
53                })
54            })
55            .or_else(|| package.masters.first());
56        Self { layout, master }
57    }
58}
59
60impl DeckSession {
61    /// Serializes the deck with all edits applied. Untouched slides keep their
62    /// exact source part bytes; edited slides are patched at the XML level.
63    pub fn save(&self) -> EditResult<Vec<u8>> {
64        if !self.package.has_parts() {
65            return Err(EditError::Write(
66                "this session carries no source file bytes; open it from the \
67                 file, or from the update plus the source file, to save"
68                    .to_owned(),
69            ));
70        }
71        let current = self.snapshot()?;
72        let baseline = baseline_snapshot(&self.package)?;
73        if current == baseline {
74            return pptx_parse::write_pptx(&self.package)
75                .map_err(|error| EditError::Write(error.to_string()));
76        }
77        let deck = deck_write(&current, &baseline, &self.package)?;
78        pptx_parse::write_pptx_with_edits(&self.package, &deck)
79            .map_err(|error| EditError::Write(error.to_string()))
80    }
81}
82
83fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
84    let doc = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
85    seed_doc(&doc, package, "")?;
86    snapshot_doc(&doc, package)
87}
88
89fn deck_write(
90    current: &DeckSnapshot,
91    baseline: &DeckSnapshot,
92    package: &PptxPackage,
93) -> EditResult<DeckWrite> {
94    let baseline_slides: HashMap<&str, &SlideSnapshot> = baseline
95        .slides
96        .iter()
97        .map(|slide| (slide.id.as_str(), slide))
98        .collect();
99    let mut slides = Vec::with_capacity(current.slides.len());
100    for slide in &current.slides {
101        let write = match baseline_slides.get(slide.id.as_str()) {
102            Some(base) if *base == slide => SlideWrite::Keep {
103                part_path: source_part_path(slide)?,
104            },
105            Some(base) => SlideWrite::Patch {
106                part_path: source_part_path(slide)?,
107                shapes: shape_writes(
108                    &slide.shapes,
109                    &base.shapes,
110                    &SlideContext::new(package, slide),
111                )?,
112            },
113            None => SlideWrite::Add {
114                name: slide.name.clone(),
115                layout_part_path: slide.layout_part_path.clone(),
116                shapes: slide
117                    .shapes
118                    .iter()
119                    .map(shape_add)
120                    .collect::<EditResult<Vec<_>>>()?,
121            },
122        };
123        slides.push(write);
124    }
125    Ok(DeckWrite { slides })
126}
127
128fn source_part_path(slide: &SlideSnapshot) -> EditResult<String> {
129    slide
130        .source_part_path
131        .clone()
132        .ok_or_else(|| EditError::Write(format!("slide {} has no source part", slide.id)))
133}
134
135fn shape_writes(
136    current: &[ShapeSnapshot],
137    baseline: &[ShapeSnapshot],
138    context: &SlideContext<'_>,
139) -> EditResult<Vec<ShapeWrite>> {
140    let baseline_shapes: HashMap<&str, &ShapeSnapshot> = baseline
141        .iter()
142        .map(|shape| (shape.id.as_str(), shape))
143        .collect();
144    let mut writes = Vec::with_capacity(current.len());
145    for shape in current {
146        let write = match baseline_shapes.get(shape.id.as_str()) {
147            Some(base) if *base == shape => ShapeWrite::Keep {
148                source_index: source_index(&shape.id)?,
149            },
150            Some(base) => ShapeWrite::Patch {
151                source_index: source_index(&shape.id)?,
152                patch: Box::new(shape_patch(shape, base, context)?),
153            },
154            None => ShapeWrite::Add(Box::new(shape_add(shape)?)),
155        };
156        writes.push(write);
157    }
158    Ok(writes)
159}
160
161fn source_index(shape_id: &str) -> EditResult<usize> {
162    shape_id
163        .rfind(":shape:")
164        .map(|position| &shape_id[position + ":shape:".len()..])
165        .and_then(|path| path.rsplit('.').next())
166        .and_then(|segment| segment.parse().ok())
167        .ok_or_else(|| EditError::Write(format!("shape {shape_id} has no source index")))
168}
169
170fn shape_patch(
171    shape: &ShapeSnapshot,
172    base: &ShapeSnapshot,
173    context: &SlideContext<'_>,
174) -> EditResult<ShapePatch> {
175    let mut patch = ShapePatch::default();
176    let moved = (shape.x, shape.y) != (base.x, base.y);
177    let resized = (shape.width, shape.height) != (base.width, base.height);
178    if moved || resized {
179        patch.offset = moved.then_some((shape.x, shape.y));
180        patch.extent = resized.then_some((shape.width, shape.height));
181        let source_inherited = base.width <= 0 || base.height <= 0;
182        patch.inherited = source_inherited.then(|| {
183            inherited_transform(shape, context)
184                .map(|transform| InheritedTransform {
185                    x: transform.x,
186                    y: transform.y,
187                    width: transform.width,
188                    height: transform.height,
189                    rotation_deg: transform.rotation_deg,
190                    flip_horizontal: transform.flip_h,
191                    flip_vertical: transform.flip_v,
192                })
193                .unwrap_or(InheritedTransform {
194                    x: shape.x,
195                    y: shape.y,
196                    width: shape.width,
197                    height: shape.height,
198                    rotation_deg: shape.rotation_deg,
199                    flip_horizontal: shape.flip_h,
200                    flip_vertical: shape.flip_v,
201                })
202        });
203    }
204    if shape.fill != base.fill {
205        patch.fill = Some(
206            shape
207                .fill
208                .clone()
209                .unwrap_or_else(|| ShapeFill::named("none")),
210        );
211    }
212    if shape.outline != base.outline {
213        patch.outline = Some(shape.outline.clone().unwrap_or_default());
214    }
215    if shape.adjust_values != base.adjust_values {
216        patch.adjust_values = Some(shape.adjust_values.clone());
217    }
218    let baseline_stories: HashMap<&str, &StorySnapshot> = base
219        .text_stories
220        .iter()
221        .map(|story| (story.id.as_str(), story))
222        .collect();
223    for story in &shape.text_stories {
224        let base_story = baseline_stories.get(story.id.as_str()).copied();
225        if base_story == Some(story) {
226            continue;
227        }
228        patch.texts.push(TextWrite {
229            target: text_target(&story.id, &shape.id)?,
230            paragraphs: paragraph_writes(story, base_story)?,
231        });
232    }
233    if shape.children != base.children {
234        patch.children = shape_writes(&shape.children, &base.children, context)?;
235    }
236    Ok(patch)
237}
238
239/// The transform a placeholder inherits: the layout's matching placeholder,
240/// then the master's. The shape's own parsed node cannot contribute — a
241/// positive extent there would already be in the snapshot.
242fn inherited_transform<'a>(
243    shape: &ShapeSnapshot,
244    context: &SlideContext<'a>,
245) -> Option<&'a ShapeTransform> {
246    let layout = shape.placeholder.as_ref().and_then(|placeholder| {
247        context
248            .layout
249            .and_then(|layout| find_placeholder(&layout.shapes, placeholder))
250    });
251    let master = shape.placeholder.as_ref().and_then(|placeholder| {
252        context
253            .master
254            .and_then(|master| find_placeholder(&master.shapes, placeholder))
255    });
256    [layout, master]
257        .into_iter()
258        .flatten()
259        .map(node_transform)
260        .find(|transform| transform.width > 0 && transform.height > 0)
261}
262
263fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
264    for node in nodes {
265        if node_placeholder(node).is_some_and(|value| placeholders_match(value, target)) {
266            return Some(node);
267        }
268        if let ShapeNode::Group(group) = node
269            && let Some(found) = find_placeholder(&group.children, target)
270        {
271            return Some(found);
272        }
273    }
274    None
275}
276
277fn placeholders_match(left: &Placeholder, right: &Placeholder) -> bool {
278    match (left.index, right.index) {
279        (Some(left), Some(right)) => left == right,
280        _ => {
281            normalize_placeholder_type(left.placeholder_type.as_deref())
282                == normalize_placeholder_type(right.placeholder_type.as_deref())
283        }
284    }
285}
286
287fn normalize_placeholder_type(value: Option<&str>) -> &str {
288    match value.unwrap_or("body") {
289        "ctrTitle" => "title",
290        "obj" => "body",
291        value => value,
292    }
293}
294
295fn node_placeholder(node: &ShapeNode) -> Option<&Placeholder> {
296    node_base(node).placeholder.as_ref()
297}
298
299fn node_transform(node: &ShapeNode) -> &ShapeTransform {
300    &node_base(node).transform
301}
302
303fn node_base(node: &ShapeNode) -> &pptx_parse::ShapeBase {
304    match node {
305        ShapeNode::Shape(shape) => &shape.base,
306        ShapeNode::Picture(shape) => &shape.base,
307        ShapeNode::GraphicFrame(shape) => &shape.base,
308        ShapeNode::Group(shape) => &shape.base,
309    }
310}
311
312fn text_target(story_id: &str, shape_id: &str) -> EditResult<TextTarget> {
313    let suffix = story_id
314        .strip_prefix("story:")
315        .and_then(|rest| rest.strip_prefix(shape_id))
316        .and_then(|rest| rest.strip_prefix(':'))
317        .ok_or_else(|| EditError::Write(format!("story {story_id} does not match its shape")))?;
318    if let Some(cell) = suffix.strip_prefix("table:") {
319        let (row, cell) = cell
320            .split_once(':')
321            .and_then(|(row, cell)| Some((row.parse().ok()?, cell.parse().ok()?)))
322            .ok_or_else(|| EditError::Write(format!("story {story_id} has no table address")))?;
323        return Ok(TextTarget::TableCell { row, cell });
324    }
325    Ok(TextTarget::Body)
326}
327
328fn paragraph_writes(
329    story: &StorySnapshot,
330    baseline: Option<&StorySnapshot>,
331) -> EditResult<Vec<ParagraphWrite>> {
332    let baseline_paragraphs: HashMap<&str, &ParagraphSnapshot> = baseline
333        .into_iter()
334        .flat_map(|story| &story.paragraphs)
335        .map(|paragraph| (paragraph.id.as_str(), paragraph))
336        .collect();
337    let prefix = format!("para:{}:", story.id);
338    let mut writes = Vec::with_capacity(story.paragraphs.len());
339    for paragraph in &story.paragraphs {
340        let source_index = paragraph
341            .id
342            .strip_prefix(&prefix)
343            .and_then(|index| index.parse::<usize>().ok());
344        let base = baseline_paragraphs.get(paragraph.id.as_str()).copied();
345        if base == Some(paragraph) && source_index.is_some() {
346            writes.push(ParagraphWrite {
347                source_index,
348                rebuild: false,
349                properties_changed: false,
350                alignment: None,
351                level: 0,
352                bullet: None,
353                runs: Vec::new(),
354            });
355            continue;
356        }
357        let properties_changed = match base {
358            Some(base) => {
359                base.alignment != paragraph.alignment
360                    || base.level != paragraph.level
361                    || base.bullet_json != paragraph.bullet_json
362            }
363            None => true,
364        };
365        let bullet = paragraph
366            .bullet_json
367            .as_deref()
368            .map(serde_json::from_str::<Bullet>)
369            .transpose()
370            .map_err(|error| EditError::Json(error.to_string()))?;
371        writes.push(ParagraphWrite {
372            source_index,
373            rebuild: true,
374            properties_changed,
375            alignment: paragraph.alignment.clone(),
376            level: paragraph.level,
377            bullet,
378            runs: paragraph.runs.iter().map(run_write).collect(),
379        });
380    }
381    Ok(writes)
382}
383
384fn run_write(run: &TextRunSnapshot) -> RunWrite {
385    RunWrite {
386        text: run.text.clone(),
387        properties: RunProperties {
388            font_size_pt: run.style.font_size_pt,
389            bold: run.style.bold,
390            italic: run.style.italic,
391            underline: run.style.underline.clone(),
392            font_family: run.style.font_family.clone(),
393            color: run.style.color.as_deref().map(color_from_hex),
394            language: None,
395            hyperlink_relationship_id: None,
396        },
397    }
398}
399
400fn color_from_hex(color: &str) -> ColorValue {
401    ColorValue {
402        rgb: Some(
403            color
404                .strip_prefix('#')
405                .unwrap_or(color)
406                .to_ascii_uppercase(),
407        ),
408        ..ColorValue::default()
409    }
410}
411
412fn shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
413    if shape.kind != ShapeKind::Shape {
414        return Err(EditError::Write(format!(
415            "shape {} cannot be written as a new shape",
416            shape.id
417        )));
418    }
419    let paragraphs = shape
420        .text_stories
421        .first()
422        .map(|story| paragraph_writes(story, None))
423        .transpose()?;
424    Ok(ShapeAdd {
425        name: shape.name.clone(),
426        geometry: shape.geometry.clone(),
427        x: shape.x,
428        y: shape.y,
429        width: shape.width,
430        height: shape.height,
431        adjust_values: shape.adjust_values.clone(),
432        fill: shape.fill.clone(),
433        outline: shape.outline.clone(),
434        paragraphs,
435    })
436}