Skip to main content

pptx_edit/
deck.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use ooxml_drawingml::{ShapeFill, Theme};
5use pptx_parse::{PptxPackage, ShapeNode, Slide};
6use serde::de::DeserializeOwned;
7use yrs::{
8    Any, Array, ArrayPrelim, ArrayRef, Doc, Map, MapPrelim, MapRef, Out, ReadTxn, TextRef,
9    Transact, TransactionMut, WriteTxn,
10};
11
12use crate::story::{seed_plain_story, seed_story, snapshot_story, validate_story};
13use crate::{
14    DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, SHAPES, SLIDE_ORDER, SLIDES,
15    STORIES, ShapeDraft, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, SlideReceipt,
16    SlideSnapshot, TransformReceipt,
17};
18
19const SCHEMA_VERSION: f64 = 1.0;
20const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
21const MAX_SHAPE_DEPTH: usize = 128;
22
23pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
24    let package_json =
25        serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
26    let mut txn = doc.transact_mut_with("pptx:bootstrap");
27    let meta = txn.get_or_insert_map(META);
28    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
29    meta.insert(&mut txn, "fingerprint", fingerprint);
30    meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
31    meta.insert(
32        &mut txn,
33        "heightEmu",
34        package.presentation.height_emu as f64,
35    );
36    meta.insert(
37        &mut txn,
38        "packageJson",
39        Any::Buffer(Arc::from(package_json)),
40    );
41    let order = txn.get_or_insert_array(SLIDE_ORDER);
42    let slides = txn.get_or_insert_map(SLIDES);
43    let shapes = txn.get_or_insert_map(SHAPES);
44    let stories = txn.get_or_insert_map(STORIES);
45
46    for (slide_index, slide) in package.slides.iter().enumerate() {
47        let theme = slide_theme(package, slide);
48        let reference = &package.presentation.slides[slide_index];
49        let slide_id = format!("slide:{slide_index}:{}", reference.id);
50        order.push_back(&mut txn, slide_id.as_str());
51        let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
52        slide_map.insert(&mut txn, "id", slide_id.as_str());
53        slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
54        if let Some(layout) = &slide.layout_part_path {
55            slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
56        }
57        if let Some(name) = &slide.name {
58            slide_map.insert(&mut txn, "name", name.as_str());
59        }
60        let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
61        for (shape_index, shape) in slide.shapes.iter().enumerate() {
62            let shape_id = seed_shape(
63                &shapes,
64                &stories,
65                &mut txn,
66                &slide_id,
67                &shape_index.to_string(),
68                shape,
69                theme,
70            )?;
71            shape_order.push_back(&mut txn, shape_id.as_str());
72        }
73    }
74    Ok(())
75}
76
77fn seed_shape(
78    shapes: &MapRef,
79    stories: &MapRef,
80    txn: &mut TransactionMut<'_>,
81    slide_id: &str,
82    path: &str,
83    shape: &ShapeNode,
84    theme: Option<&Theme>,
85) -> EditResult<String> {
86    let shape_id = format!("{slide_id}:shape:{path}");
87    let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
88    let base = match shape {
89        ShapeNode::Shape(shape) => &shape.base,
90        ShapeNode::Picture(shape) => &shape.base,
91        ShapeNode::GraphicFrame(shape) => &shape.base,
92        ShapeNode::Group(shape) => &shape.base,
93    };
94    shape_map.insert(txn, "id", shape_id.as_str());
95    shape_map.insert(txn, "sourceId", base.id as f64);
96    shape_map.insert(txn, "name", base.name.as_str());
97    shape_map.insert(txn, "x", base.transform.x as f64);
98    shape_map.insert(txn, "y", base.transform.y as f64);
99    shape_map.insert(txn, "width", base.transform.width as f64);
100    shape_map.insert(txn, "height", base.transform.height as f64);
101    shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
102    shape_map.insert(txn, "flipH", base.transform.flip_h);
103    shape_map.insert(txn, "flipV", base.transform.flip_v);
104    insert_json(
105        &shape_map,
106        txn,
107        "placeholderJson",
108        base.placeholder.as_ref(),
109    )?;
110
111    let mut text_story_ids = Vec::new();
112    let mut child_ids = Vec::new();
113    match shape {
114        ShapeNode::Shape(shape) => {
115            shape_map.insert(txn, "kind", "shape");
116            shape_map.insert(txn, "geometry", shape.geometry.as_str());
117            insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
118            insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
119            if let Some(body) = &shape.text {
120                let story_id = format!("story:{shape_id}:0");
121                seed_story(stories, txn, &story_id, body, theme)?;
122                text_story_ids.push(story_id);
123            }
124        }
125        ShapeNode::Picture(picture) => {
126            shape_map.insert(txn, "kind", "picture");
127            shape_map.insert(txn, "geometry", "rect");
128            insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
129            insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
130            if let Some(media) = &picture.media_part_path {
131                shape_map.insert(txn, "mediaPartPath", media.as_str());
132            }
133        }
134        ShapeNode::GraphicFrame(frame) => {
135            shape_map.insert(txn, "kind", "graphicFrame");
136            shape_map.insert(txn, "geometry", "rect");
137            insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
138            if let pptx_parse::GraphicFrameData::Table { rows } = &frame.data {
139                for (row_index, row) in rows.iter().enumerate() {
140                    for (cell_index, body) in row.iter().enumerate() {
141                        let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
142                        seed_story(stories, txn, &story_id, body, theme)?;
143                        text_story_ids.push(story_id);
144                    }
145                }
146            }
147        }
148        ShapeNode::Group(group) => {
149            shape_map.insert(txn, "kind", "group");
150            shape_map.insert(txn, "geometry", "group");
151            for (child_index, child) in group.children.iter().enumerate() {
152                child_ids.push(seed_shape(
153                    shapes,
154                    stories,
155                    txn,
156                    slide_id,
157                    &format!("{path}.{child_index}"),
158                    child,
159                    theme,
160                )?);
161            }
162        }
163    }
164    shape_map.insert(txn, "textStories", string_array(&text_story_ids));
165    shape_map.insert(txn, "children", string_array(&child_ids));
166    Ok(shape_id)
167}
168
169fn slide_theme<'a>(package: &'a PptxPackage, slide: &Slide) -> Option<&'a Theme> {
170    let layout = slide.layout_part_path.as_deref().and_then(|path| {
171        package
172            .layouts
173            .iter()
174            .find(|layout| layout.part_path == path)
175    });
176    let master = layout
177        .and_then(|layout| layout.master_part_path.as_deref())
178        .and_then(|path| {
179            package
180                .masters
181                .iter()
182                .find(|master| master.part_path == path)
183        })
184        .or_else(|| {
185            layout.and_then(|layout| {
186                package.masters.iter().find(|master| {
187                    master
188                        .layout_part_paths
189                        .iter()
190                        .any(|path| path == &layout.part_path)
191                })
192            })
193        });
194    master
195        .and_then(|master| master.theme_part_path.as_deref())
196        .and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
197        .map(|part| &part.theme)
198        .or_else(|| package.themes.first().map(|part| &part.theme))
199}
200
201fn insert_json<T: serde::Serialize>(
202    map: &MapRef,
203    txn: &mut TransactionMut<'_>,
204    key: &str,
205    value: Option<&T>,
206) -> EditResult<()> {
207    if let Some(value) = value {
208        let json =
209            serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
210        map.insert(txn, key, json);
211    }
212    Ok(())
213}
214
215impl DeckSession {
216    pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
217        snapshot_doc(&self.doc)
218    }
219
220    pub fn insert_slide(
221        &self,
222        context: &EditCtx,
223        index: u32,
224        layout_part_path: Option<&str>,
225    ) -> EditResult<SlideReceipt> {
226        let slide_id = self.next_id("slide");
227        let mut txn = self.transact_for(context);
228        let order = required_order(&txn)?;
229        let length = order.len(&txn);
230        if index > length {
231            return Err(EditError::OutOfBounds { index, length });
232        }
233        let slides = required_map(&txn, SLIDES)?;
234        let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
235        slide.insert(&mut txn, "id", slide_id.as_str());
236        slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
237        if let Some(layout_part_path) = layout_part_path {
238            slide.insert(&mut txn, "layoutPartPath", layout_part_path);
239        }
240        slide.insert(&mut txn, "shapes", ArrayPrelim::default());
241        order.insert(&mut txn, index, slide_id.as_str());
242        Ok(SlideReceipt {
243            slide_id,
244            from_index: None,
245            to_index: Some(index),
246        })
247    }
248
249    pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
250        let mut txn = self.transact_for(context);
251        let order = required_order(&txn)?;
252        let index = array_index(&order, &txn, slide_id)
253            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
254        let slides = required_map(&txn, SLIDES)?;
255        let slide = slide_ref(&txn, slide_id)?;
256        let shape_order = slide_shape_order(&slide, &txn)?;
257        let shape_ids = string_array_ref(&shape_order, &txn);
258        remove_shape_entries(&mut txn, &shape_ids)?;
259        order.remove(&mut txn, index);
260        slides.remove(&mut txn, slide_id);
261        Ok(SlideReceipt {
262            slide_id: slide_id.to_owned(),
263            from_index: Some(index),
264            to_index: None,
265        })
266    }
267
268    pub fn move_slide(
269        &self,
270        context: &EditCtx,
271        slide_id: &str,
272        to_index: u32,
273    ) -> EditResult<SlideReceipt> {
274        let mut txn = self.transact_for(context);
275        let order = required_order(&txn)?;
276        let length = order.len(&txn);
277        if to_index >= length {
278            return Err(EditError::OutOfBounds {
279                index: to_index,
280                length,
281            });
282        }
283        let from_index = array_index(&order, &txn, slide_id)
284            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
285        if from_index != to_index {
286            order.remove(&mut txn, from_index);
287            order.insert(&mut txn, to_index, slide_id);
288        }
289        Ok(SlideReceipt {
290            slide_id: slide_id.to_owned(),
291            from_index: Some(from_index),
292            to_index: Some(to_index),
293        })
294    }
295
296    pub fn add_text_box(
297        &self,
298        context: &EditCtx,
299        slide_id: &str,
300        draft: &ShapeDraft,
301    ) -> EditResult<ShapeReceipt> {
302        validate_rect(draft.rect)?;
303        let shape_id = self.next_id("shape");
304        let story_id = format!("story:{shape_id}:0");
305        let paragraph_id = self.next_id("para");
306        let mut txn = self.transact_for(context);
307        let slide = slide_ref(&txn, slide_id)?;
308        let order = slide_shape_order(&slide, &txn)?;
309        let index = order.len(&txn);
310        let shapes = required_map(&txn, SHAPES)?;
311        let stories = required_map(&txn, STORIES)?;
312        seed_plain_story(
313            &stories,
314            &mut txn,
315            &story_id,
316            &paragraph_id,
317            &draft.text,
318            &draft.style,
319        );
320        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
321        shape.insert(&mut txn, "id", shape_id.as_str());
322        shape.insert(&mut txn, "sourceId", 0_f64);
323        shape.insert(&mut txn, "kind", "shape");
324        shape.insert(&mut txn, "name", draft.name.as_str());
325        shape.insert(&mut txn, "x", draft.rect.x as f64);
326        shape.insert(&mut txn, "y", draft.rect.y as f64);
327        shape.insert(&mut txn, "width", draft.rect.width as f64);
328        shape.insert(&mut txn, "height", draft.rect.height as f64);
329        shape.insert(&mut txn, "rotationDeg", 0_f64);
330        shape.insert(&mut txn, "flipH", false);
331        shape.insert(&mut txn, "flipV", false);
332        shape.insert(&mut txn, "geometry", "rect");
333        insert_json(
334            &shape,
335            &mut txn,
336            "fillJson",
337            Some(&ShapeFill::named("none")),
338        )?;
339        shape.insert(
340            &mut txn,
341            "textStories",
342            string_array(std::slice::from_ref(&story_id)),
343        );
344        shape.insert(&mut txn, "children", string_array(&[]));
345        order.push_back(&mut txn, shape_id.as_str());
346        Ok(ShapeReceipt {
347            slide_id: slide_id.to_owned(),
348            shape_id,
349            index,
350        })
351    }
352
353    pub fn remove_shape(
354        &self,
355        context: &EditCtx,
356        slide_id: &str,
357        shape_id: &str,
358    ) -> EditResult<ShapeReceipt> {
359        let mut txn = self.transact_for(context);
360        let slide = slide_ref(&txn, slide_id)?;
361        let order = slide_shape_order(&slide, &txn)?;
362        let index = array_index(&order, &txn, shape_id)
363            .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
364        remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
365        order.remove(&mut txn, index);
366        Ok(ShapeReceipt {
367            slide_id: slide_id.to_owned(),
368            shape_id: shape_id.to_owned(),
369            index,
370        })
371    }
372
373    pub fn move_shape(
374        &self,
375        context: &EditCtx,
376        slide_id: &str,
377        shape_id: &str,
378        x: i64,
379        y: i64,
380    ) -> EditResult<TransformReceipt> {
381        validate_coordinate(x)?;
382        validate_coordinate(y)?;
383        let mut txn = self.transact_for(context);
384        require_shape_membership(&txn, slide_id, shape_id)?;
385        let shape = shape_ref(&txn, shape_id)?;
386        let before = shape_rect(&shape, &txn)?;
387        shape.insert(&mut txn, "x", x as f64);
388        shape.insert(&mut txn, "y", y as f64);
389        Ok(TransformReceipt {
390            slide_id: slide_id.to_owned(),
391            shape_id: shape_id.to_owned(),
392            before,
393            after: ShapeRect { x, y, ..before },
394        })
395    }
396
397    pub fn resize_shape(
398        &self,
399        context: &EditCtx,
400        slide_id: &str,
401        shape_id: &str,
402        width: i64,
403        height: i64,
404    ) -> EditResult<TransformReceipt> {
405        let rect = ShapeRect {
406            width,
407            height,
408            ..ShapeRect::default()
409        };
410        validate_rect(rect)?;
411        let mut txn = self.transact_for(context);
412        require_shape_membership(&txn, slide_id, shape_id)?;
413        let shape = shape_ref(&txn, shape_id)?;
414        let before = shape_rect(&shape, &txn)?;
415        shape.insert(&mut txn, "width", width as f64);
416        shape.insert(&mut txn, "height", height as f64);
417        Ok(TransformReceipt {
418            slide_id: slide_id.to_owned(),
419            shape_id: shape_id.to_owned(),
420            before,
421            after: ShapeRect {
422                width,
423                height,
424                ..before
425            },
426        })
427    }
428}
429
430pub(crate) fn validate_doc(doc: &Doc) -> EditResult<()> {
431    let snapshot = snapshot_doc(doc)?;
432    if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
433        return Err(EditError::InvalidState(
434            "slide dimensions must be positive".to_owned(),
435        ));
436    }
437    let txn = doc.transact();
438    let meta = required_map(&txn, META)?;
439    if map_number(&meta, &txn, "schemaVersion") != Some(SCHEMA_VERSION) {
440        return Err(EditError::InvalidState(
441            "unsupported deck schema version".to_owned(),
442        ));
443    }
444    if map_string(&meta, &txn, "fingerprint").is_none() {
445        return Err(EditError::InvalidState("missing fingerprint".to_owned()));
446    }
447    package_from_meta(&meta, &txn)?;
448    let stories = required_map(&txn, STORIES)?;
449    for (story_id, value) in stories.iter(&txn) {
450        let story = value
451            .cast::<TextRef>()
452            .map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
453        validate_story(&story, &txn, story_id)?;
454    }
455    Ok(())
456}
457
458pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
459    let txn = doc.transact();
460    let meta = required_map(&txn, META)?;
461    package_from_meta(&meta, &txn)
462}
463
464fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
465    let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
466        return Err(EditError::InvalidState("missing package data".to_owned()));
467    };
468    serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
469}
470
471fn snapshot_doc(doc: &Doc) -> EditResult<DeckSnapshot> {
472    let txn = doc.transact();
473    let meta = required_map(&txn, META)?;
474    let order = required_order(&txn)?;
475    let slides = required_map(&txn, SLIDES)?;
476    let shapes = required_map(&txn, SHAPES)?;
477    let stories = required_map(&txn, STORIES)?;
478    let mut seen_slides = HashSet::new();
479    let mut slide_snapshots = Vec::new();
480    for slide_id in string_array_ref(&order, &txn) {
481        if !seen_slides.insert(slide_id.clone()) {
482            continue;
483        }
484        let slide = slides
485            .get(&txn, &slide_id)
486            .and_then(|value| value.cast::<MapRef>().ok())
487            .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
488        let shape_order = slide_shape_order(&slide, &txn)?;
489        let mut seen_shapes = HashSet::new();
490        let mut shape_snapshots = Vec::new();
491        for shape_id in string_array_ref(&shape_order, &txn) {
492            if seen_shapes.insert(shape_id.clone()) {
493                shape_snapshots.push(snapshot_shape(
494                    &shapes,
495                    &stories,
496                    &txn,
497                    &shape_id,
498                    &mut HashSet::new(),
499                )?);
500            }
501        }
502        slide_snapshots.push(SlideSnapshot {
503            id: slide_id,
504            source_part_path: map_string(&slide, &txn, "sourcePartPath"),
505            layout_part_path: map_string(&slide, &txn, "layoutPartPath"),
506            name: map_string(&slide, &txn, "name"),
507            shapes: shape_snapshots,
508        });
509    }
510    Ok(DeckSnapshot {
511        width_emu: required_i64(&meta, &txn, "widthEmu")?,
512        height_emu: required_i64(&meta, &txn, "heightEmu")?,
513        slides: slide_snapshots,
514    })
515}
516
517fn snapshot_shape<T: ReadTxn>(
518    shapes: &MapRef,
519    stories: &MapRef,
520    txn: &T,
521    shape_id: &str,
522    visiting: &mut HashSet<String>,
523) -> EditResult<ShapeSnapshot> {
524    if visiting.len() >= MAX_SHAPE_DEPTH {
525        return Err(EditError::InvalidState(format!(
526            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
527        )));
528    }
529    if !visiting.insert(shape_id.to_owned()) {
530        return Err(EditError::InvalidState(format!(
531            "shape cycle at {shape_id}"
532        )));
533    }
534    let shape = shapes
535        .get(txn, shape_id)
536        .and_then(|value| value.cast::<MapRef>().ok())
537        .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
538    let mut text_snapshots = Vec::new();
539    for story_id in map_string_array(&shape, txn, "textStories")? {
540        let story = stories
541            .get(txn, &story_id)
542            .and_then(|value| value.cast::<TextRef>().ok())
543            .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
544        text_snapshots.push(snapshot_story(&story, txn, &story_id)?);
545    }
546    let mut children = Vec::new();
547    for child_id in map_string_array(&shape, txn, "children")? {
548        children.push(snapshot_shape(shapes, stories, txn, &child_id, visiting)?);
549    }
550    visiting.remove(shape_id);
551    Ok(ShapeSnapshot {
552        id: shape_id.to_owned(),
553        source_id: required_u32(&shape, txn, "sourceId")?,
554        kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
555        name: required_string(&shape, txn, "name")?,
556        x: required_i64(&shape, txn, "x")?,
557        y: required_i64(&shape, txn, "y")?,
558        width: required_i64(&shape, txn, "width")?,
559        height: required_i64(&shape, txn, "height")?,
560        rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
561        flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
562        flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
563        geometry: required_string(&shape, txn, "geometry")?,
564        placeholder: optional_json(&shape, txn, "placeholderJson")?,
565        fill: optional_json(&shape, txn, "fillJson")?,
566        outline: optional_json(&shape, txn, "outlineJson")?,
567        media_part_path: map_string(&shape, txn, "mediaPartPath"),
568        graphic: optional_json(&shape, txn, "graphicJson")?,
569        text_stories: text_snapshots,
570        children,
571    })
572}
573
574fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
575    match value {
576        "shape" => Ok(ShapeKind::Shape),
577        "picture" => Ok(ShapeKind::Picture),
578        "graphicFrame" => Ok(ShapeKind::GraphicFrame),
579        "group" => Ok(ShapeKind::Group),
580        _ => Err(EditError::InvalidState(format!(
581            "unknown shape kind {value}"
582        ))),
583    }
584}
585
586fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
587    txn.get_array(SLIDE_ORDER)
588        .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
589}
590
591fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
592    txn.get_map(name)
593        .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
594}
595
596fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
597    required_map(txn, SLIDES)?
598        .get(txn, slide_id)
599        .and_then(|value| value.cast::<MapRef>().ok())
600        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
601}
602
603fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
604    required_map(txn, SHAPES)?
605        .get(txn, shape_id)
606        .and_then(|value| value.cast::<MapRef>().ok())
607        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
608}
609
610fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
611    slide
612        .get(txn, "shapes")
613        .and_then(|value| value.cast::<ArrayRef>().ok())
614        .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
615}
616
617fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
618    let slide = slide_ref(txn, slide_id)?;
619    let order = slide_shape_order(&slide, txn)?;
620    if array_index(&order, txn, shape_id).is_some() {
621        Ok(())
622    } else {
623        Err(EditError::ShapeNotFound(shape_id.to_owned()))
624    }
625}
626
627fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
628    let shapes = required_map(txn, SHAPES)?;
629    let stories = required_map(txn, STORIES)?;
630    let mut entries = ShapeEntries::default();
631    for shape_id in root_shape_ids {
632        collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
633    }
634    for story_id in entries.story_ids {
635        stories.remove(txn, &story_id);
636    }
637    for shape_id in entries.shape_ids {
638        shapes.remove(txn, &shape_id);
639    }
640    Ok(())
641}
642
643#[derive(Default)]
644struct ShapeEntries {
645    shape_ids: Vec<String>,
646    seen_shape_ids: HashSet<String>,
647    story_ids: Vec<String>,
648    seen_story_ids: HashSet<String>,
649}
650
651fn collect_shape_entries<T: ReadTxn>(
652    shapes: &MapRef,
653    txn: &T,
654    shape_id: &str,
655    visiting: &mut HashSet<String>,
656    entries: &mut ShapeEntries,
657) -> EditResult<()> {
658    if visiting.len() >= MAX_SHAPE_DEPTH {
659        return Err(EditError::InvalidState(format!(
660            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
661        )));
662    }
663    if !visiting.insert(shape_id.to_owned()) {
664        return Err(EditError::InvalidState(format!(
665            "shape cycle at {shape_id}"
666        )));
667    }
668    if entries.seen_shape_ids.contains(shape_id) {
669        visiting.remove(shape_id);
670        return Ok(());
671    }
672    let shape = shapes
673        .get(txn, shape_id)
674        .and_then(|value| value.cast::<MapRef>().ok())
675        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
676    for story_id in map_string_array(&shape, txn, "textStories")? {
677        if entries.seen_story_ids.insert(story_id.clone()) {
678            entries.story_ids.push(story_id);
679        }
680    }
681    for child_id in map_string_array(&shape, txn, "children")? {
682        collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
683    }
684    visiting.remove(shape_id);
685    entries.seen_shape_ids.insert(shape_id.to_owned());
686    entries.shape_ids.push(shape_id.to_owned());
687    Ok(())
688}
689
690fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
691    array
692        .iter(txn)
693        .enumerate()
694        .find(|(_, item)| out_string(item).as_deref() == Some(value))
695        .map(|(index, _)| index as u32)
696}
697
698fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
699    array
700        .iter(txn)
701        .filter_map(|value| out_string(&value))
702        .collect()
703}
704
705fn map_string_array<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<Vec<String>> {
706    match map.get(txn, key) {
707        Some(Out::Any(Any::Array(values))) => Ok(values
708            .iter()
709            .filter_map(|value| match value {
710                Any::String(value) => Some(value.to_string()),
711                _ => None,
712            })
713            .collect()),
714        None => Ok(Vec::new()),
715        _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
716    }
717}
718
719fn string_array(values: &[String]) -> Any {
720    Any::Array(Arc::from(
721        values
722            .iter()
723            .map(|value| Any::from(value.as_str()))
724            .collect::<Vec<_>>(),
725    ))
726}
727
728fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
729    Ok(ShapeRect {
730        x: required_i64(shape, txn, "x")?,
731        y: required_i64(shape, txn, "y")?,
732        width: required_i64(shape, txn, "width")?,
733        height: required_i64(shape, txn, "height")?,
734    })
735}
736
737fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
738    let value = required_i64(map, txn, key)?;
739    u32::try_from(value).map_err(|_| {
740        EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
741    })
742}
743
744fn validate_rect(rect: ShapeRect) -> EditResult<()> {
745    validate_coordinate(rect.x)?;
746    validate_coordinate(rect.y)?;
747    if rect.width <= 0 || rect.height <= 0 {
748        return Err(EditError::InvalidGeometry(
749            "shape width and height must be positive".to_owned(),
750        ));
751    }
752    validate_coordinate(rect.width)?;
753    validate_coordinate(rect.height)
754}
755
756fn validate_coordinate(value: i64) -> EditResult<()> {
757    if value.unsigned_abs() > MAX_GEOMETRY as u64 {
758        return Err(EditError::InvalidGeometry(format!(
759            "coordinate {value} exceeds the safe range"
760        )));
761    }
762    Ok(())
763}
764
765fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
766    map_string(map, txn, key)
767        .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
768}
769
770fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
771    map.get(txn, key).and_then(|value| out_string(&value))
772}
773
774fn out_string(value: &Out) -> Option<String> {
775    match value {
776        Out::Any(Any::String(value)) => Some(value.to_string()),
777        _ => None,
778    }
779}
780
781fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
782    let number = map_number(map, txn, key)
783        .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
784    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
785        return Err(EditError::InvalidState(format!("invalid integer {key}")));
786    }
787    Ok(number as i64)
788}
789
790fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
791    match map.get(txn, key) {
792        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
793        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
794        _ => None,
795    }
796}
797
798fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
799    match map.get(txn, key) {
800        Some(Out::Any(Any::Bool(value))) => Some(value),
801        _ => None,
802    }
803}
804
805fn optional_json<T: DeserializeOwned, R: ReadTxn>(
806    map: &MapRef,
807    txn: &R,
808    key: &str,
809) -> EditResult<Option<T>> {
810    map_string(map, txn, key)
811        .map(|json| {
812            serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
813        })
814        .transpose()
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use crate::TextStyle;
821
822    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
823
824    #[test]
825    fn delete_operations_remove_owned_map_entries() {
826        let session = DeckSession::open(FIXTURE, 101).unwrap();
827        let context = EditCtx::local("test");
828        let baseline = map_lengths(&session);
829
830        for cycle in 0..3 {
831            let index = session.snapshot().unwrap().slides.len() as u32;
832            let slide = session.insert_slide(&context, index, None).unwrap();
833            let first = session
834                .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
835                .unwrap();
836            session
837                .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
838                .unwrap();
839            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
840
841            session
842                .remove_shape(&context, &slide.slide_id, &first.shape_id)
843                .unwrap();
844            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
845
846            session.delete_slide(&context, &slide.slide_id).unwrap();
847            assert_eq!(map_lengths(&session), baseline);
848        }
849    }
850
851    #[test]
852    fn deleting_seeded_slide_cascades_descendant_entries() {
853        let session = DeckSession::open(FIXTURE, 202).unwrap();
854        let slide = session.snapshot().unwrap().slides[0].clone();
855        let before = map_lengths(&session);
856        let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
857            let next = shape_entry_lengths(shape);
858            (total.0 + next.0, total.1 + next.1)
859        });
860
861        session
862            .delete_slide(&EditCtx::local("test"), &slide.id)
863            .unwrap();
864
865        assert!(owned.0 > slide.shapes.len() as u32);
866        assert_eq!(
867            map_lengths(&session),
868            (before.0 - 1, before.1 - owned.0, before.2 - owned.1)
869        );
870    }
871
872    fn draft(cycle: u32, index: u32) -> ShapeDraft {
873        ShapeDraft {
874            name: format!("Shape {cycle}:{index}"),
875            rect: ShapeRect {
876                x: 100_000,
877                y: 100_000,
878                width: 1_000_000,
879                height: 500_000,
880            },
881            text: "Text".to_owned(),
882            style: TextStyle::default(),
883        }
884    }
885
886    fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
887        let txn = session.doc.transact();
888        (
889            required_map(&txn, SLIDES).unwrap().len(&txn),
890            required_map(&txn, SHAPES).unwrap().len(&txn),
891            required_map(&txn, STORIES).unwrap().len(&txn),
892        )
893    }
894
895    fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
896        shape.children.iter().fold(
897            (1, shape.text_stories.len() as u32),
898            |(shape_count, story_count), child| {
899                let child_counts = shape_entry_lengths(child);
900                (shape_count + child_counts.0, story_count + child_counts.1)
901            },
902        )
903    }
904
905    fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
906        (left.0 + right.0, left.1 + right.1, left.2 + right.2)
907    }
908}