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