Skip to main content

pptx_edit/
deck.rs

1use std::collections::{BTreeMap, HashMap, 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::{ChartAxis, ChartSpace, PptxPackage, ShapeBase, 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::comments::{flavor_key, seed_comments, snapshot_comments, snapshot_flavor};
16use crate::story::{seed_plain_story, seed_story, snapshot_story, validate_story};
17use crate::{
18    DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN,
19    PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt, ShapeDraft,
20    ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
21    ShapeStrokeReceipt, SlideReceipt, SlideSnapshot, TransformReceipt,
22};
23
24const SCHEMA_VERSION: f64 = 2.1;
25/// Versions [`migrate_doc`] can carry forward. Anything else is unreadable.
26const MIGRATABLE_SCHEMA_VERSIONS: [f64; 3] = [1.0, 2.0, SCHEMA_VERSION];
27const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
28const MAX_SHAPE_DEPTH: usize = 128;
29const EMU_PER_POINT: f64 = 12_700.0;
30const MAX_ADJUSTMENTS: usize = 32;
31const MAX_ADJUSTMENT_INDEX: usize = 32;
32
33pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
34    let package_json =
35        serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
36    let mut txn = doc.transact_mut_with("pptx:bootstrap");
37    let meta = txn.get_or_insert_map(META);
38    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
39    meta.insert(&mut txn, "fingerprint", fingerprint);
40    meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
41    meta.insert(
42        &mut txn,
43        "heightEmu",
44        package.presentation.height_emu as f64,
45    );
46    meta.insert(
47        &mut txn,
48        "packageJson",
49        Any::Buffer(Arc::from(package_json)),
50    );
51    meta.insert(
52        &mut txn,
53        "commentFlavor",
54        flavor_key(package.comment_flavor.unwrap_or_default()),
55    );
56    let order = txn.get_or_insert_array(SLIDE_ORDER);
57    let slides = txn.get_or_insert_map(SLIDES);
58    let shapes = txn.get_or_insert_map(SHAPES);
59    let stories = txn.get_or_insert_map(STORIES);
60    let mut slide_id_by_part: HashMap<String, String> = HashMap::new();
61
62    for (slide_index, slide) in package.slides.iter().enumerate() {
63        let theme = slide_theme(package, slide);
64        let slide_id = seeded_slide_id(slide_index, package.presentation.slides[slide_index].id);
65        order.push_back(&mut txn, slide_id.as_str());
66        let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
67        slide_map.insert(&mut txn, "id", slide_id.as_str());
68        slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
69        if let Some(layout) = &slide.layout_part_path {
70            slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
71        }
72        if let Some(name) = &slide.name {
73            slide_map.insert(&mut txn, "name", name.as_str());
74        }
75        if !slide.notes.is_empty() {
76            slide_map.insert(&mut txn, "notes", slide.notes.as_str());
77        }
78        let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
79        for (shape_index, shape) in slide.shapes.iter().enumerate() {
80            let shape_id = seed_shape(
81                &shapes,
82                &stories,
83                &mut txn,
84                &slide_id,
85                &shape_index.to_string(),
86                shape,
87                theme,
88            )?;
89            shape_order.push_back(&mut txn, shape_id.as_str());
90        }
91        slide_id_by_part.insert(slide.part_path.clone(), slide_id);
92    }
93    seed_comments(&mut txn, package, &|part| {
94        slide_id_by_part.get(part).cloned()
95    })?;
96    Ok(())
97}
98
99fn seed_shape(
100    shapes: &MapRef,
101    stories: &MapRef,
102    txn: &mut TransactionMut<'_>,
103    slide_id: &str,
104    path: &str,
105    shape: &ShapeNode,
106    theme: Option<&Theme>,
107) -> EditResult<String> {
108    let shape_id = seeded_shape_id(slide_id, path);
109    let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
110    let base = shape_base(shape);
111    shape_map.insert(txn, "id", shape_id.as_str());
112    shape_map.insert(txn, "sourceId", base.id as f64);
113    shape_map.insert(txn, "name", base.name.as_str());
114    shape_map.insert(txn, "x", base.transform.x as f64);
115    shape_map.insert(txn, "y", base.transform.y as f64);
116    shape_map.insert(txn, "width", base.transform.width as f64);
117    shape_map.insert(txn, "height", base.transform.height as f64);
118    shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
119    shape_map.insert(txn, "flipH", base.transform.flip_h);
120    shape_map.insert(txn, "flipV", base.transform.flip_v);
121    if base.hidden {
122        shape_map.insert(txn, "hidden", true);
123    }
124    insert_json(
125        &shape_map,
126        txn,
127        "placeholderJson",
128        base.placeholder.as_ref(),
129    )?;
130
131    let mut text_story_ids = Vec::new();
132    let mut child_ids = Vec::new();
133    match shape {
134        ShapeNode::Shape(shape) => {
135            shape_map.insert(txn, "kind", "shape");
136            shape_map.insert(txn, "geometry", shape.geometry.as_str());
137            if !shape.has_preset_geometry {
138                shape_map.insert(txn, "hasPresetGeometry", false);
139            }
140            let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
141                .into_iter()
142                .collect::<BTreeMap<_, _>>();
143            adjust_values.extend(shape.adjust_values.clone());
144            insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
145            insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
146            insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
147            if let Some(body) = &shape.text {
148                let story_id = format!("story:{shape_id}:0");
149                seed_story(stories, txn, &story_id, body, theme)?;
150                text_story_ids.push(story_id);
151            }
152        }
153        ShapeNode::Picture(picture) => {
154            shape_map.insert(txn, "kind", "picture");
155            shape_map.insert(txn, "geometry", "rect");
156            insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
157            insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
158            if !picture.effects.is_empty() {
159                insert_json(&shape_map, txn, "blipEffectsJson", Some(&picture.effects))?;
160            }
161            if let Some(media) = &picture.media_part_path {
162                shape_map.insert(txn, "mediaPartPath", media.as_str());
163            }
164        }
165        ShapeNode::GraphicFrame(frame) => {
166            shape_map.insert(txn, "kind", "graphicFrame");
167            shape_map.insert(txn, "geometry", "rect");
168            insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
169            if let pptx_parse::GraphicFrameData::Table(table) = &frame.data {
170                for (row_index, row) in table.rows.iter().enumerate() {
171                    for (cell_index, cell) in row.cells.iter().enumerate() {
172                        let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
173                        seed_story(stories, txn, &story_id, &cell.text, theme)?;
174                        text_story_ids.push(story_id);
175                    }
176                }
177            }
178        }
179        ShapeNode::Group(group) => {
180            shape_map.insert(txn, "kind", "group");
181            shape_map.insert(txn, "geometry", "group");
182            for (child_index, child) in group.children.iter().enumerate() {
183                child_ids.push(seed_shape(
184                    shapes,
185                    stories,
186                    txn,
187                    slide_id,
188                    &seeded_child_path(path, child_index),
189                    child,
190                    theme,
191                )?);
192            }
193        }
194    }
195    shape_map.insert(txn, "textStories", string_array(&text_story_ids));
196    shape_map.insert(txn, "children", string_array(&child_ids));
197    Ok(shape_id)
198}
199
200fn seeded_slide_id(slide_index: usize, reference_id: u32) -> String {
201    format!("slide:{slide_index}:{reference_id}")
202}
203
204fn seeded_shape_id(slide_id: &str, path: &str) -> String {
205    format!("{slide_id}:shape:{path}")
206}
207
208fn seeded_child_path(path: &str, child_index: usize) -> String {
209    format!("{path}.{child_index}")
210}
211
212pub(crate) fn shape_base(shape: &ShapeNode) -> &ShapeBase {
213    match shape {
214        ShapeNode::Shape(shape) => &shape.base,
215        ShapeNode::Picture(shape) => &shape.base,
216        ShapeNode::GraphicFrame(shape) => &shape.base,
217        ShapeNode::Group(shape) => &shape.base,
218    }
219}
220
221fn slide_theme<'a>(package: &'a PptxPackage, slide: &Slide) -> Option<&'a Theme> {
222    theme_for_layout(package, slide.layout_part_path.as_deref())
223}
224
225fn theme_for_layout<'a>(
226    package: &'a PptxPackage,
227    layout_part_path: Option<&str>,
228) -> Option<&'a Theme> {
229    let layout = layout_part_path
230        .and_then(|path| {
231            package
232                .layouts
233                .iter()
234                .find(|layout| layout.part_path == path)
235        })
236        .or_else(|| package.layouts.first());
237    let master = layout
238        .and_then(|layout| layout.master_part_path.as_deref())
239        .and_then(|path| {
240            package
241                .masters
242                .iter()
243                .find(|master| master.part_path == path)
244        })
245        .or_else(|| {
246            layout.and_then(|layout| {
247                package.masters.iter().find(|master| {
248                    master
249                        .layout_part_paths
250                        .iter()
251                        .any(|path| path == &layout.part_path)
252                })
253            })
254        })
255        .or_else(|| package.masters.first());
256    master
257        .and_then(|master| master.theme_part_path.as_deref())
258        .and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
259        .map(|part| &part.theme)
260        .or_else(|| package.themes.first().map(|part| &part.theme))
261}
262
263fn insert_json<T: serde::Serialize>(
264    map: &MapRef,
265    txn: &mut TransactionMut<'_>,
266    key: &str,
267    value: Option<&T>,
268) -> EditResult<()> {
269    if let Some(value) = value {
270        let json =
271            serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
272        map.insert(txn, key, json);
273    }
274    Ok(())
275}
276
277impl DeckSession {
278    pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
279        snapshot_doc(&self.doc, &self.package)
280    }
281
282    pub fn insert_slide(
283        &self,
284        context: &EditCtx,
285        index: u32,
286        layout_part_path: Option<&str>,
287    ) -> EditResult<SlideReceipt> {
288        if let Some(path) = layout_part_path
289            && !self
290                .package
291                .layouts
292                .iter()
293                .any(|layout| layout.part_path == path)
294        {
295            return Err(EditError::InvalidState(format!(
296                "unknown slide layout {path:?}"
297            )));
298        }
299        let slide_id = self.next_id("slide");
300        let mut txn = self.transact_for(context);
301        let order = required_order(&txn)?;
302        let length = order.len(&txn);
303        if index > length {
304            return Err(EditError::OutOfBounds { index, length });
305        }
306        let slides = required_map(&txn, SLIDES)?;
307        let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
308        slide.insert(&mut txn, "id", slide_id.as_str());
309        slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
310        if let Some(layout_part_path) = layout_part_path {
311            slide.insert(&mut txn, "layoutPartPath", layout_part_path);
312        }
313        slide.insert(&mut txn, "shapes", ArrayPrelim::default());
314        order.insert(&mut txn, index, slide_id.as_str());
315        Ok(SlideReceipt {
316            slide_id,
317            from_index: None,
318            to_index: Some(index),
319        })
320    }
321
322    pub fn set_slide_notes(&self, context: &EditCtx, slide_id: &str, text: &str) -> EditResult<()> {
323        crate::model::validate_xml_text(text)?;
324        self.add_undo_barrier();
325        let mut txn = self.transact_for(context);
326        let slide = slide_ref(&txn, slide_id)?;
327        slide.insert(&mut txn, "notes", text);
328        drop(txn);
329        self.add_undo_barrier();
330        Ok(())
331    }
332
333    pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
334        let mut txn = self.transact_for(context);
335        let order = required_order(&txn)?;
336        let index = array_index(&order, &txn, slide_id)
337            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
338        let slides = required_map(&txn, SLIDES)?;
339        let slide = slide_ref(&txn, slide_id)?;
340        let shape_order = slide_shape_order(&slide, &txn)?;
341        let shape_ids = string_array_ref(&shape_order, &txn);
342        remove_shape_entries(&mut txn, &shape_ids)?;
343        let comments = required_map(&txn, crate::COMMENTS)?;
344        let comment_ids: Vec<String> = comments
345            .iter(&txn)
346            .filter_map(|(id, value)| {
347                let entry = value.cast::<MapRef>().ok()?;
348                (map_string(&entry, &txn, "slideId").as_deref() == Some(slide_id))
349                    .then(|| id.to_owned())
350            })
351            .collect();
352        for id in comment_ids {
353            comments.remove(&mut txn, &id);
354        }
355        order.remove(&mut txn, index);
356        slides.remove(&mut txn, slide_id);
357        Ok(SlideReceipt {
358            slide_id: slide_id.to_owned(),
359            from_index: Some(index),
360            to_index: None,
361        })
362    }
363
364    pub fn move_slide(
365        &self,
366        context: &EditCtx,
367        slide_id: &str,
368        to_index: u32,
369    ) -> EditResult<SlideReceipt> {
370        let mut txn = self.transact_for(context);
371        let order = required_order(&txn)?;
372        let length = order.len(&txn);
373        if to_index >= length {
374            return Err(EditError::OutOfBounds {
375                index: to_index,
376                length,
377            });
378        }
379        let from_index = array_index(&order, &txn, slide_id)
380            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
381        if from_index != to_index {
382            order.remove(&mut txn, from_index);
383            order.insert(&mut txn, to_index, slide_id);
384        }
385        Ok(SlideReceipt {
386            slide_id: slide_id.to_owned(),
387            from_index: Some(from_index),
388            to_index: Some(to_index),
389        })
390    }
391
392    pub fn add_text_box(
393        &self,
394        context: &EditCtx,
395        slide_id: &str,
396        draft: &ShapeDraft,
397    ) -> EditResult<ShapeReceipt> {
398        validate_rect(draft.rect)?;
399        crate::model::validate_xml_text(&draft.name)?;
400        crate::model::validate_xml_text(&draft.text)?;
401        crate::story::validate_style_values(
402            draft.style.font_family.as_deref(),
403            draft.style.underline.as_deref(),
404            draft.style.color.as_deref(),
405            draft.style.font_size_pt,
406            draft.style.spacing_pt,
407            draft.style.baseline_pct,
408        )?;
409        let shape_id = self.next_id("shape");
410        let story_id = format!("story:{shape_id}:0");
411        let paragraph_id = self.next_id("para");
412        let mut txn = self.transact_for(context);
413        let slide = slide_ref(&txn, slide_id)?;
414        let order = slide_shape_order(&slide, &txn)?;
415        let index = order.len(&txn);
416        let shapes = required_map(&txn, SHAPES)?;
417        let stories = required_map(&txn, STORIES)?;
418        seed_plain_story(
419            &stories,
420            &mut txn,
421            &story_id,
422            &paragraph_id,
423            &draft.text,
424            &draft.style,
425        );
426        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
427        shape.insert(&mut txn, "id", shape_id.as_str());
428        shape.insert(&mut txn, "sourceId", 0_f64);
429        shape.insert(&mut txn, "kind", "shape");
430        shape.insert(&mut txn, "name", draft.name.as_str());
431        shape.insert(&mut txn, "x", draft.rect.x as f64);
432        shape.insert(&mut txn, "y", draft.rect.y as f64);
433        shape.insert(&mut txn, "width", draft.rect.width as f64);
434        shape.insert(&mut txn, "height", draft.rect.height as f64);
435        shape.insert(&mut txn, "rotationDeg", 0_f64);
436        shape.insert(&mut txn, "flipH", false);
437        shape.insert(&mut txn, "flipV", false);
438        shape.insert(&mut txn, "geometry", "rect");
439        insert_json(
440            &shape,
441            &mut txn,
442            "fillJson",
443            Some(&ShapeFill::named("none")),
444        )?;
445        shape.insert(
446            &mut txn,
447            "textStories",
448            string_array(std::slice::from_ref(&story_id)),
449        );
450        shape.insert(&mut txn, "children", string_array(&[]));
451        order.push_back(&mut txn, shape_id.as_str());
452        Ok(ShapeReceipt {
453            slide_id: slide_id.to_owned(),
454            shape_id,
455            index,
456        })
457    }
458
459    pub fn add_shape(
460        &self,
461        context: &EditCtx,
462        slide_id: &str,
463        draft: &PresetShapeDraft,
464    ) -> EditResult<ShapeReceipt> {
465        validate_rect(draft.rect)?;
466        crate::model::validate_xml_text(&draft.name)?;
467        let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
468        if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none() {
469            return Err(EditError::InvalidGeometry(format!(
470                "unsupported preset geometry {}",
471                draft.geometry
472            )));
473        }
474        let fill = shape_fill(draft.fill.as_deref())?;
475        let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
476            .into_iter()
477            .collect::<BTreeMap<_, _>>();
478        let shape_id = self.next_id("shape");
479        let mut txn = self.transact_for(context);
480        let slide = slide_ref(&txn, slide_id)?;
481        let order = slide_shape_order(&slide, &txn)?;
482        let index = order.len(&txn);
483        let shapes = required_map(&txn, SHAPES)?;
484        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
485        shape.insert(&mut txn, "id", shape_id.as_str());
486        shape.insert(&mut txn, "sourceId", 0_f64);
487        shape.insert(&mut txn, "kind", "shape");
488        shape.insert(&mut txn, "name", draft.name.as_str());
489        shape.insert(&mut txn, "x", draft.rect.x as f64);
490        shape.insert(&mut txn, "y", draft.rect.y as f64);
491        shape.insert(&mut txn, "width", draft.rect.width as f64);
492        shape.insert(&mut txn, "height", draft.rect.height as f64);
493        shape.insert(&mut txn, "rotationDeg", 0_f64);
494        shape.insert(&mut txn, "flipH", false);
495        shape.insert(&mut txn, "flipV", false);
496        shape.insert(&mut txn, "geometry", draft.geometry.as_str());
497        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&adjust_values))?;
498        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
499        shape.insert(&mut txn, "textStories", string_array(&[]));
500        shape.insert(&mut txn, "children", string_array(&[]));
501        order.push_back(&mut txn, shape_id.as_str());
502        Ok(ShapeReceipt {
503            slide_id: slide_id.to_owned(),
504            shape_id,
505            index,
506        })
507    }
508
509    pub fn set_shape_fill(
510        &self,
511        context: &EditCtx,
512        slide_id: &str,
513        shape_id: &str,
514        color: Option<&str>,
515    ) -> EditResult<ShapeFillReceipt> {
516        let fill = shape_fill(color)?;
517        let mut txn = self.transact_for(context);
518        require_shape_membership(&txn, slide_id, shape_id)?;
519        let shape = shape_ref(&txn, shape_id)?;
520        require_shape_kind(&shape, &txn)?;
521        let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
522            .as_ref()
523            .and_then(fill_color);
524        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
525        Ok(ShapeFillReceipt {
526            slide_id: slide_id.to_owned(),
527            shape_id: shape_id.to_owned(),
528            before,
529            after: fill_color(&fill),
530        })
531    }
532
533    pub fn set_shape_stroke(
534        &self,
535        context: &EditCtx,
536        slide_id: &str,
537        shape_id: &str,
538        stroke: &ShapeStroke,
539    ) -> EditResult<ShapeStrokeReceipt> {
540        let color = stroke.color.as_deref().map(color_value).transpose()?;
541        if let Some(width) = stroke.width_pt
542            && (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
543        {
544            return Err(EditError::InvalidGeometry(format!(
545                "stroke width {width}pt is outside the safe range"
546            )));
547        }
548        let mut txn = self.transact_for(context);
549        require_shape_membership(&txn, slide_id, shape_id)?;
550        let shape = shape_ref(&txn, shape_id)?;
551        require_shape_kind(&shape, &txn)?;
552        let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
553        let before = existing.as_ref().and_then(outline_stroke);
554        let outline = if stroke.color.is_none() && stroke.width_pt.is_none() {
555            ShapeOutline::default()
556        } else {
557            let mut outline = existing.unwrap_or_default();
558            if let Some(color) = color {
559                if outline.width.is_none() {
560                    outline.width = Some(EMU_PER_POINT);
561                }
562                outline.color = Some(color);
563                outline.gradient = None;
564            } else if outline.color.is_none() && outline.gradient.is_none() {
565                outline.color = Some(color_value("#000000")?);
566            }
567            if let Some(width) = stroke.width_pt {
568                outline.width = Some(width * EMU_PER_POINT);
569            }
570            outline
571        };
572        insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
573        Ok(ShapeStrokeReceipt {
574            slide_id: slide_id.to_owned(),
575            shape_id: shape_id.to_owned(),
576            before,
577            after: outline_stroke(&outline),
578        })
579    }
580
581    pub fn set_shape_adjust(
582        &self,
583        context: &EditCtx,
584        slide_id: &str,
585        shape_id: &str,
586        adjustments: &BTreeMap<String, f64>,
587    ) -> EditResult<ShapeAdjustReceipt> {
588        validate_adjustments(adjustments)?;
589        let mut txn = self.transact_for(context);
590        require_shape_membership(&txn, slide_id, shape_id)?;
591        let shape = shape_ref(&txn, shape_id)?;
592        require_shape_kind(&shape, &txn)?;
593        let geometry = required_string(&shape, &txn, "geometry")?;
594        if geometry == "custom" {
595            return Err(EditError::InvalidGeometry(
596                "custom geometry does not support shape adjustments".to_owned(),
597            ));
598        }
599        if !map_bool(&shape, &txn, "hasPresetGeometry").unwrap_or(true) {
600            return Err(EditError::InvalidGeometry(
601                "shape without preset geometry does not support shape adjustments".to_owned(),
602            ));
603        }
604        let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
605        let mut after = BTreeMap::new();
606        for (name, value) in adjustments {
607            let maximum = if geometry == "roundRect" && name == "adj" {
608                0.5
609            } else {
610                1.0
611            };
612            after.insert(name.clone(), value.clamp(0.0, maximum));
613        }
614        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
615        Ok(ShapeAdjustReceipt {
616            slide_id: slide_id.to_owned(),
617            shape_id: shape_id.to_owned(),
618            before,
619            after,
620        })
621    }
622
623    pub fn remove_shape(
624        &self,
625        context: &EditCtx,
626        slide_id: &str,
627        shape_id: &str,
628    ) -> EditResult<ShapeReceipt> {
629        let mut txn = self.transact_for(context);
630        let slide = slide_ref(&txn, slide_id)?;
631        let order = slide_shape_order(&slide, &txn)?;
632        let index = array_index(&order, &txn, shape_id)
633            .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
634        remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
635        order.remove(&mut txn, index);
636        Ok(ShapeReceipt {
637            slide_id: slide_id.to_owned(),
638            shape_id: shape_id.to_owned(),
639            index,
640        })
641    }
642
643    pub fn move_shape(
644        &self,
645        context: &EditCtx,
646        slide_id: &str,
647        shape_id: &str,
648        x: i64,
649        y: i64,
650    ) -> EditResult<TransformReceipt> {
651        validate_coordinate(x)?;
652        validate_coordinate(y)?;
653        let mut txn = self.transact_for(context);
654        require_shape_membership(&txn, slide_id, shape_id)?;
655        let shape = shape_ref(&txn, shape_id)?;
656        let before = shape_rect(&shape, &txn)?;
657        shape.insert(&mut txn, "x", x as f64);
658        shape.insert(&mut txn, "y", y as f64);
659        Ok(TransformReceipt {
660            slide_id: slide_id.to_owned(),
661            shape_id: shape_id.to_owned(),
662            before,
663            after: ShapeRect { x, y, ..before },
664        })
665    }
666
667    pub fn resize_shape(
668        &self,
669        context: &EditCtx,
670        slide_id: &str,
671        shape_id: &str,
672        width: i64,
673        height: i64,
674    ) -> EditResult<TransformReceipt> {
675        let rect = ShapeRect {
676            width,
677            height,
678            ..ShapeRect::default()
679        };
680        validate_rect(rect)?;
681        let mut txn = self.transact_for(context);
682        require_shape_membership(&txn, slide_id, shape_id)?;
683        let shape = shape_ref(&txn, shape_id)?;
684        let before = shape_rect(&shape, &txn)?;
685        shape.insert(&mut txn, "width", width as f64);
686        shape.insert(&mut txn, "height", height as f64);
687        Ok(TransformReceipt {
688            slide_id: slide_id.to_owned(),
689            shape_id: shape_id.to_owned(),
690            before,
691            after: ShapeRect {
692                width,
693                height,
694                ..before
695            },
696        })
697    }
698
699    pub fn set_shape_rect(
700        &self,
701        context: &EditCtx,
702        slide_id: &str,
703        shape_id: &str,
704        rect: ShapeRect,
705    ) -> EditResult<TransformReceipt> {
706        validate_rect(rect)?;
707        let mut txn = self.transact_for(context);
708        require_shape_membership(&txn, slide_id, shape_id)?;
709        let shape = shape_ref(&txn, shape_id)?;
710        let before = shape_rect(&shape, &txn)?;
711        shape.insert(&mut txn, "x", rect.x as f64);
712        shape.insert(&mut txn, "y", rect.y as f64);
713        shape.insert(&mut txn, "width", rect.width as f64);
714        shape.insert(&mut txn, "height", rect.height as f64);
715        Ok(TransformReceipt {
716            slide_id: slide_id.to_owned(),
717            shape_id: shape_id.to_owned(),
718            before,
719            after: rect,
720        })
721    }
722}
723
724pub(crate) fn validate_doc(doc: &Doc) -> EditResult<()> {
725    let package = {
726        let txn = doc.transact();
727        let meta = required_map(&txn, META)?;
728        validate_schema_version(&meta, &txn)?;
729        if map_string(&meta, &txn, "fingerprint").is_none() {
730            return Err(EditError::InvalidState("missing fingerprint".to_owned()));
731        }
732        package_from_meta(&meta, &txn)?
733    };
734    let snapshot = snapshot_doc(doc, &package)?;
735    if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
736        return Err(EditError::InvalidState(
737            "slide dimensions must be positive".to_owned(),
738        ));
739    }
740    let txn = doc.transact();
741    let stories = required_map(&txn, STORIES)?;
742    for (story_id, value) in stories.iter(&txn) {
743        let story = value
744            .cast::<TextRef>()
745            .map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
746        validate_story(&story, &txn, story_id)?;
747    }
748    Ok(())
749}
750
751pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
752    let txn = doc.transact();
753    let meta = required_map(&txn, META)?;
754    validate_schema_version(&meta, &txn)?;
755    package_from_meta(&meta, &txn)
756}
757
758pub(crate) fn import_source_render_data(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
759    let mut package = package_from_doc(doc)?;
760    let sources = source
761        .slides
762        .iter()
763        .map(|part| (&part.part_path, &part.shapes))
764        .chain(
765            source
766                .layouts
767                .iter()
768                .map(|part| (&part.part_path, &part.shapes)),
769        )
770        .chain(
771            source
772                .masters
773                .iter()
774                .map(|part| (&part.part_path, &part.shapes)),
775        )
776        .collect::<HashMap<_, _>>();
777    let mut changed = false;
778    for (path, shapes) in package
779        .slides
780        .iter_mut()
781        .map(|part| (&part.part_path, &mut part.shapes))
782        .chain(
783            package
784                .layouts
785                .iter_mut()
786                .map(|part| (&part.part_path, &mut part.shapes)),
787        )
788        .chain(
789            package
790                .masters
791                .iter_mut()
792                .map(|part| (&part.part_path, &mut part.shapes)),
793        )
794    {
795        if let Some(source) = sources.get(path) {
796            changed |= merge_source_render_shapes(shapes, source);
797        }
798    }
799    for master in &mut package.masters {
800        if let Some(source) = source
801            .masters
802            .iter()
803            .find(|source| source.part_path == master.part_path)
804        {
805            let target = &mut master.text_styles;
806            let source = &source.text_styles;
807            for (target, source) in target
808                .title
809                .iter_mut()
810                .zip(&source.title)
811                .chain(target.body.iter_mut().zip(&source.body))
812                .chain(target.other.iter_mut().zip(&source.other))
813            {
814                changed |= merge_source_paragraph_properties(target, source);
815            }
816        }
817    }
818    for chart in &mut package.charts {
819        if let Some(source) = source
820            .charts
821            .iter()
822            .find(|source| source.part_path == chart.part_path)
823        {
824            changed |= merge_source_chart_properties(&mut chart.chart, &source.chart);
825        }
826    }
827    for slide in &mut package.slides {
828        if let Some(source) = source
829            .slides
830            .iter()
831            .find(|source| source.part_path == slide.part_path)
832            && slide.notes != source.notes
833        {
834            slide.notes.clone_from(&source.notes);
835            changed = true;
836        }
837    }
838    if package.table_styles != source.table_styles {
839        package.table_styles.clone_from(&source.table_styles);
840        changed = true;
841    }
842    if !changed {
843        return Ok(());
844    }
845    let bytes = serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
846    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
847    let meta = required_map(&txn, META)?;
848    meta.insert(&mut txn, "packageJson", Any::Buffer(Arc::from(bytes)));
849    backfill_blip_effects(&mut txn, &package)?;
850    backfill_tables(&mut txn, &package)?;
851    Ok(())
852}
853
854fn merge_source_render_shapes(target: &mut [ShapeNode], source: &[ShapeNode]) -> bool {
855    let mut changed = false;
856    for source in source {
857        let Some(target) = target
858            .iter_mut()
859            .find(|target| shape_base(target).id == shape_base(source).id)
860        else {
861            continue;
862        };
863        match (target, source) {
864            (ShapeNode::Shape(target), ShapeNode::Shape(source)) => {
865                changed |= target.picture_fill != source.picture_fill;
866                target.picture_fill.clone_from(&source.picture_fill);
867                if let (Some(target), Some(source)) = (&mut target.text, &source.text) {
868                    changed |= merge_source_text_body(target, source);
869                }
870            }
871            (ShapeNode::Picture(target), ShapeNode::Picture(source)) => {
872                if target.effects.is_empty() && !source.effects.is_empty() {
873                    target.effects.clone_from(&source.effects);
874                    changed = true;
875                }
876            }
877            (ShapeNode::Group(target), ShapeNode::Group(source)) => {
878                changed |= merge_source_render_shapes(&mut target.children, &source.children);
879            }
880            (ShapeNode::GraphicFrame(target), ShapeNode::GraphicFrame(source)) => {
881                if matches!(
882                    target.data,
883                    pptx_parse::GraphicFrameData::Unknown { picture: None, .. }
884                ) && matches!(
885                    source.data,
886                    pptx_parse::GraphicFrameData::Unknown {
887                        picture: Some(_),
888                        ..
889                    }
890                ) {
891                    target.data.clone_from(&source.data);
892                    changed = true;
893                }
894                if let (
895                    pptx_parse::GraphicFrameData::Table(target),
896                    pptx_parse::GraphicFrameData::Table(source),
897                ) = (&mut target.data, &source.data)
898                {
899                    changed |= merge_source_table(target, source);
900                }
901            }
902            _ => {}
903        }
904    }
905    changed
906}
907
908/// Copies the chart-space fill, axis lines, series lines and text properties a
909/// source chart part declares.
910fn merge_source_chart_properties(target: &mut ChartSpace, source: &ChartSpace) -> bool {
911    let mut changed = target.fill != source.fill;
912    target.fill.clone_from(&source.fill);
913    changed |= target.text != source.text || target.title_text != source.title_text;
914    target.text.clone_from(&source.text);
915    target.title_text.clone_from(&source.title_text);
916    if let (Some(target), Some(source)) = (&mut target.legend, &source.legend) {
917        changed |= target.text != source.text;
918        target.text.clone_from(&source.text);
919    }
920    for (target, source) in target.plot_groups.iter_mut().zip(&source.plot_groups) {
921        for (target, source) in target.series.iter_mut().zip(&source.series) {
922            changed |= target.line != source.line;
923            target.line.clone_from(&source.line);
924        }
925    }
926    let mut merge_axis = |target: Option<&mut ChartAxis>, source: Option<&ChartAxis>| {
927        if let (Some(target), Some(source)) = (target, source) {
928            changed |= target.line != source.line || target.text != source.text;
929            target.line.clone_from(&source.line);
930            target.text.clone_from(&source.text);
931        }
932    };
933    if let (Some(target), Some(source)) = (&mut target.axes, &source.axes) {
934        merge_axis(target.category.as_mut(), source.category.as_ref());
935        merge_axis(target.value.as_mut(), source.value.as_ref());
936    }
937    if let (Some(target), Some(source)) = (&mut target.axis_list, &source.axis_list) {
938        for (target, source) in target.iter_mut().zip(source) {
939            merge_axis(Some(target), Some(source));
940        }
941    }
942    changed
943}
944
945fn backfill_tables(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
946    let shapes = required_map(txn, SHAPES)?;
947    for (index, (slide, reference)) in package
948        .slides
949        .iter()
950        .zip(&package.presentation.slides)
951        .enumerate()
952    {
953        let slide_id = seeded_slide_id(index, reference.id);
954        for (index, node) in slide.shapes.iter().enumerate() {
955            backfill_table(txn, &shapes, &slide_id, &index.to_string(), node)?;
956        }
957    }
958    Ok(())
959}
960
961fn backfill_table(
962    txn: &mut TransactionMut<'_>,
963    shapes: &MapRef,
964    slide_id: &str,
965    path: &str,
966    node: &ShapeNode,
967) -> EditResult<()> {
968    if let ShapeNode::GraphicFrame(frame) = node
969        && let pptx_parse::GraphicFrameData::Table(table) = &frame.data
970        && !table.grid.is_empty()
971        && let Some(shape) = shapes
972            .get(txn, &seeded_shape_id(slide_id, path))
973            .and_then(|value| value.cast::<MapRef>().ok())
974        && matches!(
975            optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
976            Some(pptx_parse::GraphicFrameData::Table(stored)) if stored.grid.is_empty()
977        )
978    {
979        insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
980    }
981    if let ShapeNode::Group(group) = node {
982        for (index, child) in group.children.iter().enumerate() {
983            backfill_table(
984                txn,
985                shapes,
986                slide_id,
987                &seeded_child_path(path, index),
988                child,
989            )?;
990        }
991    }
992    Ok(())
993}
994
995/// Restores the grid, spans and cell formatting a stored package dropped.
996fn merge_source_table(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
997    let mut changed = target.grid != source.grid || target.properties != source.properties;
998    target.grid.clone_from(&source.grid);
999    target.properties.clone_from(&source.properties);
1000    for (target, source) in target.rows.iter_mut().zip(&source.rows) {
1001        changed |= target.height != source.height;
1002        target.height = source.height;
1003        for (target, source) in target.cells.iter_mut().zip(&source.cells) {
1004            changed |= target.grid_span != source.grid_span
1005                || target.row_span != source.row_span
1006                || target.merged != source.merged
1007                || target.fill != source.fill
1008                || target.borders != source.borders;
1009            target.grid_span = source.grid_span;
1010            target.row_span = source.row_span;
1011            target.merged = source.merged;
1012            target.fill.clone_from(&source.fill);
1013            target.borders.clone_from(&source.borders);
1014            changed |= merge_source_text_body(&mut target.text, &source.text);
1015        }
1016    }
1017    changed
1018}
1019
1020fn merge_source_text_body(
1021    target: &mut pptx_parse::TextBody,
1022    source: &pptx_parse::TextBody,
1023) -> bool {
1024    let mut changed = target.list_style != source.list_style
1025        || target.default_list_style != source.default_list_style
1026        || target.compat_line_spacing != source.compat_line_spacing
1027        || target.vertical_overflow != source.vertical_overflow
1028        || target.horizontal_overflow != source.horizontal_overflow;
1029    target.vertical_overflow = source.vertical_overflow;
1030    target.horizontal_overflow = source.horizontal_overflow;
1031    target.compat_line_spacing = source.compat_line_spacing;
1032    target.list_style.clone_from(&source.list_style);
1033    target
1034        .default_list_style
1035        .clone_from(&source.default_list_style);
1036    for (target, source) in target.paragraphs.iter_mut().zip(&source.paragraphs) {
1037        changed |= merge_source_paragraph_properties(&mut target.properties, &source.properties);
1038    }
1039    changed
1040}
1041
1042fn merge_source_paragraph_properties(
1043    target: &mut pptx_parse::ParagraphProperties,
1044    source: &pptx_parse::ParagraphProperties,
1045) -> bool {
1046    let mut changed = target.bullet_font != source.bullet_font
1047        || target.bullet_color != source.bullet_color
1048        || target.bullet_size != source.bullet_size
1049        || target.line_spacing != source.line_spacing
1050        || target.margin_right != source.margin_right
1051        || target.space_before != source.space_before
1052        || target.space_after != source.space_after;
1053    target.line_spacing = source.line_spacing;
1054    target.margin_right = source.margin_right;
1055    target.space_before = source.space_before;
1056    target.space_after = source.space_after;
1057    if let (
1058        Some(pptx_parse::Bullet::AutoNumber {
1059            restart: target, ..
1060        }),
1061        Some(pptx_parse::Bullet::AutoNumber {
1062            restart: source, ..
1063        }),
1064    ) = (&mut target.bullet, &source.bullet)
1065    {
1066        changed |= *target != *source;
1067        *target = *source;
1068    }
1069    target.bullet_font.clone_from(&source.bullet_font);
1070    target.bullet_color.clone_from(&source.bullet_color);
1071    target.bullet_size.clone_from(&source.bullet_size);
1072    changed
1073}
1074
1075pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
1076    let txn = doc.transact();
1077    let meta = required_map(&txn, META)?;
1078    map_string(&meta, &txn, "fingerprint")
1079        .ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
1080}
1081
1082/// Carries a released 1.0 or 2.0 document forward to the current schema.
1083pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
1084    let version = {
1085        let txn = doc.transact();
1086        let meta = required_map(&txn, META)?;
1087        schema_version(&meta, &txn)?
1088    };
1089    if version < SCHEMA_VERSION {
1090        migrate_doc_to_v2_1(doc)?;
1091    }
1092    Ok(())
1093}
1094
1095/// Applies every schema change made since 2.0 in one transaction: the package is
1096/// rewritten through the current model, hidden flags and bitmap effects are
1097/// backfilled, the comment flavour is recorded, and everything a stored package
1098/// cannot carry -- baselines, outline gradients, character spacing, OLE picture
1099/// previews, chart and paragraph properties, table geometry -- is deferred to
1100/// [`import_source_render_data`] until the source is reattached.
1101fn migrate_doc_to_v2_1(doc: &Doc) -> EditResult<()> {
1102    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1103    let meta = required_map(&txn, META)?;
1104    let package = package_from_meta(&meta, &txn)?;
1105    let package_json =
1106        serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1107    meta.insert(
1108        &mut txn,
1109        "packageJson",
1110        Any::Buffer(Arc::from(package_json)),
1111    );
1112    backfill_hidden(&mut txn, &package)?;
1113    record_comment_flavor(&mut txn, &meta, &package);
1114    meta.insert(&mut txn, "baselinesPendingSource", true);
1115    meta.insert(&mut txn, "outlineGradientsPendingSource", true);
1116    backfill_blip_effects(&mut txn, &package)?;
1117    backfill_tables(&mut txn, &package)?;
1118    meta.insert(&mut txn, "spacingPendingSource", true);
1119    if package_needs_ole_source(&package) {
1120        meta.insert(&mut txn, "olePicturesPendingSource", true);
1121    }
1122    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1123    Ok(())
1124}
1125
1126fn record_comment_flavor(txn: &mut TransactionMut<'_>, meta: &MapRef, package: &PptxPackage) {
1127    if meta.contains_key(txn, "commentFlavor") {
1128        return;
1129    }
1130    if !package.comments.is_empty()
1131        || package
1132            .relationships
1133            .values()
1134            .flatten()
1135            .any(|relationship| {
1136                relationship.is_type(pptx_parse::relationship_types::COMMENTS)
1137                    || relationship.is_type(pptx_parse::relationship_types::MODERN_COMMENTS)
1138            })
1139    {
1140        meta.insert(txn, "commentsPendingSource", true);
1141    }
1142    meta.insert(
1143        txn,
1144        "commentFlavor",
1145        flavor_key(package.comment_flavor.unwrap_or_default()),
1146    );
1147}
1148
1149fn backfill_blip_effects(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1150    let shapes = required_map(txn, SHAPES)?;
1151    for (index, (slide, reference)) in package
1152        .slides
1153        .iter()
1154        .zip(&package.presentation.slides)
1155        .enumerate()
1156    {
1157        let slide_id = seeded_slide_id(index, reference.id);
1158        for (index, node) in slide.shapes.iter().enumerate() {
1159            backfill_picture_effects(txn, &shapes, &slide_id, &index.to_string(), node)?;
1160        }
1161    }
1162    Ok(())
1163}
1164
1165fn backfill_picture_effects(
1166    txn: &mut TransactionMut<'_>,
1167    shapes: &MapRef,
1168    slide_id: &str,
1169    path: &str,
1170    node: &ShapeNode,
1171) -> EditResult<()> {
1172    match node {
1173        ShapeNode::Picture(picture) if !picture.effects.is_empty() => {
1174            let id = seeded_shape_id(slide_id, path);
1175            if let Some(shape) = shapes
1176                .get(txn, &id)
1177                .and_then(|shape| shape.cast::<MapRef>().ok())
1178                && !shape.contains_key(txn, "blipEffectsJson")
1179            {
1180                insert_json(&shape, txn, "blipEffectsJson", Some(&picture.effects))?;
1181            }
1182        }
1183        ShapeNode::Group(group) => {
1184            for (index, child) in group.children.iter().enumerate() {
1185                backfill_picture_effects(
1186                    txn,
1187                    shapes,
1188                    slide_id,
1189                    &seeded_child_path(path, index),
1190                    child,
1191                )?;
1192            }
1193        }
1194        _ => {}
1195    }
1196    Ok(())
1197}
1198
1199fn package_needs_ole_source(package: &PptxPackage) -> bool {
1200    package
1201        .slides
1202        .iter()
1203        .any(|part| needs_ole_source(&part.shapes))
1204        || package
1205            .layouts
1206            .iter()
1207            .any(|part| needs_ole_source(&part.shapes))
1208        || package
1209            .masters
1210            .iter()
1211            .any(|part| needs_ole_source(&part.shapes))
1212}
1213
1214fn needs_ole_source(nodes: &[ShapeNode]) -> bool {
1215    nodes.iter().any(|node| match node {
1216        ShapeNode::GraphicFrame(frame) => matches!(&frame.data,
1217            pptx_parse::GraphicFrameData::Unknown { uri: Some(uri), picture: None }
1218                if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole"),
1219        ShapeNode::Group(group) => needs_ole_source(&group.children),
1220        _ => false,
1221    })
1222}
1223
1224pub(crate) fn import_source_ole_pictures(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
1225    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1226    let meta = required_map(&txn, META)?;
1227    if map_bool(&meta, &txn, "olePicturesPendingSource") != Some(true) {
1228        return Ok(());
1229    }
1230    let shapes = required_map(&txn, SHAPES)?;
1231    for (index, (slide, reference)) in source
1232        .slides
1233        .iter()
1234        .zip(&source.presentation.slides)
1235        .enumerate()
1236    {
1237        let slide_id = seeded_slide_id(index, reference.id);
1238        for (index, node) in slide.shapes.iter().enumerate() {
1239            backfill_ole_picture(&mut txn, &shapes, &slide_id, &index.to_string(), node)?;
1240        }
1241    }
1242    meta.remove(&mut txn, "olePicturesPendingSource");
1243    Ok(())
1244}
1245
1246fn backfill_ole_picture(
1247    txn: &mut TransactionMut<'_>,
1248    shapes: &MapRef,
1249    slide_id: &str,
1250    path: &str,
1251    node: &ShapeNode,
1252) -> EditResult<()> {
1253    if let ShapeNode::GraphicFrame(frame) = node
1254        && matches!(
1255            frame.data,
1256            pptx_parse::GraphicFrameData::Unknown {
1257                picture: Some(_),
1258                ..
1259            }
1260        )
1261        && let Some(shape) = shapes
1262            .get(txn, &seeded_shape_id(slide_id, path))
1263            .and_then(|value| value.cast::<MapRef>().ok())
1264        && matches!(
1265            optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
1266            Some(pptx_parse::GraphicFrameData::Unknown { picture: None, .. })
1267        )
1268    {
1269        insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
1270    }
1271    if let ShapeNode::Group(group) = node {
1272        for (index, child) in group.children.iter().enumerate() {
1273            backfill_ole_picture(
1274                txn,
1275                shapes,
1276                slide_id,
1277                &seeded_child_path(path, index),
1278                child,
1279            )?;
1280        }
1281    }
1282    Ok(())
1283}
1284
1285fn backfill_hidden(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1286    let shapes = required_map(txn, SHAPES)?;
1287    for shape_id in seeded_hidden_shape_ids(package) {
1288        if let Some(shape) = shapes
1289            .get(txn, &shape_id)
1290            .and_then(|value| value.cast::<MapRef>().ok())
1291        {
1292            shape.insert(txn, "hidden", true);
1293        }
1294    }
1295    Ok(())
1296}
1297
1298fn seeded_hidden_shape_ids(package: &PptxPackage) -> Vec<String> {
1299    let mut ids = Vec::new();
1300    for (slide_index, (slide, reference)) in package
1301        .slides
1302        .iter()
1303        .zip(&package.presentation.slides)
1304        .enumerate()
1305    {
1306        let slide_id = seeded_slide_id(slide_index, reference.id);
1307        for (shape_index, shape) in slide.shapes.iter().enumerate() {
1308            collect_hidden_shape_ids(&slide_id, &shape_index.to_string(), shape, &mut ids);
1309        }
1310    }
1311    ids
1312}
1313
1314fn collect_hidden_shape_ids(slide_id: &str, path: &str, shape: &ShapeNode, ids: &mut Vec<String>) {
1315    if shape_base(shape).hidden {
1316        ids.push(seeded_shape_id(slide_id, path));
1317    }
1318    if let ShapeNode::Group(group) = shape {
1319        for (child_index, child) in group.children.iter().enumerate() {
1320            collect_hidden_shape_ids(slide_id, &seeded_child_path(path, child_index), child, ids);
1321        }
1322    }
1323}
1324
1325fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
1326    map_number(meta, txn, "schemaVersion")
1327        .filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
1328        .ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
1329}
1330
1331fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
1332    schema_version(meta, txn).map(|_| ())
1333}
1334
1335fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
1336    let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
1337        return Err(EditError::InvalidState("missing package data".to_owned()));
1338    };
1339    serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
1340}
1341
1342pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
1343    let txn = doc.transact();
1344    let meta = required_map(&txn, META)?;
1345    let order = required_order(&txn)?;
1346    let slides = required_map(&txn, SLIDES)?;
1347    let shapes = required_map(&txn, SHAPES)?;
1348    let stories = required_map(&txn, STORIES)?;
1349    let mut seen_slides = HashSet::new();
1350    let mut slide_snapshots = Vec::new();
1351    for slide_id in string_array_ref(&order, &txn) {
1352        if !seen_slides.insert(slide_id.clone()) {
1353            continue;
1354        }
1355        let slide = slides
1356            .get(&txn, &slide_id)
1357            .and_then(|value| value.cast::<MapRef>().ok())
1358            .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
1359        let source_part_path = map_string(&slide, &txn, "sourcePartPath");
1360        let layout_part_path = map_string(&slide, &txn, "layoutPartPath");
1361        let theme = theme_for_layout(package, layout_part_path.as_deref());
1362        let shape_order = slide_shape_order(&slide, &txn)?;
1363        let mut seen_shapes = HashSet::new();
1364        let mut shape_snapshots = Vec::new();
1365        for shape_id in string_array_ref(&shape_order, &txn) {
1366            if seen_shapes.insert(shape_id.clone()) {
1367                shape_snapshots.push(snapshot_shape(
1368                    &shapes,
1369                    &stories,
1370                    &txn,
1371                    &shape_id,
1372                    &mut HashSet::new(),
1373                    theme,
1374                )?);
1375            }
1376        }
1377        let notes = map_string(&slide, &txn, "notes").unwrap_or_else(|| {
1378            package
1379                .slides
1380                .iter()
1381                .find(|source| Some(&source.part_path) == source_part_path.as_ref())
1382                .map(|source| source.notes.clone())
1383                .unwrap_or_default()
1384        });
1385        slide_snapshots.push(SlideSnapshot {
1386            id: slide_id,
1387            source_part_path,
1388            layout_part_path,
1389            name: map_string(&slide, &txn, "name"),
1390            notes,
1391            shapes: shape_snapshots,
1392        });
1393    }
1394    Ok(DeckSnapshot {
1395        width_emu: required_i64(&meta, &txn, "widthEmu")?,
1396        height_emu: required_i64(&meta, &txn, "heightEmu")?,
1397        slides: slide_snapshots,
1398        comment_flavor: snapshot_flavor(&txn)?,
1399        comments: snapshot_comments(&txn)?,
1400    })
1401}
1402
1403fn snapshot_shape<T: ReadTxn>(
1404    shapes: &MapRef,
1405    stories: &MapRef,
1406    txn: &T,
1407    shape_id: &str,
1408    visiting: &mut HashSet<String>,
1409    theme: Option<&Theme>,
1410) -> EditResult<ShapeSnapshot> {
1411    if visiting.len() >= MAX_SHAPE_DEPTH {
1412        return Err(EditError::InvalidState(format!(
1413            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
1414        )));
1415    }
1416    if !visiting.insert(shape_id.to_owned()) {
1417        return Err(EditError::InvalidState(format!(
1418            "shape cycle at {shape_id}"
1419        )));
1420    }
1421    let shape = shapes
1422        .get(txn, shape_id)
1423        .and_then(|value| value.cast::<MapRef>().ok())
1424        .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
1425    let mut text_snapshots = Vec::new();
1426    for story_id in map_string_array(&shape, txn, "textStories")? {
1427        let story = stories
1428            .get(txn, &story_id)
1429            .and_then(|value| value.cast::<TextRef>().ok())
1430            .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
1431        text_snapshots.push(snapshot_story(&story, txn, &story_id)?);
1432    }
1433    let mut children = Vec::new();
1434    for child_id in map_string_array(&shape, txn, "children")? {
1435        children.push(snapshot_shape(
1436            shapes, stories, txn, &child_id, visiting, theme,
1437        )?);
1438    }
1439    visiting.remove(shape_id);
1440    let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
1441    let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
1442    let resolved_fill_color = fill
1443        .as_ref()
1444        .filter(|fill| fill.fill_type != "none")
1445        .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
1446    let resolved_outline_color = outline
1447        .as_ref()
1448        .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
1449    Ok(ShapeSnapshot {
1450        id: shape_id.to_owned(),
1451        source_id: required_u32(&shape, txn, "sourceId")?,
1452        kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
1453        name: required_string(&shape, txn, "name")?,
1454        x: required_i64(&shape, txn, "x")?,
1455        y: required_i64(&shape, txn, "y")?,
1456        width: required_i64(&shape, txn, "width")?,
1457        height: required_i64(&shape, txn, "height")?,
1458        rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
1459        flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
1460        flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
1461        hidden: map_bool(&shape, txn, "hidden").unwrap_or_default(),
1462        geometry: required_string(&shape, txn, "geometry")?,
1463        adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
1464        placeholder: optional_json(&shape, txn, "placeholderJson")?,
1465        fill,
1466        resolved_fill_color,
1467        outline,
1468        resolved_outline_color,
1469        media_part_path: map_string(&shape, txn, "mediaPartPath"),
1470        blip_effects: optional_json(&shape, txn, "blipEffectsJson")?.unwrap_or_default(),
1471        graphic: optional_json(&shape, txn, "graphicJson")?,
1472        text_stories: text_snapshots,
1473        children,
1474    })
1475}
1476
1477fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
1478    match value {
1479        "shape" => Ok(ShapeKind::Shape),
1480        "picture" => Ok(ShapeKind::Picture),
1481        "graphicFrame" => Ok(ShapeKind::GraphicFrame),
1482        "group" => Ok(ShapeKind::Group),
1483        _ => Err(EditError::InvalidState(format!(
1484            "unknown shape kind {value}"
1485        ))),
1486    }
1487}
1488
1489fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
1490    txn.get_array(SLIDE_ORDER)
1491        .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
1492}
1493
1494pub(crate) fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
1495    txn.get_map(name)
1496        .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
1497}
1498
1499pub(crate) fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
1500    required_map(txn, SLIDES)?
1501        .get(txn, slide_id)
1502        .and_then(|value| value.cast::<MapRef>().ok())
1503        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
1504}
1505
1506fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
1507    required_map(txn, SHAPES)?
1508        .get(txn, shape_id)
1509        .and_then(|value| value.cast::<MapRef>().ok())
1510        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
1511}
1512
1513fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
1514    if required_string(shape, txn, "kind")? == "shape" {
1515        Ok(())
1516    } else {
1517        Err(EditError::InvalidGeometry(
1518            "only preset shapes support shape styling".to_owned(),
1519        ))
1520    }
1521}
1522
1523fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
1524    slide
1525        .get(txn, "shapes")
1526        .and_then(|value| value.cast::<ArrayRef>().ok())
1527        .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
1528}
1529
1530fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
1531    let slide = slide_ref(txn, slide_id)?;
1532    let order = slide_shape_order(&slide, txn)?;
1533    if array_index(&order, txn, shape_id).is_some() {
1534        Ok(())
1535    } else {
1536        Err(EditError::ShapeNotFound(shape_id.to_owned()))
1537    }
1538}
1539
1540fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
1541    let shapes = required_map(txn, SHAPES)?;
1542    let stories = required_map(txn, STORIES)?;
1543    let mut entries = ShapeEntries::default();
1544    for shape_id in root_shape_ids {
1545        collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
1546    }
1547    for story_id in entries.story_ids {
1548        stories.remove(txn, &story_id);
1549    }
1550    for shape_id in entries.shape_ids {
1551        shapes.remove(txn, &shape_id);
1552    }
1553    Ok(())
1554}
1555
1556#[derive(Default)]
1557struct ShapeEntries {
1558    shape_ids: Vec<String>,
1559    seen_shape_ids: HashSet<String>,
1560    story_ids: Vec<String>,
1561    seen_story_ids: HashSet<String>,
1562}
1563
1564fn collect_shape_entries<T: ReadTxn>(
1565    shapes: &MapRef,
1566    txn: &T,
1567    shape_id: &str,
1568    visiting: &mut HashSet<String>,
1569    entries: &mut ShapeEntries,
1570) -> EditResult<()> {
1571    if visiting.len() >= MAX_SHAPE_DEPTH {
1572        return Err(EditError::InvalidState(format!(
1573            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
1574        )));
1575    }
1576    if !visiting.insert(shape_id.to_owned()) {
1577        return Err(EditError::InvalidState(format!(
1578            "shape cycle at {shape_id}"
1579        )));
1580    }
1581    if entries.seen_shape_ids.contains(shape_id) {
1582        visiting.remove(shape_id);
1583        return Ok(());
1584    }
1585    let shape = shapes
1586        .get(txn, shape_id)
1587        .and_then(|value| value.cast::<MapRef>().ok())
1588        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
1589    for story_id in map_string_array(&shape, txn, "textStories")? {
1590        if entries.seen_story_ids.insert(story_id.clone()) {
1591            entries.story_ids.push(story_id);
1592        }
1593    }
1594    for child_id in map_string_array(&shape, txn, "children")? {
1595        collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
1596    }
1597    visiting.remove(shape_id);
1598    entries.seen_shape_ids.insert(shape_id.to_owned());
1599    entries.shape_ids.push(shape_id.to_owned());
1600    Ok(())
1601}
1602
1603fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
1604    array
1605        .iter(txn)
1606        .enumerate()
1607        .find(|(_, item)| out_string(item).as_deref() == Some(value))
1608        .map(|(index, _)| index as u32)
1609}
1610
1611fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
1612    array
1613        .iter(txn)
1614        .filter_map(|value| out_string(&value))
1615        .collect()
1616}
1617
1618fn map_string_array<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<Vec<String>> {
1619    match map.get(txn, key) {
1620        Some(Out::Any(Any::Array(values))) => Ok(values
1621            .iter()
1622            .filter_map(|value| match value {
1623                Any::String(value) => Some(value.to_string()),
1624                _ => None,
1625            })
1626            .collect()),
1627        None => Ok(Vec::new()),
1628        _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
1629    }
1630}
1631
1632fn string_array(values: &[String]) -> Any {
1633    Any::Array(Arc::from(
1634        values
1635            .iter()
1636            .map(|value| Any::from(value.as_str()))
1637            .collect::<Vec<_>>(),
1638    ))
1639}
1640
1641fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
1642    Ok(ShapeRect {
1643        x: required_i64(shape, txn, "x")?,
1644        y: required_i64(shape, txn, "y")?,
1645        width: required_i64(shape, txn, "width")?,
1646        height: required_i64(shape, txn, "height")?,
1647    })
1648}
1649
1650fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
1651    let value = required_i64(map, txn, key)?;
1652    u32::try_from(value).map_err(|_| {
1653        EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
1654    })
1655}
1656
1657fn validate_rect(rect: ShapeRect) -> EditResult<()> {
1658    validate_coordinate(rect.x)?;
1659    validate_coordinate(rect.y)?;
1660    if rect.width <= 0 || rect.height <= 0 {
1661        return Err(EditError::InvalidGeometry(
1662            "shape width and height must be positive".to_owned(),
1663        ));
1664    }
1665    validate_coordinate(rect.width)?;
1666    validate_coordinate(rect.height)
1667}
1668
1669fn validate_coordinate(value: i64) -> EditResult<()> {
1670    if value.unsigned_abs() > MAX_GEOMETRY as u64 {
1671        return Err(EditError::InvalidGeometry(format!(
1672            "coordinate {value} exceeds the safe range"
1673        )));
1674    }
1675    Ok(())
1676}
1677
1678fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
1679    if adjustments.len() > MAX_ADJUSTMENTS {
1680        return Err(EditError::InvalidAdjustment(format!(
1681            "at most {MAX_ADJUSTMENTS} values are allowed"
1682        )));
1683    }
1684    for (name, value) in adjustments {
1685        if !valid_adjustment_name(name) {
1686            return Err(EditError::InvalidAdjustment(format!(
1687                "unrecognized guide name {name:?}"
1688            )));
1689        }
1690        if !value.is_finite() {
1691            return Err(EditError::InvalidAdjustment(format!(
1692                "guide {name:?} must be finite"
1693            )));
1694        }
1695    }
1696    Ok(())
1697}
1698
1699fn valid_adjustment_name(name: &str) -> bool {
1700    if name == "adj" {
1701        return true;
1702    }
1703    let Some(index) = name.strip_prefix("adj") else {
1704        return false;
1705    };
1706    index
1707        .parse::<usize>()
1708        .ok()
1709        .filter(|value| value.to_string() == index)
1710        .is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
1711}
1712
1713fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
1714    Ok(match color {
1715        Some(color) => ShapeFill {
1716            fill_type: "solid".to_owned(),
1717            color: Some(color_value(color)?),
1718            gradient: None,
1719        },
1720        None => ShapeFill::named("none"),
1721    })
1722}
1723
1724fn fill_color(fill: &ShapeFill) -> Option<String> {
1725    (fill.fill_type != "none")
1726        .then(|| resolve_color_value_to_hex(fill.color.as_ref()))
1727        .flatten()
1728}
1729
1730fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
1731    Some(ShapeStroke {
1732        color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
1733        width_pt: outline.width.map(|width| width / EMU_PER_POINT),
1734    })
1735}
1736
1737fn color_value(color: &str) -> EditResult<ColorValue> {
1738    let rgb = color.strip_prefix('#').unwrap_or(color);
1739    if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1740        return Err(EditError::InvalidGeometry(format!(
1741            "color {color:?} must be a six-digit hex value"
1742        )));
1743    }
1744    Ok(ColorValue {
1745        rgb: Some(rgb.to_ascii_uppercase()),
1746        ..ColorValue::default()
1747    })
1748}
1749
1750fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
1751    map_string(map, txn, key)
1752        .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
1753}
1754
1755pub(crate) fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
1756    map.get(txn, key).and_then(|value| out_string(&value))
1757}
1758
1759fn out_string(value: &Out) -> Option<String> {
1760    match value {
1761        Out::Any(Any::String(value)) => Some(value.to_string()),
1762        _ => None,
1763    }
1764}
1765
1766fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
1767    let number = map_number(map, txn, key)
1768        .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
1769    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
1770        return Err(EditError::InvalidState(format!("invalid integer {key}")));
1771    }
1772    Ok(number as i64)
1773}
1774
1775pub(crate) fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
1776    match map.get(txn, key) {
1777        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
1778        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
1779        _ => None,
1780    }
1781}
1782
1783pub(crate) fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
1784    match map.get(txn, key) {
1785        Some(Out::Any(Any::Bool(value))) => Some(value),
1786        _ => None,
1787    }
1788}
1789
1790fn optional_json<T: DeserializeOwned, R: ReadTxn>(
1791    map: &MapRef,
1792    txn: &R,
1793    key: &str,
1794) -> EditResult<Option<T>> {
1795    map_string(map, txn, key)
1796        .map(|json| {
1797            serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
1798        })
1799        .transpose()
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804    use super::*;
1805    use crate::TextStyle;
1806
1807    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
1808    const HIDDEN_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/hidden-shapes.pptx");
1809
1810    #[test]
1811    fn a_reattached_source_restores_the_series_lines_a_stored_package_lacks() {
1812        let source = ChartSpace {
1813            plot_groups: vec![pptx_parse::ChartPlotGroup {
1814                series: vec![pptx_parse::ChartSeries {
1815                    line: Some(ooxml_drawingml::chart::ChartLine {
1816                        none: false,
1817                        color: Some("#FFFFFF".to_owned()),
1818                        width_emu: Some(41275.0),
1819                    }),
1820                    ..pptx_parse::ChartSeries::default()
1821                }],
1822                ..pptx_parse::ChartPlotGroup::default()
1823            }],
1824            ..ChartSpace::default()
1825        };
1826        let mut target = source.clone();
1827        target.plot_groups[0].series[0].line = None;
1828
1829        assert!(merge_source_chart_properties(&mut target, &source));
1830        assert_eq!(target, source);
1831        assert!(!merge_source_chart_properties(&mut target, &source));
1832    }
1833
1834    #[test]
1835    fn a_reattached_source_restores_the_right_margin_a_stored_package_lacks() {
1836        let source = pptx_parse::ParagraphProperties {
1837            margin_right: Some(914_400),
1838            ..pptx_parse::ParagraphProperties::default()
1839        };
1840        let mut target = pptx_parse::ParagraphProperties::default();
1841
1842        assert!(merge_source_paragraph_properties(&mut target, &source));
1843        assert_eq!(target.margin_right, Some(914_400));
1844        assert!(!merge_source_paragraph_properties(&mut target, &source));
1845    }
1846
1847    #[test]
1848    fn a_reattached_source_restores_the_paragraph_spacing_a_stored_package_lacks() {
1849        let source = pptx_parse::ParagraphProperties {
1850            space_before: Some(pptx_parse::LineSpacing::Points { value: 10.0 }),
1851            space_after: Some(pptx_parse::LineSpacing::Percent { value: 0.2 }),
1852            ..pptx_parse::ParagraphProperties::default()
1853        };
1854        let mut target = pptx_parse::ParagraphProperties::default();
1855
1856        assert!(merge_source_paragraph_properties(&mut target, &source));
1857        assert_eq!(target.space_before, source.space_before);
1858        assert_eq!(target.space_after, source.space_after);
1859        assert!(!merge_source_paragraph_properties(&mut target, &source));
1860    }
1861
1862    #[test]
1863    fn attaching_a_source_refreshes_chart_text_a_stored_package_never_carried() {
1864        use ooxml_drawingml::chart::{ChartLegend, ChartTextProperties};
1865
1866        let styled = ChartTextProperties {
1867            spacing_pt: Some(3.0),
1868            ..ChartTextProperties::default()
1869        };
1870        let source = ChartSpace {
1871            text: Some(styled.clone()),
1872            title_text: Some(styled.clone()),
1873            legend: Some(ChartLegend {
1874                visible: true,
1875                text: Some(styled.clone()),
1876                ..ChartLegend::default()
1877            }),
1878            axis_list: Some(vec![ChartAxis {
1879                text: Some(styled.clone()),
1880                ..ChartAxis::default()
1881            }]),
1882            ..ChartSpace::default()
1883        };
1884        let mut target = ChartSpace {
1885            legend: Some(ChartLegend {
1886                visible: true,
1887                ..ChartLegend::default()
1888            }),
1889            axis_list: Some(vec![ChartAxis::default()]),
1890            ..ChartSpace::default()
1891        };
1892        assert!(merge_source_chart_properties(&mut target, &source));
1893        assert_eq!(target.text.as_ref(), Some(&styled));
1894        assert_eq!(target.title_text.as_ref(), Some(&styled));
1895        assert_eq!(target.legend.unwrap().text.as_ref(), Some(&styled));
1896        assert_eq!(target.axis_list.unwrap()[0].text.as_ref(), Some(&styled));
1897        assert!(!merge_source_chart_properties(&mut source.clone(), &source));
1898    }
1899
1900    #[test]
1901    fn an_unmigratable_schema_version_is_reported_before_package_json() {
1902        let session = DeckSession::open(FIXTURE, 100).unwrap();
1903        {
1904            let mut txn = session.doc.transact_mut();
1905            let meta = required_map(&txn, META).unwrap();
1906            meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
1907            meta.insert(
1908                &mut txn,
1909                "packageJson",
1910                Any::Buffer(Arc::from(b"{}".to_vec())),
1911            );
1912        }
1913
1914        assert!(matches!(
1915            validate_doc(&session.doc),
1916            Err(EditError::InvalidState(message))
1917                if message == "unsupported deck schema version"
1918        ));
1919        assert!(matches!(
1920            DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
1921            Err(EditError::InvalidState(message))
1922                if message == "unsupported deck schema version"
1923        ));
1924    }
1925
1926    #[test]
1927    fn migration_leaves_a_current_document_untouched() {
1928        let session = DeckSession::open(FIXTURE, 102).unwrap();
1929        let before = session.encode_state_as_update_v1();
1930        migrate_doc(&session.doc).unwrap();
1931        assert_eq!(session.encode_state_as_update_v1(), before);
1932    }
1933
1934    #[test]
1935    fn legacy_migrations_commit_once_and_normalise_the_package() {
1936        use std::sync::Mutex;
1937
1938        const V1: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v1.update.bin");
1939        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-connectors.update.bin");
1940        for update in [V1, V2] {
1941            let doc = crate::doc_with_client_id(920);
1942            crate::hydrate_doc(&doc, update).unwrap();
1943            let before = snapshot_doc(&doc, &package_from_doc(&doc).unwrap()).unwrap();
1944            let observed = Arc::new(Mutex::new(Vec::new()));
1945            let events = observed.clone();
1946            let _subscription = doc
1947                .observe_update_v1(move |txn, _| {
1948                    let meta = required_map(txn, META).unwrap();
1949                    let Some(Out::Any(Any::Buffer(package))) = meta.get(txn, "packageJson") else {
1950                        panic!("missing package data");
1951                    };
1952                    events
1953                        .lock()
1954                        .unwrap()
1955                        .push((map_number(&meta, txn, "schemaVersion").unwrap(), package));
1956                })
1957                .unwrap();
1958
1959            migrate_doc(&doc).unwrap();
1960
1961            let events = observed.lock().unwrap();
1962            assert_eq!(
1963                events
1964                    .iter()
1965                    .map(|(version, _)| *version)
1966                    .collect::<Vec<_>>(),
1967                [SCHEMA_VERSION]
1968            );
1969            for (_, package) in events.iter() {
1970                let json: serde_json::Value = serde_json::from_slice(package).unwrap();
1971                assert!(json.get("charts").unwrap().is_array());
1972                assert!(json.get("shapeElements").is_none());
1973                assert!(json["presentation"].get("firstSlideNum").is_none());
1974            }
1975            let package = package_from_doc(&doc).unwrap();
1976            assert_eq!(snapshot_doc(&doc, &package).unwrap(), before);
1977            assert!(!package.models_connectors());
1978            assert_eq!(package.presentation.first_slide_num, 1);
1979        }
1980    }
1981
1982    #[test]
1983    fn a_v2_hidden_snapshot_backfills_flags_without_rewriting_the_package() {
1984        use std::sync::Mutex;
1985        use yrs::Update;
1986        use yrs::updates::decoder::Decode;
1987
1988        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-hidden.update.bin");
1989        let doc = crate::doc_with_client_id(9430);
1990        doc.transact_mut()
1991            .apply_update(Update::decode_v1(V2).unwrap())
1992            .unwrap();
1993        let before = package_from_doc(&doc).unwrap();
1994        let observed = Arc::new(Mutex::new(Vec::new()));
1995        let events = observed.clone();
1996        let _subscription = doc
1997            .observe_update_v1(move |txn, _| {
1998                let meta = required_map(txn, META).unwrap();
1999                let shapes = required_map(txn, SHAPES).unwrap();
2000                let mut hidden: Vec<String> = shapes
2001                    .iter(txn)
2002                    .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2003                    .filter(|(_, shape)| map_bool(shape, txn, "hidden").unwrap_or(false))
2004                    .map(|(id, _)| id.to_owned())
2005                    .collect();
2006                hidden.sort();
2007                events.lock().unwrap().push((
2008                    map_number(&meta, txn, "schemaVersion").unwrap(),
2009                    package_from_meta(&meta, txn).unwrap(),
2010                    hidden,
2011                ));
2012            })
2013            .unwrap();
2014        migrate_doc(&doc).unwrap();
2015        let events = observed.lock().unwrap();
2016        assert_eq!(
2017            events
2018                .iter()
2019                .map(|(version, _, _)| *version)
2020                .collect::<Vec<_>>(),
2021            [SCHEMA_VERSION]
2022        );
2023        for (_, package, hidden) in events.iter() {
2024            assert_eq!(
2025                serde_json::to_vec(package).unwrap(),
2026                serde_json::to_vec(&before).unwrap()
2027            );
2028            assert_eq!(
2029                hidden,
2030                &[
2031                    "slide:0:256:shape:0",
2032                    "slide:0:256:shape:8",
2033                    "slide:0:256:shape:8.13",
2034                    "slide:1:257:shape:16",
2035                ]
2036            );
2037        }
2038    }
2039
2040    #[test]
2041    fn seeding_stores_hidden_only_for_hidden_shapes() {
2042        assert!(hidden_keys(&DeckSession::open(FIXTURE, 103).unwrap()).is_empty());
2043
2044        let session = DeckSession::open(HIDDEN_FIXTURE, 104).unwrap();
2045        assert_eq!(
2046            hidden_keys(&session),
2047            [
2048                "slide:0:256:shape:0",
2049                "slide:0:256:shape:8",
2050                "slide:0:256:shape:8.13",
2051                "slide:1:257:shape:16",
2052                "slide:1:257:shape:4",
2053            ]
2054        );
2055        let snapshot = session.snapshot().unwrap();
2056        let group = &snapshot.slides[0].shapes[8];
2057        assert!(snapshot.slides[0].shapes[0].hidden);
2058        assert!(group.hidden);
2059        assert_eq!(group.children.len(), 14);
2060        assert!(group.children[..13].iter().all(|child| !child.hidden));
2061        assert!(group.children[13].hidden);
2062    }
2063
2064    #[test]
2065    fn hidden_flags_serialise_sparsely_and_round_trip() {
2066        let snapshot = DeckSession::open(HIDDEN_FIXTURE, 106)
2067            .unwrap()
2068            .snapshot()
2069            .unwrap();
2070        let json = serde_json::to_string(&snapshot).unwrap();
2071        assert_eq!(json.matches("\"hidden\":true").count(), 5);
2072        assert!(!json.contains("\"hidden\":false"));
2073        assert_eq!(
2074            serde_json::from_str::<DeckSnapshot>(&json).unwrap(),
2075            snapshot
2076        );
2077    }
2078
2079    fn hidden_keys(session: &DeckSession) -> Vec<String> {
2080        let txn = session.doc.transact();
2081        let shapes = required_map(&txn, SHAPES).unwrap();
2082        let mut ids: Vec<String> = shapes
2083            .iter(&txn)
2084            .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2085            .filter(|(_, shape)| shape.get(&txn, "hidden").is_some())
2086            .map(|(id, _)| id.to_owned())
2087            .collect();
2088        ids.sort();
2089        ids
2090    }
2091
2092    #[test]
2093    fn delete_operations_remove_owned_map_entries() {
2094        let session = DeckSession::open(FIXTURE, 101).unwrap();
2095        let context = EditCtx::local("test");
2096        let baseline = map_lengths(&session);
2097
2098        for cycle in 0..3 {
2099            let index = session.snapshot().unwrap().slides.len() as u32;
2100            let slide = session.insert_slide(&context, index, None).unwrap();
2101            let first = session
2102                .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
2103                .unwrap();
2104            session
2105                .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
2106                .unwrap();
2107            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
2108
2109            session
2110                .remove_shape(&context, &slide.slide_id, &first.shape_id)
2111                .unwrap();
2112            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
2113
2114            session.delete_slide(&context, &slide.slide_id).unwrap();
2115            assert_eq!(map_lengths(&session), baseline);
2116        }
2117    }
2118
2119    #[test]
2120    fn deleting_seeded_slide_cascades_descendant_entries() {
2121        let session = DeckSession::open(FIXTURE, 202).unwrap();
2122        let slide = session.snapshot().unwrap().slides[0].clone();
2123        let before = map_lengths(&session);
2124        let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
2125            let next = shape_entry_lengths(shape);
2126            (total.0 + next.0, total.1 + next.1)
2127        });
2128
2129        session
2130            .delete_slide(&EditCtx::local("test"), &slide.id)
2131            .unwrap();
2132
2133        assert!(owned.0 > slide.shapes.len() as u32);
2134        assert_eq!(
2135            map_lengths(&session),
2136            (before.0 - 1, before.1 - owned.0, before.2 - owned.1)
2137        );
2138    }
2139
2140    fn draft(cycle: u32, index: u32) -> ShapeDraft {
2141        ShapeDraft {
2142            name: format!("Shape {cycle}:{index}"),
2143            rect: ShapeRect {
2144                x: 100_000,
2145                y: 100_000,
2146                width: 1_000_000,
2147                height: 500_000,
2148            },
2149            text: "Text".to_owned(),
2150            style: TextStyle::default(),
2151        }
2152    }
2153
2154    fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
2155        let txn = session.doc.transact();
2156        (
2157            required_map(&txn, SLIDES).unwrap().len(&txn),
2158            required_map(&txn, SHAPES).unwrap().len(&txn),
2159            required_map(&txn, STORIES).unwrap().len(&txn),
2160        )
2161    }
2162
2163    fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
2164        shape.children.iter().fold(
2165            (1, shape.text_stories.len() as u32),
2166            |(shape_count, story_count), child| {
2167                let child_counts = shape_entry_lengths(child);
2168                (shape_count + child_counts.0, story_count + child_counts.1)
2169            },
2170        )
2171    }
2172
2173    fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
2174        (left.0 + right.0, left.1 + right.1, left.2 + right.2)
2175    }
2176
2177    #[test]
2178    fn a_v2_comment_snapshot_records_its_flavour_while_deferring_the_import() {
2179        use std::sync::Mutex;
2180        use yrs::Update;
2181        use yrs::updates::decoder::Decode;
2182
2183        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-comments.update.bin");
2184        let doc = crate::doc_with_client_id(9620);
2185        doc.transact_mut()
2186            .apply_update(Update::decode_v1(V2).unwrap())
2187            .unwrap();
2188        let before = {
2189            let txn = doc.transact();
2190            let meta = required_map(&txn, META).unwrap();
2191            assert_eq!(map_number(&meta, &txn, "schemaVersion"), Some(2.0));
2192            assert!(meta.get(&txn, "commentFlavor").is_none());
2193            package_from_meta(&meta, &txn).unwrap()
2194        };
2195        let observed = Arc::new(Mutex::new(Vec::new()));
2196        let events = observed.clone();
2197        let _subscription = doc
2198            .observe_update_v1(move |txn, _| {
2199                let meta = required_map(txn, META).unwrap();
2200                events.lock().unwrap().push((
2201                    map_number(&meta, txn, "schemaVersion").unwrap(),
2202                    package_from_meta(&meta, txn).unwrap(),
2203                    map_string(&meta, txn, "commentFlavor"),
2204                    map_bool(&meta, txn, "commentsPendingSource"),
2205                ));
2206            })
2207            .unwrap();
2208        migrate_doc(&doc).unwrap();
2209        let events = observed.lock().unwrap();
2210        assert_eq!(
2211            *events,
2212            [(
2213                SCHEMA_VERSION,
2214                before,
2215                Some("legacy".to_owned()),
2216                Some(true)
2217            )]
2218        );
2219    }
2220}