Skip to main content

pptx_edit/
deck.rs

1use std::collections::{BTreeMap, HashSet};
2use std::sync::Arc;
3
4use ooxml_drawingml::{
5    ColorValue, ShapeFill, ShapeOutline, Theme, preset_geometry_default_adjustments,
6    preset_geometry_to_path, resolve_color_value_to_hex, resolve_color_value_to_hex_with_theme,
7};
8use pptx_parse::{PptxPackage, ShapeNode, Slide};
9use serde::de::DeserializeOwned;
10use yrs::{
11    Any, Array, ArrayPrelim, ArrayRef, Doc, Map, MapPrelim, MapRef, Out, ReadTxn, TextRef,
12    Transact, TransactionMut, WriteTxn,
13};
14
15use crate::story::{seed_plain_story, seed_story, snapshot_story, validate_story};
16use crate::{
17    DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN,
18    PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt, ShapeDraft,
19    ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
20    ShapeStrokeReceipt, SlideReceipt, SlideSnapshot, TransformReceipt,
21};
22
23const SCHEMA_VERSION: f64 = 2.0;
24/// Versions [`migrate_doc`] can carry forward. Anything else is unreadable.
25const MIGRATABLE_SCHEMA_VERSIONS: [f64; 2] = [1.0, SCHEMA_VERSION];
26const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
27const MAX_SHAPE_DEPTH: usize = 128;
28const EMU_PER_POINT: f64 = 12_700.0;
29const MAX_ADJUSTMENTS: usize = 32;
30const MAX_ADJUSTMENT_INDEX: usize = 32;
31
32pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
33    let package_json =
34        serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
35    let mut txn = doc.transact_mut_with("pptx:bootstrap");
36    let meta = txn.get_or_insert_map(META);
37    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
38    meta.insert(&mut txn, "fingerprint", fingerprint);
39    meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
40    meta.insert(
41        &mut txn,
42        "heightEmu",
43        package.presentation.height_emu as f64,
44    );
45    meta.insert(
46        &mut txn,
47        "packageJson",
48        Any::Buffer(Arc::from(package_json)),
49    );
50    let order = txn.get_or_insert_array(SLIDE_ORDER);
51    let slides = txn.get_or_insert_map(SLIDES);
52    let shapes = txn.get_or_insert_map(SHAPES);
53    let stories = txn.get_or_insert_map(STORIES);
54
55    for (slide_index, slide) in package.slides.iter().enumerate() {
56        let theme = slide_theme(package, slide);
57        let reference = &package.presentation.slides[slide_index];
58        let slide_id = format!("slide:{slide_index}:{}", reference.id);
59        order.push_back(&mut txn, slide_id.as_str());
60        let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
61        slide_map.insert(&mut txn, "id", slide_id.as_str());
62        slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
63        if let Some(layout) = &slide.layout_part_path {
64            slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
65        }
66        if let Some(name) = &slide.name {
67            slide_map.insert(&mut txn, "name", name.as_str());
68        }
69        let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
70        for (shape_index, shape) in slide.shapes.iter().enumerate() {
71            let shape_id = seed_shape(
72                &shapes,
73                &stories,
74                &mut txn,
75                &slide_id,
76                &shape_index.to_string(),
77                shape,
78                theme,
79            )?;
80            shape_order.push_back(&mut txn, shape_id.as_str());
81        }
82    }
83    Ok(())
84}
85
86fn seed_shape(
87    shapes: &MapRef,
88    stories: &MapRef,
89    txn: &mut TransactionMut<'_>,
90    slide_id: &str,
91    path: &str,
92    shape: &ShapeNode,
93    theme: Option<&Theme>,
94) -> EditResult<String> {
95    let shape_id = format!("{slide_id}:shape:{path}");
96    let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
97    let base = match shape {
98        ShapeNode::Shape(shape) => &shape.base,
99        ShapeNode::Picture(shape) => &shape.base,
100        ShapeNode::GraphicFrame(shape) => &shape.base,
101        ShapeNode::Group(shape) => &shape.base,
102    };
103    shape_map.insert(txn, "id", shape_id.as_str());
104    shape_map.insert(txn, "sourceId", base.id as f64);
105    shape_map.insert(txn, "name", base.name.as_str());
106    shape_map.insert(txn, "x", base.transform.x as f64);
107    shape_map.insert(txn, "y", base.transform.y as f64);
108    shape_map.insert(txn, "width", base.transform.width as f64);
109    shape_map.insert(txn, "height", base.transform.height as f64);
110    shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
111    shape_map.insert(txn, "flipH", base.transform.flip_h);
112    shape_map.insert(txn, "flipV", base.transform.flip_v);
113    insert_json(
114        &shape_map,
115        txn,
116        "placeholderJson",
117        base.placeholder.as_ref(),
118    )?;
119
120    let mut text_story_ids = Vec::new();
121    let mut child_ids = Vec::new();
122    match shape {
123        ShapeNode::Shape(shape) => {
124            shape_map.insert(txn, "kind", "shape");
125            shape_map.insert(txn, "geometry", shape.geometry.as_str());
126            let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
127                .into_iter()
128                .collect::<BTreeMap<_, _>>();
129            adjust_values.extend(shape.adjust_values.clone());
130            insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
131            insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
132            insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
133            if let Some(body) = &shape.text {
134                let story_id = format!("story:{shape_id}:0");
135                seed_story(stories, txn, &story_id, body, theme)?;
136                text_story_ids.push(story_id);
137            }
138        }
139        ShapeNode::Picture(picture) => {
140            shape_map.insert(txn, "kind", "picture");
141            shape_map.insert(txn, "geometry", "rect");
142            insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
143            insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
144            if let Some(media) = &picture.media_part_path {
145                shape_map.insert(txn, "mediaPartPath", media.as_str());
146            }
147        }
148        ShapeNode::GraphicFrame(frame) => {
149            shape_map.insert(txn, "kind", "graphicFrame");
150            shape_map.insert(txn, "geometry", "rect");
151            insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
152            if let pptx_parse::GraphicFrameData::Table { rows } = &frame.data {
153                for (row_index, row) in rows.iter().enumerate() {
154                    for (cell_index, body) in row.iter().enumerate() {
155                        let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
156                        seed_story(stories, txn, &story_id, body, theme)?;
157                        text_story_ids.push(story_id);
158                    }
159                }
160            }
161        }
162        ShapeNode::Group(group) => {
163            shape_map.insert(txn, "kind", "group");
164            shape_map.insert(txn, "geometry", "group");
165            for (child_index, child) in group.children.iter().enumerate() {
166                child_ids.push(seed_shape(
167                    shapes,
168                    stories,
169                    txn,
170                    slide_id,
171                    &format!("{path}.{child_index}"),
172                    child,
173                    theme,
174                )?);
175            }
176        }
177    }
178    shape_map.insert(txn, "textStories", string_array(&text_story_ids));
179    shape_map.insert(txn, "children", string_array(&child_ids));
180    Ok(shape_id)
181}
182
183fn slide_theme<'a>(package: &'a PptxPackage, slide: &Slide) -> Option<&'a Theme> {
184    theme_for_layout(package, slide.layout_part_path.as_deref())
185}
186
187fn theme_for_layout<'a>(
188    package: &'a PptxPackage,
189    layout_part_path: Option<&str>,
190) -> Option<&'a Theme> {
191    let layout = layout_part_path
192        .and_then(|path| {
193            package
194                .layouts
195                .iter()
196                .find(|layout| layout.part_path == path)
197        })
198        .or_else(|| package.layouts.first());
199    let master = layout
200        .and_then(|layout| layout.master_part_path.as_deref())
201        .and_then(|path| {
202            package
203                .masters
204                .iter()
205                .find(|master| master.part_path == path)
206        })
207        .or_else(|| {
208            layout.and_then(|layout| {
209                package.masters.iter().find(|master| {
210                    master
211                        .layout_part_paths
212                        .iter()
213                        .any(|path| path == &layout.part_path)
214                })
215            })
216        })
217        .or_else(|| package.masters.first());
218    master
219        .and_then(|master| master.theme_part_path.as_deref())
220        .and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
221        .map(|part| &part.theme)
222        .or_else(|| package.themes.first().map(|part| &part.theme))
223}
224
225fn insert_json<T: serde::Serialize>(
226    map: &MapRef,
227    txn: &mut TransactionMut<'_>,
228    key: &str,
229    value: Option<&T>,
230) -> EditResult<()> {
231    if let Some(value) = value {
232        let json =
233            serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
234        map.insert(txn, key, json);
235    }
236    Ok(())
237}
238
239impl DeckSession {
240    pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
241        snapshot_doc(&self.doc, &self.package)
242    }
243
244    pub fn insert_slide(
245        &self,
246        context: &EditCtx,
247        index: u32,
248        layout_part_path: Option<&str>,
249    ) -> EditResult<SlideReceipt> {
250        if let Some(path) = layout_part_path
251            && !self
252                .package
253                .layouts
254                .iter()
255                .any(|layout| layout.part_path == path)
256        {
257            return Err(EditError::InvalidState(format!(
258                "unknown slide layout {path:?}"
259            )));
260        }
261        let slide_id = self.next_id("slide");
262        let mut txn = self.transact_for(context);
263        let order = required_order(&txn)?;
264        let length = order.len(&txn);
265        if index > length {
266            return Err(EditError::OutOfBounds { index, length });
267        }
268        let slides = required_map(&txn, SLIDES)?;
269        let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
270        slide.insert(&mut txn, "id", slide_id.as_str());
271        slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
272        if let Some(layout_part_path) = layout_part_path {
273            slide.insert(&mut txn, "layoutPartPath", layout_part_path);
274        }
275        slide.insert(&mut txn, "shapes", ArrayPrelim::default());
276        order.insert(&mut txn, index, slide_id.as_str());
277        Ok(SlideReceipt {
278            slide_id,
279            from_index: None,
280            to_index: Some(index),
281        })
282    }
283
284    pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
285        let mut txn = self.transact_for(context);
286        let order = required_order(&txn)?;
287        let index = array_index(&order, &txn, slide_id)
288            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
289        let slides = required_map(&txn, SLIDES)?;
290        let slide = slide_ref(&txn, slide_id)?;
291        let shape_order = slide_shape_order(&slide, &txn)?;
292        let shape_ids = string_array_ref(&shape_order, &txn);
293        remove_shape_entries(&mut txn, &shape_ids)?;
294        order.remove(&mut txn, index);
295        slides.remove(&mut txn, slide_id);
296        Ok(SlideReceipt {
297            slide_id: slide_id.to_owned(),
298            from_index: Some(index),
299            to_index: None,
300        })
301    }
302
303    pub fn move_slide(
304        &self,
305        context: &EditCtx,
306        slide_id: &str,
307        to_index: u32,
308    ) -> EditResult<SlideReceipt> {
309        let mut txn = self.transact_for(context);
310        let order = required_order(&txn)?;
311        let length = order.len(&txn);
312        if to_index >= length {
313            return Err(EditError::OutOfBounds {
314                index: to_index,
315                length,
316            });
317        }
318        let from_index = array_index(&order, &txn, slide_id)
319            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
320        if from_index != to_index {
321            order.remove(&mut txn, from_index);
322            order.insert(&mut txn, to_index, slide_id);
323        }
324        Ok(SlideReceipt {
325            slide_id: slide_id.to_owned(),
326            from_index: Some(from_index),
327            to_index: Some(to_index),
328        })
329    }
330
331    pub fn add_text_box(
332        &self,
333        context: &EditCtx,
334        slide_id: &str,
335        draft: &ShapeDraft,
336    ) -> EditResult<ShapeReceipt> {
337        validate_rect(draft.rect)?;
338        crate::model::validate_xml_text(&draft.name)?;
339        crate::model::validate_xml_text(&draft.text)?;
340        crate::story::validate_style_values(
341            draft.style.font_family.as_deref(),
342            draft.style.underline.as_deref(),
343            draft.style.color.as_deref(),
344            draft.style.font_size_pt,
345        )?;
346        let shape_id = self.next_id("shape");
347        let story_id = format!("story:{shape_id}:0");
348        let paragraph_id = self.next_id("para");
349        let mut txn = self.transact_for(context);
350        let slide = slide_ref(&txn, slide_id)?;
351        let order = slide_shape_order(&slide, &txn)?;
352        let index = order.len(&txn);
353        let shapes = required_map(&txn, SHAPES)?;
354        let stories = required_map(&txn, STORIES)?;
355        seed_plain_story(
356            &stories,
357            &mut txn,
358            &story_id,
359            &paragraph_id,
360            &draft.text,
361            &draft.style,
362        );
363        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
364        shape.insert(&mut txn, "id", shape_id.as_str());
365        shape.insert(&mut txn, "sourceId", 0_f64);
366        shape.insert(&mut txn, "kind", "shape");
367        shape.insert(&mut txn, "name", draft.name.as_str());
368        shape.insert(&mut txn, "x", draft.rect.x as f64);
369        shape.insert(&mut txn, "y", draft.rect.y as f64);
370        shape.insert(&mut txn, "width", draft.rect.width as f64);
371        shape.insert(&mut txn, "height", draft.rect.height as f64);
372        shape.insert(&mut txn, "rotationDeg", 0_f64);
373        shape.insert(&mut txn, "flipH", false);
374        shape.insert(&mut txn, "flipV", false);
375        shape.insert(&mut txn, "geometry", "rect");
376        insert_json(
377            &shape,
378            &mut txn,
379            "fillJson",
380            Some(&ShapeFill::named("none")),
381        )?;
382        shape.insert(
383            &mut txn,
384            "textStories",
385            string_array(std::slice::from_ref(&story_id)),
386        );
387        shape.insert(&mut txn, "children", string_array(&[]));
388        order.push_back(&mut txn, shape_id.as_str());
389        Ok(ShapeReceipt {
390            slide_id: slide_id.to_owned(),
391            shape_id,
392            index,
393        })
394    }
395
396    pub fn add_shape(
397        &self,
398        context: &EditCtx,
399        slide_id: &str,
400        draft: &PresetShapeDraft,
401    ) -> EditResult<ShapeReceipt> {
402        validate_rect(draft.rect)?;
403        crate::model::validate_xml_text(&draft.name)?;
404        let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
405        if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none() {
406            return Err(EditError::InvalidGeometry(format!(
407                "unsupported preset geometry {}",
408                draft.geometry
409            )));
410        }
411        let fill = shape_fill(draft.fill.as_deref())?;
412        let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
413            .into_iter()
414            .collect::<BTreeMap<_, _>>();
415        let shape_id = self.next_id("shape");
416        let mut txn = self.transact_for(context);
417        let slide = slide_ref(&txn, slide_id)?;
418        let order = slide_shape_order(&slide, &txn)?;
419        let index = order.len(&txn);
420        let shapes = required_map(&txn, SHAPES)?;
421        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
422        shape.insert(&mut txn, "id", shape_id.as_str());
423        shape.insert(&mut txn, "sourceId", 0_f64);
424        shape.insert(&mut txn, "kind", "shape");
425        shape.insert(&mut txn, "name", draft.name.as_str());
426        shape.insert(&mut txn, "x", draft.rect.x as f64);
427        shape.insert(&mut txn, "y", draft.rect.y as f64);
428        shape.insert(&mut txn, "width", draft.rect.width as f64);
429        shape.insert(&mut txn, "height", draft.rect.height as f64);
430        shape.insert(&mut txn, "rotationDeg", 0_f64);
431        shape.insert(&mut txn, "flipH", false);
432        shape.insert(&mut txn, "flipV", false);
433        shape.insert(&mut txn, "geometry", draft.geometry.as_str());
434        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&adjust_values))?;
435        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
436        shape.insert(&mut txn, "textStories", string_array(&[]));
437        shape.insert(&mut txn, "children", string_array(&[]));
438        order.push_back(&mut txn, shape_id.as_str());
439        Ok(ShapeReceipt {
440            slide_id: slide_id.to_owned(),
441            shape_id,
442            index,
443        })
444    }
445
446    pub fn set_shape_fill(
447        &self,
448        context: &EditCtx,
449        slide_id: &str,
450        shape_id: &str,
451        color: Option<&str>,
452    ) -> EditResult<ShapeFillReceipt> {
453        let fill = shape_fill(color)?;
454        let mut txn = self.transact_for(context);
455        require_shape_membership(&txn, slide_id, shape_id)?;
456        let shape = shape_ref(&txn, shape_id)?;
457        require_shape_kind(&shape, &txn)?;
458        let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
459            .as_ref()
460            .and_then(fill_color);
461        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
462        Ok(ShapeFillReceipt {
463            slide_id: slide_id.to_owned(),
464            shape_id: shape_id.to_owned(),
465            before,
466            after: fill_color(&fill),
467        })
468    }
469
470    pub fn set_shape_stroke(
471        &self,
472        context: &EditCtx,
473        slide_id: &str,
474        shape_id: &str,
475        stroke: &ShapeStroke,
476    ) -> EditResult<ShapeStrokeReceipt> {
477        let color = stroke.color.as_deref().map(color_value).transpose()?;
478        if let Some(width) = stroke.width_pt
479            && (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
480        {
481            return Err(EditError::InvalidGeometry(format!(
482                "stroke width {width}pt is outside the safe range"
483            )));
484        }
485        let mut txn = self.transact_for(context);
486        require_shape_membership(&txn, slide_id, shape_id)?;
487        let shape = shape_ref(&txn, shape_id)?;
488        require_shape_kind(&shape, &txn)?;
489        let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
490        let before = existing.as_ref().and_then(outline_stroke);
491        let outline = if stroke.color.is_none() && stroke.width_pt.is_none() {
492            ShapeOutline::default()
493        } else {
494            let mut outline = existing.unwrap_or_default();
495            if let Some(color) = color {
496                if outline.width.is_none() {
497                    outline.width = Some(EMU_PER_POINT);
498                }
499                outline.color = Some(color);
500            } else if outline.color.is_none() {
501                outline.color = Some(color_value("#000000")?);
502            }
503            if let Some(width) = stroke.width_pt {
504                outline.width = Some(width * EMU_PER_POINT);
505            }
506            outline
507        };
508        insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
509        Ok(ShapeStrokeReceipt {
510            slide_id: slide_id.to_owned(),
511            shape_id: shape_id.to_owned(),
512            before,
513            after: outline_stroke(&outline),
514        })
515    }
516
517    pub fn set_shape_adjust(
518        &self,
519        context: &EditCtx,
520        slide_id: &str,
521        shape_id: &str,
522        adjustments: &BTreeMap<String, f64>,
523    ) -> EditResult<ShapeAdjustReceipt> {
524        validate_adjustments(adjustments)?;
525        let mut txn = self.transact_for(context);
526        require_shape_membership(&txn, slide_id, shape_id)?;
527        let shape = shape_ref(&txn, shape_id)?;
528        require_shape_kind(&shape, &txn)?;
529        let geometry = required_string(&shape, &txn, "geometry")?;
530        let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
531        let mut after = BTreeMap::new();
532        for (name, value) in adjustments {
533            let maximum = if geometry == "roundRect" && name == "adj" {
534                0.5
535            } else {
536                1.0
537            };
538            after.insert(name.clone(), value.clamp(0.0, maximum));
539        }
540        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
541        Ok(ShapeAdjustReceipt {
542            slide_id: slide_id.to_owned(),
543            shape_id: shape_id.to_owned(),
544            before,
545            after,
546        })
547    }
548
549    pub fn remove_shape(
550        &self,
551        context: &EditCtx,
552        slide_id: &str,
553        shape_id: &str,
554    ) -> EditResult<ShapeReceipt> {
555        let mut txn = self.transact_for(context);
556        let slide = slide_ref(&txn, slide_id)?;
557        let order = slide_shape_order(&slide, &txn)?;
558        let index = array_index(&order, &txn, shape_id)
559            .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
560        remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
561        order.remove(&mut txn, index);
562        Ok(ShapeReceipt {
563            slide_id: slide_id.to_owned(),
564            shape_id: shape_id.to_owned(),
565            index,
566        })
567    }
568
569    pub fn move_shape(
570        &self,
571        context: &EditCtx,
572        slide_id: &str,
573        shape_id: &str,
574        x: i64,
575        y: i64,
576    ) -> EditResult<TransformReceipt> {
577        validate_coordinate(x)?;
578        validate_coordinate(y)?;
579        let mut txn = self.transact_for(context);
580        require_shape_membership(&txn, slide_id, shape_id)?;
581        let shape = shape_ref(&txn, shape_id)?;
582        let before = shape_rect(&shape, &txn)?;
583        shape.insert(&mut txn, "x", x as f64);
584        shape.insert(&mut txn, "y", y as f64);
585        Ok(TransformReceipt {
586            slide_id: slide_id.to_owned(),
587            shape_id: shape_id.to_owned(),
588            before,
589            after: ShapeRect { x, y, ..before },
590        })
591    }
592
593    pub fn resize_shape(
594        &self,
595        context: &EditCtx,
596        slide_id: &str,
597        shape_id: &str,
598        width: i64,
599        height: i64,
600    ) -> EditResult<TransformReceipt> {
601        let rect = ShapeRect {
602            width,
603            height,
604            ..ShapeRect::default()
605        };
606        validate_rect(rect)?;
607        let mut txn = self.transact_for(context);
608        require_shape_membership(&txn, slide_id, shape_id)?;
609        let shape = shape_ref(&txn, shape_id)?;
610        let before = shape_rect(&shape, &txn)?;
611        shape.insert(&mut txn, "width", width as f64);
612        shape.insert(&mut txn, "height", height as f64);
613        Ok(TransformReceipt {
614            slide_id: slide_id.to_owned(),
615            shape_id: shape_id.to_owned(),
616            before,
617            after: ShapeRect {
618                width,
619                height,
620                ..before
621            },
622        })
623    }
624}
625
626pub(crate) fn validate_doc(doc: &Doc) -> EditResult<()> {
627    let package = {
628        let txn = doc.transact();
629        let meta = required_map(&txn, META)?;
630        validate_schema_version(&meta, &txn)?;
631        if map_string(&meta, &txn, "fingerprint").is_none() {
632            return Err(EditError::InvalidState("missing fingerprint".to_owned()));
633        }
634        package_from_meta(&meta, &txn)?
635    };
636    let snapshot = snapshot_doc(doc, &package)?;
637    if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
638        return Err(EditError::InvalidState(
639            "slide dimensions must be positive".to_owned(),
640        ));
641    }
642    let txn = doc.transact();
643    let stories = required_map(&txn, STORIES)?;
644    for (story_id, value) in stories.iter(&txn) {
645        let story = value
646            .cast::<TextRef>()
647            .map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
648        validate_story(&story, &txn, story_id)?;
649    }
650    Ok(())
651}
652
653pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
654    let txn = doc.transact();
655    let meta = required_map(&txn, META)?;
656    validate_schema_version(&meta, &txn)?;
657    package_from_meta(&meta, &txn)
658}
659
660pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
661    let txn = doc.transact();
662    let meta = required_map(&txn, META)?;
663    map_string(&meta, &txn, "fingerprint")
664        .ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
665}
666
667/// Rewrites a hydrated older document into the current schema and stamps it, so
668/// the next snapshot this session writes is a v2 one.
669pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
670    let package = {
671        let txn = doc.transact();
672        let meta = required_map(&txn, META)?;
673        if schema_version(&meta, &txn)? == SCHEMA_VERSION {
674            return Ok(());
675        }
676        package_from_meta(&meta, &txn)?
677    };
678    let package_json =
679        serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
680    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
681    let meta = required_map(&txn, META)?;
682    meta.insert(
683        &mut txn,
684        "packageJson",
685        Any::Buffer(Arc::from(package_json)),
686    );
687    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
688    Ok(())
689}
690
691fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
692    map_number(meta, txn, "schemaVersion")
693        .filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
694        .ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
695}
696
697fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
698    schema_version(meta, txn).map(|_| ())
699}
700
701fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
702    let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
703        return Err(EditError::InvalidState("missing package data".to_owned()));
704    };
705    serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
706}
707
708pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
709    let txn = doc.transact();
710    let meta = required_map(&txn, META)?;
711    let order = required_order(&txn)?;
712    let slides = required_map(&txn, SLIDES)?;
713    let shapes = required_map(&txn, SHAPES)?;
714    let stories = required_map(&txn, STORIES)?;
715    let mut seen_slides = HashSet::new();
716    let mut slide_snapshots = Vec::new();
717    for slide_id in string_array_ref(&order, &txn) {
718        if !seen_slides.insert(slide_id.clone()) {
719            continue;
720        }
721        let slide = slides
722            .get(&txn, &slide_id)
723            .and_then(|value| value.cast::<MapRef>().ok())
724            .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
725        let source_part_path = map_string(&slide, &txn, "sourcePartPath");
726        let layout_part_path = map_string(&slide, &txn, "layoutPartPath");
727        let theme = theme_for_layout(package, layout_part_path.as_deref());
728        let shape_order = slide_shape_order(&slide, &txn)?;
729        let mut seen_shapes = HashSet::new();
730        let mut shape_snapshots = Vec::new();
731        for shape_id in string_array_ref(&shape_order, &txn) {
732            if seen_shapes.insert(shape_id.clone()) {
733                shape_snapshots.push(snapshot_shape(
734                    &shapes,
735                    &stories,
736                    &txn,
737                    &shape_id,
738                    &mut HashSet::new(),
739                    theme,
740                )?);
741            }
742        }
743        slide_snapshots.push(SlideSnapshot {
744            id: slide_id,
745            source_part_path,
746            layout_part_path,
747            name: map_string(&slide, &txn, "name"),
748            shapes: shape_snapshots,
749        });
750    }
751    Ok(DeckSnapshot {
752        width_emu: required_i64(&meta, &txn, "widthEmu")?,
753        height_emu: required_i64(&meta, &txn, "heightEmu")?,
754        slides: slide_snapshots,
755    })
756}
757
758fn snapshot_shape<T: ReadTxn>(
759    shapes: &MapRef,
760    stories: &MapRef,
761    txn: &T,
762    shape_id: &str,
763    visiting: &mut HashSet<String>,
764    theme: Option<&Theme>,
765) -> EditResult<ShapeSnapshot> {
766    if visiting.len() >= MAX_SHAPE_DEPTH {
767        return Err(EditError::InvalidState(format!(
768            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
769        )));
770    }
771    if !visiting.insert(shape_id.to_owned()) {
772        return Err(EditError::InvalidState(format!(
773            "shape cycle at {shape_id}"
774        )));
775    }
776    let shape = shapes
777        .get(txn, shape_id)
778        .and_then(|value| value.cast::<MapRef>().ok())
779        .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
780    let mut text_snapshots = Vec::new();
781    for story_id in map_string_array(&shape, txn, "textStories")? {
782        let story = stories
783            .get(txn, &story_id)
784            .and_then(|value| value.cast::<TextRef>().ok())
785            .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
786        text_snapshots.push(snapshot_story(&story, txn, &story_id)?);
787    }
788    let mut children = Vec::new();
789    for child_id in map_string_array(&shape, txn, "children")? {
790        children.push(snapshot_shape(
791            shapes, stories, txn, &child_id, visiting, theme,
792        )?);
793    }
794    visiting.remove(shape_id);
795    let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
796    let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
797    let resolved_fill_color = fill
798        .as_ref()
799        .filter(|fill| fill.fill_type != "none")
800        .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
801    let resolved_outline_color = outline
802        .as_ref()
803        .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
804    Ok(ShapeSnapshot {
805        id: shape_id.to_owned(),
806        source_id: required_u32(&shape, txn, "sourceId")?,
807        kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
808        name: required_string(&shape, txn, "name")?,
809        x: required_i64(&shape, txn, "x")?,
810        y: required_i64(&shape, txn, "y")?,
811        width: required_i64(&shape, txn, "width")?,
812        height: required_i64(&shape, txn, "height")?,
813        rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
814        flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
815        flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
816        geometry: required_string(&shape, txn, "geometry")?,
817        adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
818        placeholder: optional_json(&shape, txn, "placeholderJson")?,
819        fill,
820        resolved_fill_color,
821        outline,
822        resolved_outline_color,
823        media_part_path: map_string(&shape, txn, "mediaPartPath"),
824        graphic: optional_json(&shape, txn, "graphicJson")?,
825        text_stories: text_snapshots,
826        children,
827    })
828}
829
830fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
831    match value {
832        "shape" => Ok(ShapeKind::Shape),
833        "picture" => Ok(ShapeKind::Picture),
834        "graphicFrame" => Ok(ShapeKind::GraphicFrame),
835        "group" => Ok(ShapeKind::Group),
836        _ => Err(EditError::InvalidState(format!(
837            "unknown shape kind {value}"
838        ))),
839    }
840}
841
842fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
843    txn.get_array(SLIDE_ORDER)
844        .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
845}
846
847fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
848    txn.get_map(name)
849        .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
850}
851
852fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
853    required_map(txn, SLIDES)?
854        .get(txn, slide_id)
855        .and_then(|value| value.cast::<MapRef>().ok())
856        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
857}
858
859fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
860    required_map(txn, SHAPES)?
861        .get(txn, shape_id)
862        .and_then(|value| value.cast::<MapRef>().ok())
863        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
864}
865
866fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
867    if required_string(shape, txn, "kind")? == "shape" {
868        Ok(())
869    } else {
870        Err(EditError::InvalidGeometry(
871            "only preset shapes support shape styling".to_owned(),
872        ))
873    }
874}
875
876fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
877    slide
878        .get(txn, "shapes")
879        .and_then(|value| value.cast::<ArrayRef>().ok())
880        .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
881}
882
883fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
884    let slide = slide_ref(txn, slide_id)?;
885    let order = slide_shape_order(&slide, txn)?;
886    if array_index(&order, txn, shape_id).is_some() {
887        Ok(())
888    } else {
889        Err(EditError::ShapeNotFound(shape_id.to_owned()))
890    }
891}
892
893fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
894    let shapes = required_map(txn, SHAPES)?;
895    let stories = required_map(txn, STORIES)?;
896    let mut entries = ShapeEntries::default();
897    for shape_id in root_shape_ids {
898        collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
899    }
900    for story_id in entries.story_ids {
901        stories.remove(txn, &story_id);
902    }
903    for shape_id in entries.shape_ids {
904        shapes.remove(txn, &shape_id);
905    }
906    Ok(())
907}
908
909#[derive(Default)]
910struct ShapeEntries {
911    shape_ids: Vec<String>,
912    seen_shape_ids: HashSet<String>,
913    story_ids: Vec<String>,
914    seen_story_ids: HashSet<String>,
915}
916
917fn collect_shape_entries<T: ReadTxn>(
918    shapes: &MapRef,
919    txn: &T,
920    shape_id: &str,
921    visiting: &mut HashSet<String>,
922    entries: &mut ShapeEntries,
923) -> EditResult<()> {
924    if visiting.len() >= MAX_SHAPE_DEPTH {
925        return Err(EditError::InvalidState(format!(
926            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
927        )));
928    }
929    if !visiting.insert(shape_id.to_owned()) {
930        return Err(EditError::InvalidState(format!(
931            "shape cycle at {shape_id}"
932        )));
933    }
934    if entries.seen_shape_ids.contains(shape_id) {
935        visiting.remove(shape_id);
936        return Ok(());
937    }
938    let shape = shapes
939        .get(txn, shape_id)
940        .and_then(|value| value.cast::<MapRef>().ok())
941        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
942    for story_id in map_string_array(&shape, txn, "textStories")? {
943        if entries.seen_story_ids.insert(story_id.clone()) {
944            entries.story_ids.push(story_id);
945        }
946    }
947    for child_id in map_string_array(&shape, txn, "children")? {
948        collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
949    }
950    visiting.remove(shape_id);
951    entries.seen_shape_ids.insert(shape_id.to_owned());
952    entries.shape_ids.push(shape_id.to_owned());
953    Ok(())
954}
955
956fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
957    array
958        .iter(txn)
959        .enumerate()
960        .find(|(_, item)| out_string(item).as_deref() == Some(value))
961        .map(|(index, _)| index as u32)
962}
963
964fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
965    array
966        .iter(txn)
967        .filter_map(|value| out_string(&value))
968        .collect()
969}
970
971fn map_string_array<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<Vec<String>> {
972    match map.get(txn, key) {
973        Some(Out::Any(Any::Array(values))) => Ok(values
974            .iter()
975            .filter_map(|value| match value {
976                Any::String(value) => Some(value.to_string()),
977                _ => None,
978            })
979            .collect()),
980        None => Ok(Vec::new()),
981        _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
982    }
983}
984
985fn string_array(values: &[String]) -> Any {
986    Any::Array(Arc::from(
987        values
988            .iter()
989            .map(|value| Any::from(value.as_str()))
990            .collect::<Vec<_>>(),
991    ))
992}
993
994fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
995    Ok(ShapeRect {
996        x: required_i64(shape, txn, "x")?,
997        y: required_i64(shape, txn, "y")?,
998        width: required_i64(shape, txn, "width")?,
999        height: required_i64(shape, txn, "height")?,
1000    })
1001}
1002
1003fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
1004    let value = required_i64(map, txn, key)?;
1005    u32::try_from(value).map_err(|_| {
1006        EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
1007    })
1008}
1009
1010fn validate_rect(rect: ShapeRect) -> EditResult<()> {
1011    validate_coordinate(rect.x)?;
1012    validate_coordinate(rect.y)?;
1013    if rect.width <= 0 || rect.height <= 0 {
1014        return Err(EditError::InvalidGeometry(
1015            "shape width and height must be positive".to_owned(),
1016        ));
1017    }
1018    validate_coordinate(rect.width)?;
1019    validate_coordinate(rect.height)
1020}
1021
1022fn validate_coordinate(value: i64) -> EditResult<()> {
1023    if value.unsigned_abs() > MAX_GEOMETRY as u64 {
1024        return Err(EditError::InvalidGeometry(format!(
1025            "coordinate {value} exceeds the safe range"
1026        )));
1027    }
1028    Ok(())
1029}
1030
1031fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
1032    if adjustments.len() > MAX_ADJUSTMENTS {
1033        return Err(EditError::InvalidAdjustment(format!(
1034            "at most {MAX_ADJUSTMENTS} values are allowed"
1035        )));
1036    }
1037    for (name, value) in adjustments {
1038        if !valid_adjustment_name(name) {
1039            return Err(EditError::InvalidAdjustment(format!(
1040                "unrecognized guide name {name:?}"
1041            )));
1042        }
1043        if !value.is_finite() {
1044            return Err(EditError::InvalidAdjustment(format!(
1045                "guide {name:?} must be finite"
1046            )));
1047        }
1048    }
1049    Ok(())
1050}
1051
1052fn valid_adjustment_name(name: &str) -> bool {
1053    if name == "adj" {
1054        return true;
1055    }
1056    let Some(index) = name.strip_prefix("adj") else {
1057        return false;
1058    };
1059    index
1060        .parse::<usize>()
1061        .ok()
1062        .filter(|value| value.to_string() == index)
1063        .is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
1064}
1065
1066fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
1067    Ok(match color {
1068        Some(color) => ShapeFill {
1069            fill_type: "solid".to_owned(),
1070            color: Some(color_value(color)?),
1071            gradient: None,
1072        },
1073        None => ShapeFill::named("none"),
1074    })
1075}
1076
1077fn fill_color(fill: &ShapeFill) -> Option<String> {
1078    (fill.fill_type != "none")
1079        .then(|| resolve_color_value_to_hex(fill.color.as_ref()))
1080        .flatten()
1081}
1082
1083fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
1084    Some(ShapeStroke {
1085        color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
1086        width_pt: outline.width.map(|width| width / EMU_PER_POINT),
1087    })
1088}
1089
1090fn color_value(color: &str) -> EditResult<ColorValue> {
1091    let rgb = color.strip_prefix('#').unwrap_or(color);
1092    if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1093        return Err(EditError::InvalidGeometry(format!(
1094            "color {color:?} must be a six-digit hex value"
1095        )));
1096    }
1097    Ok(ColorValue {
1098        rgb: Some(rgb.to_ascii_uppercase()),
1099        ..ColorValue::default()
1100    })
1101}
1102
1103fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
1104    map_string(map, txn, key)
1105        .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
1106}
1107
1108fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
1109    map.get(txn, key).and_then(|value| out_string(&value))
1110}
1111
1112fn out_string(value: &Out) -> Option<String> {
1113    match value {
1114        Out::Any(Any::String(value)) => Some(value.to_string()),
1115        _ => None,
1116    }
1117}
1118
1119fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
1120    let number = map_number(map, txn, key)
1121        .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
1122    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
1123        return Err(EditError::InvalidState(format!("invalid integer {key}")));
1124    }
1125    Ok(number as i64)
1126}
1127
1128fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
1129    match map.get(txn, key) {
1130        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
1131        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
1132        _ => None,
1133    }
1134}
1135
1136fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
1137    match map.get(txn, key) {
1138        Some(Out::Any(Any::Bool(value))) => Some(value),
1139        _ => None,
1140    }
1141}
1142
1143fn optional_json<T: DeserializeOwned, R: ReadTxn>(
1144    map: &MapRef,
1145    txn: &R,
1146    key: &str,
1147) -> EditResult<Option<T>> {
1148    map_string(map, txn, key)
1149        .map(|json| {
1150            serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
1151        })
1152        .transpose()
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use crate::TextStyle;
1159
1160    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
1161
1162    #[test]
1163    fn an_unmigratable_schema_version_is_reported_before_package_json() {
1164        let session = DeckSession::open(FIXTURE, 100).unwrap();
1165        {
1166            let mut txn = session.doc.transact_mut();
1167            let meta = required_map(&txn, META).unwrap();
1168            meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
1169            meta.insert(
1170                &mut txn,
1171                "packageJson",
1172                Any::Buffer(Arc::from(b"{}".to_vec())),
1173            );
1174        }
1175
1176        assert!(matches!(
1177            validate_doc(&session.doc),
1178            Err(EditError::InvalidState(message))
1179                if message == "unsupported deck schema version"
1180        ));
1181        assert!(matches!(
1182            DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
1183            Err(EditError::InvalidState(message))
1184                if message == "unsupported deck schema version"
1185        ));
1186    }
1187
1188    #[test]
1189    fn migration_leaves_a_current_document_untouched() {
1190        let session = DeckSession::open(FIXTURE, 102).unwrap();
1191        let before = session.encode_state_as_update_v1();
1192        migrate_doc(&session.doc).unwrap();
1193        assert_eq!(session.encode_state_as_update_v1(), before);
1194    }
1195
1196    #[test]
1197    fn delete_operations_remove_owned_map_entries() {
1198        let session = DeckSession::open(FIXTURE, 101).unwrap();
1199        let context = EditCtx::local("test");
1200        let baseline = map_lengths(&session);
1201
1202        for cycle in 0..3 {
1203            let index = session.snapshot().unwrap().slides.len() as u32;
1204            let slide = session.insert_slide(&context, index, None).unwrap();
1205            let first = session
1206                .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
1207                .unwrap();
1208            session
1209                .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
1210                .unwrap();
1211            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
1212
1213            session
1214                .remove_shape(&context, &slide.slide_id, &first.shape_id)
1215                .unwrap();
1216            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
1217
1218            session.delete_slide(&context, &slide.slide_id).unwrap();
1219            assert_eq!(map_lengths(&session), baseline);
1220        }
1221    }
1222
1223    #[test]
1224    fn deleting_seeded_slide_cascades_descendant_entries() {
1225        let session = DeckSession::open(FIXTURE, 202).unwrap();
1226        let slide = session.snapshot().unwrap().slides[0].clone();
1227        let before = map_lengths(&session);
1228        let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
1229            let next = shape_entry_lengths(shape);
1230            (total.0 + next.0, total.1 + next.1)
1231        });
1232
1233        session
1234            .delete_slide(&EditCtx::local("test"), &slide.id)
1235            .unwrap();
1236
1237        assert!(owned.0 > slide.shapes.len() as u32);
1238        assert_eq!(
1239            map_lengths(&session),
1240            (before.0 - 1, before.1 - owned.0, before.2 - owned.1)
1241        );
1242    }
1243
1244    fn draft(cycle: u32, index: u32) -> ShapeDraft {
1245        ShapeDraft {
1246            name: format!("Shape {cycle}:{index}"),
1247            rect: ShapeRect {
1248                x: 100_000,
1249                y: 100_000,
1250                width: 1_000_000,
1251                height: 500_000,
1252            },
1253            text: "Text".to_owned(),
1254            style: TextStyle::default(),
1255        }
1256    }
1257
1258    fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
1259        let txn = session.doc.transact();
1260        (
1261            required_map(&txn, SLIDES).unwrap().len(&txn),
1262            required_map(&txn, SHAPES).unwrap().len(&txn),
1263            required_map(&txn, STORIES).unwrap().len(&txn),
1264        )
1265    }
1266
1267    fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
1268        shape.children.iter().fold(
1269            (1, shape.text_stories.len() as u32),
1270            |(shape_count, story_count), child| {
1271                let child_counts = shape_entry_lengths(child);
1272                (shape_count + child_counts.0, story_count + child_counts.1)
1273            },
1274        )
1275    }
1276
1277    fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
1278        (left.0 + right.0, left.1 + right.1, left.2 + right.2)
1279    }
1280}