Skip to main content

pptx_edit/
deck.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use base64::Engine as _;
5use ooxml_drawingml::{
6    ColorMap, ColorValue, ShapeFill, ShapeOutline, Theme, preset_geometry_default_adjustments,
7    preset_geometry_to_path, resolve_color_value_to_hex, resolve_color_value_to_hex_with_theme,
8};
9use pptx_parse::{
10    ChartAxis, ChartSpace, GraphicFrameData, Placeholder, PptxPackage, ShapeBase, ShapeNode, Slide,
11};
12use serde::de::DeserializeOwned;
13use yrs::updates::decoder::Decode;
14use yrs::{
15    Any, Array, ArrayPrelim, ArrayRef, ClientID, Doc, Map, MapPrelim, MapRef, Out, ReadTxn,
16    StateVector, TextRef, Transact, TransactionMut, Update, WriteTxn,
17};
18
19use crate::comments::{
20    baseline_comments, flavor_key, seed_comments, snapshot_comments, snapshot_flavor,
21};
22use crate::inherit::{SlideContext, inherited_transform, layout_transform, record_inherited};
23use crate::story::{baseline_story, seed_plain_story, seed_story, snapshot_story, validate_story};
24use crate::{
25    DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN, PendingMedia,
26    PictureDraft, PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt,
27    ShapeDraft, ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
28    ShapeStrokeReceipt, ShapeZOrderReceipt, SlideReceipt, SlideScope, SlideSnapshot,
29    TransformReceipt,
30};
31
32const SCHEMA_VERSION: f64 = 2.2;
33/// Versions [`migrate_doc`] can carry forward. Anything else is unreadable.
34const MIGRATABLE_SCHEMA_VERSIONS: [f64; 4] = [1.0, 2.0, 2.1, SCHEMA_VERSION];
35const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
36const MAX_SHAPE_DEPTH: usize = 128;
37const EMU_PER_POINT: f64 = 12_700.0;
38const MAX_ADJUSTMENTS: usize = 32;
39const MAX_ADJUSTMENT_INDEX: usize = 32;
40/// Stays well under the 16 MiB collaboration frame cap once base64-encoded.
41const MAX_PENDING_PICTURE_BYTES: usize = 8 * 1024 * 1024;
42
43pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
44    let package_json =
45        serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
46    let mut txn = doc.transact_mut_with("pptx:bootstrap");
47    let meta = txn.get_or_insert_map(META);
48    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
49    meta.insert(&mut txn, "fingerprint", fingerprint);
50    meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
51    meta.insert(
52        &mut txn,
53        "heightEmu",
54        package.presentation.height_emu as f64,
55    );
56    meta.insert(
57        &mut txn,
58        "packageJson",
59        Any::Buffer(Arc::from(package_json)),
60    );
61    meta.insert(
62        &mut txn,
63        "commentFlavor",
64        flavor_key(package.comment_flavor.unwrap_or_default()),
65    );
66    let order = txn.get_or_insert_array(SLIDE_ORDER);
67    let slides = txn.get_or_insert_map(SLIDES);
68    let shapes = txn.get_or_insert_map(SHAPES);
69    let stories = txn.get_or_insert_map(STORIES);
70    let mut slide_id_by_part: HashMap<String, String> = HashMap::new();
71
72    for (slide_index, slide) in package.slides.iter().enumerate() {
73        let theme = pptx_parse::slide_theme(package, Some(&slide.part_path), None);
74        let slide_id = seeded_slide_id(slide_index, package.presentation.slides[slide_index].id);
75        order.push_back(&mut txn, slide_id.as_str());
76        let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
77        slide_map.insert(&mut txn, "id", slide_id.as_str());
78        slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
79        if let Some(layout) = &slide.layout_part_path {
80            slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
81        }
82        if let Some(name) = &slide.name {
83            slide_map.insert(&mut txn, "name", name.as_str());
84        }
85        if !slide.notes.is_empty() {
86            slide_map.insert(&mut txn, "notes", slide.notes.as_str());
87        }
88        let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
89        for (shape_index, shape) in slide.shapes.iter().enumerate() {
90            let shape_id = seed_shape(
91                &shapes,
92                &stories,
93                &mut txn,
94                &slide_id,
95                &shape_index.to_string(),
96                shape,
97                Some(&theme),
98            )?;
99            shape_order.push_back(&mut txn, shape_id.as_str());
100        }
101        slide_id_by_part.insert(slide.part_path.clone(), slide_id);
102    }
103    seed_comments(&mut txn, package, &|part| {
104        slide_id_by_part.get(part).cloned()
105    })?;
106    Ok(())
107}
108
109fn seed_shape(
110    shapes: &MapRef,
111    stories: &MapRef,
112    txn: &mut TransactionMut<'_>,
113    slide_id: &str,
114    path: &str,
115    shape: &ShapeNode,
116    theme: Option<&Theme>,
117) -> EditResult<String> {
118    let shape_id = seeded_shape_id(slide_id, path);
119    let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
120    let base = shape_base(shape);
121    shape_map.insert(txn, "id", shape_id.as_str());
122    shape_map.insert(txn, "sourceId", base.id as f64);
123    shape_map.insert(txn, "name", base.name.as_str());
124    shape_map.insert(txn, "x", base.transform.x as f64);
125    shape_map.insert(txn, "y", base.transform.y as f64);
126    shape_map.insert(txn, "width", base.transform.width as f64);
127    shape_map.insert(txn, "height", base.transform.height as f64);
128    shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
129    shape_map.insert(txn, "flipH", base.transform.flip_h);
130    shape_map.insert(txn, "flipV", base.transform.flip_v);
131    if base.hidden {
132        shape_map.insert(txn, "hidden", true);
133    }
134    insert_json(
135        &shape_map,
136        txn,
137        "placeholderJson",
138        base.placeholder.as_ref(),
139    )?;
140
141    let mut text_story_ids = Vec::new();
142    let mut child_ids = Vec::new();
143    match shape {
144        ShapeNode::Shape(shape) => {
145            shape_map.insert(txn, "kind", "shape");
146            shape_map.insert(txn, "geometry", shape.geometry.as_str());
147            if !shape.has_preset_geometry {
148                shape_map.insert(txn, "hasPresetGeometry", false);
149            }
150            let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
151                .into_iter()
152                .collect::<BTreeMap<_, _>>();
153            adjust_values.extend(shape.adjust_values.clone());
154            insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
155            insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
156            insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
157            if let Some(body) = &shape.text {
158                let story_id = format!("story:{shape_id}:0");
159                seed_story(stories, txn, &story_id, body, theme)?;
160                text_story_ids.push(story_id);
161            }
162        }
163        ShapeNode::Picture(picture) => {
164            shape_map.insert(txn, "kind", "picture");
165            shape_map.insert(txn, "geometry", "rect");
166            insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
167            insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
168            if !picture.effects.is_empty() {
169                insert_json(&shape_map, txn, "blipEffectsJson", Some(&picture.effects))?;
170            }
171            if let Some(media) = &picture.media_part_path {
172                shape_map.insert(txn, "mediaPartPath", media.as_str());
173            }
174        }
175        ShapeNode::GraphicFrame(frame) => {
176            shape_map.insert(txn, "kind", "graphicFrame");
177            shape_map.insert(txn, "geometry", "rect");
178            insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
179            if let pptx_parse::GraphicFrameData::Table(table) = &frame.data {
180                for (row_index, row) in table.rows.iter().enumerate() {
181                    for (cell_index, cell) in row.cells.iter().enumerate() {
182                        let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
183                        seed_story(stories, txn, &story_id, &cell.text, theme)?;
184                        text_story_ids.push(story_id);
185                    }
186                }
187            }
188        }
189        ShapeNode::Group(group) => {
190            shape_map.insert(txn, "kind", "group");
191            shape_map.insert(txn, "geometry", "group");
192            for (child_index, child) in group.children.iter().enumerate() {
193                child_ids.push(seed_shape(
194                    shapes,
195                    stories,
196                    txn,
197                    slide_id,
198                    &seeded_child_path(path, child_index),
199                    child,
200                    theme,
201                )?);
202            }
203        }
204    }
205    shape_map.insert(txn, "textStories", string_array(&text_story_ids));
206    shape_map.insert(txn, "children", string_array(&child_ids));
207    Ok(shape_id)
208}
209
210fn seeded_slide_id(slide_index: usize, reference_id: u32) -> String {
211    format!("slide:{slide_index}:{reference_id}")
212}
213
214fn seeded_shape_id(slide_id: &str, path: &str) -> String {
215    format!("{slide_id}:shape:{path}")
216}
217
218fn seeded_child_path(path: &str, child_index: usize) -> String {
219    format!("{path}.{child_index}")
220}
221
222pub(crate) fn shape_base(shape: &ShapeNode) -> &ShapeBase {
223    match shape {
224        ShapeNode::Shape(shape) => &shape.base,
225        ShapeNode::Picture(shape) => &shape.base,
226        ShapeNode::GraphicFrame(shape) => &shape.base,
227        ShapeNode::Group(shape) => &shape.base,
228    }
229}
230
231fn insert_json<T: serde::Serialize>(
232    map: &MapRef,
233    txn: &mut TransactionMut<'_>,
234    key: &str,
235    value: Option<&T>,
236) -> EditResult<()> {
237    if let Some(value) = value {
238        let json =
239            serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
240        map.insert(txn, key, json);
241    }
242    Ok(())
243}
244
245impl DeckSession {
246    pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
247        snapshot_doc(&self.doc, &self.package)
248    }
249
250    /// Slide ids in deck order, matching `snapshot().slides` without walking shapes.
251    pub fn slide_ids(&self) -> EditResult<Vec<String>> {
252        let txn = self.doc.transact();
253        let order = required_order(&txn)?;
254        let slides = required_map(&txn, SLIDES)?;
255        let mut seen_slides = HashSet::new();
256        let mut ids = Vec::new();
257        for slide_id in string_array_ref(&order, &txn) {
258            if !seen_slides.insert(slide_id.clone()) {
259                continue;
260            }
261            if slides
262                .get(&txn, &slide_id)
263                .and_then(|value| value.cast::<MapRef>().ok())
264                .is_none()
265            {
266                return Err(EditError::InvalidState(format!("missing slide {slide_id}")));
267            }
268            ids.push(slide_id);
269        }
270        Ok(ids)
271    }
272
273    /// Snapshot one slide without materializing the rest of the deck.
274    pub fn slide_scope(&self, slide_index: usize) -> EditResult<SlideScope> {
275        slide_scope(&self.doc, &self.package, slide_index)
276    }
277
278    pub fn insert_slide(
279        &self,
280        context: &EditCtx,
281        index: u32,
282        layout_part_path: Option<&str>,
283    ) -> EditResult<SlideReceipt> {
284        if let Some(path) = layout_part_path
285            && !self
286                .package
287                .layouts
288                .iter()
289                .any(|layout| layout.part_path == path)
290        {
291            return Err(EditError::InvalidState(format!(
292                "unknown slide layout {path:?}"
293            )));
294        }
295        let slide_id = self.next_id("slide");
296        let mut txn = self.transact_for(context);
297        let order = required_order(&txn)?;
298        let length = order.len(&txn);
299        if index > length {
300            return Err(EditError::OutOfBounds { index, length });
301        }
302        let slides = required_map(&txn, SLIDES)?;
303        let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
304        slide.insert(&mut txn, "id", slide_id.as_str());
305        slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
306        if let Some(layout_part_path) = layout_part_path {
307            slide.insert(&mut txn, "layoutPartPath", layout_part_path);
308        }
309        slide.insert(&mut txn, "shapes", ArrayPrelim::default());
310        order.insert(&mut txn, index, slide_id.as_str());
311        Ok(SlideReceipt {
312            slide_id,
313            from_index: None,
314            to_index: Some(index),
315        })
316    }
317
318    pub fn set_slide_notes(&self, context: &EditCtx, slide_id: &str, text: &str) -> EditResult<()> {
319        crate::model::validate_xml_text(text)?;
320        self.automatic_undo_barrier();
321        let mut txn = self.transact_for(context);
322        let slide = slide_ref(&txn, slide_id)?;
323        slide.insert(&mut txn, "notes", text);
324        drop(txn);
325        self.automatic_undo_barrier();
326        Ok(())
327    }
328
329    pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
330        let mut txn = self.transact_for(context);
331        let order = required_order(&txn)?;
332        let index = array_index(&order, &txn, slide_id)
333            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
334        let slides = required_map(&txn, SLIDES)?;
335        let slide = slide_ref(&txn, slide_id)?;
336        let shape_order = slide_shape_order(&slide, &txn)?;
337        let shape_ids = live_shape_order(&shape_order, &txn)?;
338        remove_shape_entries(&mut txn, &shape_ids)?;
339        let comments = required_map(&txn, crate::COMMENTS)?;
340        let comment_ids: Vec<String> = comments
341            .iter(&txn)
342            .filter_map(|(id, value)| {
343                let entry = value.cast::<MapRef>().ok()?;
344                (map_string(&entry, &txn, "slideId").as_deref() == Some(slide_id))
345                    .then(|| id.to_owned())
346            })
347            .collect();
348        for id in comment_ids {
349            comments.remove(&mut txn, &id);
350        }
351        order.remove(&mut txn, index);
352        slides.remove(&mut txn, slide_id);
353        Ok(SlideReceipt {
354            slide_id: slide_id.to_owned(),
355            from_index: Some(index),
356            to_index: None,
357        })
358    }
359
360    pub fn move_slide(
361        &self,
362        context: &EditCtx,
363        slide_id: &str,
364        to_index: u32,
365    ) -> EditResult<SlideReceipt> {
366        let mut txn = self.transact_for(context);
367        let order = required_order(&txn)?;
368        let length = order.len(&txn);
369        if to_index >= length {
370            return Err(EditError::OutOfBounds {
371                index: to_index,
372                length,
373            });
374        }
375        let from_index = array_index(&order, &txn, slide_id)
376            .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
377        if from_index != to_index {
378            order.remove(&mut txn, from_index);
379            order.insert(&mut txn, to_index, slide_id);
380        }
381        Ok(SlideReceipt {
382            slide_id: slide_id.to_owned(),
383            from_index: Some(from_index),
384            to_index: Some(to_index),
385        })
386    }
387
388    pub fn add_text_box(
389        &self,
390        context: &EditCtx,
391        slide_id: &str,
392        draft: &ShapeDraft,
393    ) -> EditResult<ShapeReceipt> {
394        validate_rect(draft.rect)?;
395        crate::model::validate_xml_text(&draft.name)?;
396        crate::model::validate_xml_text(&draft.text)?;
397        crate::story::validate_style_values(
398            draft.style.font_family.as_deref(),
399            draft.style.underline.as_deref(),
400            draft.style.color.as_deref(),
401            draft.style.font_size_pt,
402            draft.style.spacing_pt,
403            draft.style.baseline_pct,
404        )?;
405        let shape_id = self.next_id("shape");
406        let story_id = format!("story:{shape_id}:0");
407        let paragraph_id = self.next_id("para");
408        let mut txn = self.transact_for(context);
409        let slide = slide_ref(&txn, slide_id)?;
410        let order = slide_shape_order(&slide, &txn)?;
411        let index = order.len(&txn);
412        let shapes = required_map(&txn, SHAPES)?;
413        let stories = required_map(&txn, STORIES)?;
414        seed_plain_story(
415            &stories,
416            &mut txn,
417            &story_id,
418            &paragraph_id,
419            &draft.text,
420            &draft.style,
421        );
422        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
423        shape.insert(&mut txn, "id", shape_id.as_str());
424        shape.insert(&mut txn, "sourceId", 0_f64);
425        shape.insert(&mut txn, "kind", "shape");
426        shape.insert(&mut txn, "name", draft.name.as_str());
427        shape.insert(&mut txn, "x", draft.rect.x as f64);
428        shape.insert(&mut txn, "y", draft.rect.y as f64);
429        shape.insert(&mut txn, "width", draft.rect.width as f64);
430        shape.insert(&mut txn, "height", draft.rect.height as f64);
431        shape.insert(&mut txn, "rotationDeg", 0_f64);
432        shape.insert(&mut txn, "flipH", false);
433        shape.insert(&mut txn, "flipV", false);
434        shape.insert(&mut txn, "geometry", "rect");
435        insert_json(
436            &shape,
437            &mut txn,
438            "fillJson",
439            Some(&ShapeFill::named("none")),
440        )?;
441        shape.insert(
442            &mut txn,
443            "textStories",
444            string_array(std::slice::from_ref(&story_id)),
445        );
446        shape.insert(&mut txn, "children", string_array(&[]));
447        order.push_back(&mut txn, shape_id.as_str());
448        Ok(ShapeReceipt {
449            slide_id: slide_id.to_owned(),
450            shape_id,
451            index,
452        })
453    }
454
455    pub fn add_shape(
456        &self,
457        context: &EditCtx,
458        slide_id: &str,
459        draft: &PresetShapeDraft,
460    ) -> EditResult<ShapeReceipt> {
461        validate_rect(draft.rect)?;
462        crate::model::validate_xml_text(&draft.name)?;
463        let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
464        if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none()
465            && ooxml_drawingml::preset_geometry_layers(
466                &draft.geometry,
467                &Default::default(),
468                aspect_ratio,
469            )
470            .is_none()
471        {
472            return Err(EditError::InvalidGeometry(format!(
473                "unsupported preset geometry {}",
474                draft.geometry
475            )));
476        }
477        let fill = shape_fill(draft.fill.as_deref())?;
478        let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
479            .into_iter()
480            .collect::<BTreeMap<_, _>>();
481        let shape_id = self.next_id("shape");
482        let mut txn = self.transact_for(context);
483        let slide = slide_ref(&txn, slide_id)?;
484        let order = slide_shape_order(&slide, &txn)?;
485        let index = order.len(&txn);
486        let shapes = required_map(&txn, SHAPES)?;
487        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
488        shape.insert(&mut txn, "id", shape_id.as_str());
489        shape.insert(&mut txn, "sourceId", 0_f64);
490        shape.insert(&mut txn, "kind", "shape");
491        shape.insert(&mut txn, "name", draft.name.as_str());
492        shape.insert(&mut txn, "x", draft.rect.x as f64);
493        shape.insert(&mut txn, "y", draft.rect.y as f64);
494        shape.insert(&mut txn, "width", draft.rect.width as f64);
495        shape.insert(&mut txn, "height", draft.rect.height as f64);
496        shape.insert(&mut txn, "rotationDeg", 0_f64);
497        shape.insert(&mut txn, "flipH", false);
498        shape.insert(&mut txn, "flipV", false);
499        shape.insert(&mut txn, "geometry", draft.geometry.as_str());
500        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&adjust_values))?;
501        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
502        shape.insert(&mut txn, "textStories", string_array(&[]));
503        shape.insert(&mut txn, "children", string_array(&[]));
504        order.push_back(&mut txn, shape_id.as_str());
505        Ok(ShapeReceipt {
506            slide_id: slide_id.to_owned(),
507            shape_id,
508            index,
509        })
510    }
511
512    /// Resolves a display-list image from the source package or shared edits.
513    pub fn media_bytes(&self, asset_id: &str) -> EditResult<Vec<u8>> {
514        if let Some(shape_id) = asset_id.strip_prefix("pending-media:") {
515            let txn = self.doc.transact();
516            let shape = shape_ref(&txn, shape_id)?;
517            let encoded = map_string(&shape, &txn, "pendingMediaBase64")
518                .ok_or_else(|| EditError::InvalidState("pending media was not found".to_owned()))?;
519            return base64::engine::general_purpose::STANDARD
520                .decode(encoded)
521                .map_err(|error| {
522                    EditError::InvalidState(format!("invalid pending image data: {error}"))
523                });
524        }
525        self.package()
526            .media
527            .iter()
528            .find(|media| media.part_path == asset_id)
529            .map(|media| media.bytes.clone())
530            .ok_or_else(|| EditError::InvalidState("media part was not found".to_owned()))
531    }
532
533    /// `save` mints the media part, content-type default and relationship.
534    pub fn add_picture(
535        &self,
536        context: &EditCtx,
537        slide_id: &str,
538        draft: &PictureDraft,
539    ) -> EditResult<ShapeReceipt> {
540        validate_rect(draft.rect)?;
541        crate::model::validate_xml_text(&draft.name)?;
542        if draft.media_bytes.is_empty() {
543            return Err(EditError::InvalidState(
544                "a picture needs image data".to_owned(),
545            ));
546        }
547        if draft.media_bytes.len() > MAX_PENDING_PICTURE_BYTES {
548            return Err(EditError::InvalidState(format!(
549                "image is {} bytes, exceeds the {MAX_PENDING_PICTURE_BYTES}-byte limit",
550                draft.media_bytes.len()
551            )));
552        }
553        if !pptx_parse::is_supported_image_content_type(&draft.content_type) {
554            return Err(EditError::InvalidState(format!(
555                "unsupported image type {:?}",
556                draft.content_type
557            )));
558        }
559        let shape_id = self.next_id("shape");
560        let mut txn = self.transact_for(context);
561        let slide = slide_ref(&txn, slide_id)?;
562        let order = slide_shape_order(&slide, &txn)?;
563        let index = order.len(&txn);
564        let shapes = required_map(&txn, SHAPES)?;
565        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
566        shape.insert(&mut txn, "id", shape_id.as_str());
567        shape.insert(&mut txn, "sourceId", 0_f64);
568        shape.insert(&mut txn, "kind", "picture");
569        shape.insert(&mut txn, "name", draft.name.as_str());
570        shape.insert(&mut txn, "x", draft.rect.x as f64);
571        shape.insert(&mut txn, "y", draft.rect.y as f64);
572        shape.insert(&mut txn, "width", draft.rect.width as f64);
573        shape.insert(&mut txn, "height", draft.rect.height as f64);
574        shape.insert(&mut txn, "rotationDeg", 0_f64);
575        shape.insert(&mut txn, "flipH", false);
576        shape.insert(&mut txn, "flipV", false);
577        shape.insert(&mut txn, "geometry", "rect");
578        shape.insert(
579            &mut txn,
580            "pendingMediaBase64",
581            base64::engine::general_purpose::STANDARD.encode(&draft.media_bytes),
582        );
583        shape.insert(
584            &mut txn,
585            "pendingMediaContentType",
586            draft.content_type.as_str(),
587        );
588        shape.insert(&mut txn, "textStories", string_array(&[]));
589        shape.insert(&mut txn, "children", string_array(&[]));
590        order.push_back(&mut txn, shape_id.as_str());
591        Ok(ShapeReceipt {
592            slide_id: slide_id.to_owned(),
593            shape_id,
594            index,
595        })
596    }
597
598    pub fn set_shape_fill(
599        &self,
600        context: &EditCtx,
601        slide_id: &str,
602        shape_id: &str,
603        color: Option<&str>,
604    ) -> EditResult<ShapeFillReceipt> {
605        let fill = shape_fill(color)?;
606        let mut txn = self.transact_for(context);
607        require_shape_membership(&txn, slide_id, shape_id)?;
608        let shape = shape_ref(&txn, shape_id)?;
609        require_shape_kind(&shape, &txn)?;
610        let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
611            .as_ref()
612            .and_then(fill_color);
613        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
614        Ok(ShapeFillReceipt {
615            slide_id: slide_id.to_owned(),
616            shape_id: shape_id.to_owned(),
617            before,
618            after: fill_color(&fill),
619        })
620    }
621
622    pub fn set_shape_stroke(
623        &self,
624        context: &EditCtx,
625        slide_id: &str,
626        shape_id: &str,
627        stroke: &ShapeStroke,
628    ) -> EditResult<ShapeStrokeReceipt> {
629        stroked_outline(None, stroke)?;
630        let mut txn = self.transact_for(context);
631        require_shape_membership(&txn, slide_id, shape_id)?;
632        let shape = shape_ref(&txn, shape_id)?;
633        require_shape_kind(&shape, &txn)?;
634        let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
635        let before = existing.as_ref().and_then(outline_stroke);
636        let outline = stroked_outline(existing, stroke)?;
637        insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
638        Ok(ShapeStrokeReceipt {
639            slide_id: slide_id.to_owned(),
640            shape_id: shape_id.to_owned(),
641            before,
642            after: outline_stroke(&outline),
643        })
644    }
645
646    pub fn set_shape_adjust(
647        &self,
648        context: &EditCtx,
649        slide_id: &str,
650        shape_id: &str,
651        adjustments: &BTreeMap<String, f64>,
652    ) -> EditResult<ShapeAdjustReceipt> {
653        validate_adjustments(adjustments)?;
654        let mut txn = self.transact_for(context);
655        require_shape_membership(&txn, slide_id, shape_id)?;
656        let shape = shape_ref(&txn, shape_id)?;
657        require_shape_kind(&shape, &txn)?;
658        let geometry = required_string(&shape, &txn, "geometry")?;
659        if geometry == "custom" {
660            return Err(EditError::InvalidGeometry(
661                "custom geometry does not support shape adjustments".to_owned(),
662            ));
663        }
664        if !map_bool(&shape, &txn, "hasPresetGeometry").unwrap_or(true) {
665            return Err(EditError::InvalidGeometry(
666                "shape without preset geometry does not support shape adjustments".to_owned(),
667            ));
668        }
669        let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
670        let mut after = BTreeMap::new();
671        for (name, value) in adjustments {
672            let maximum = if geometry == "roundRect" && name == "adj" {
673                0.5
674            } else {
675                1.0
676            };
677            after.insert(name.clone(), value.clamp(0.0, maximum));
678        }
679        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
680        Ok(ShapeAdjustReceipt {
681            slide_id: slide_id.to_owned(),
682            shape_id: shape_id.to_owned(),
683            before,
684            after,
685        })
686    }
687
688    pub fn remove_shape(
689        &self,
690        context: &EditCtx,
691        slide_id: &str,
692        shape_id: &str,
693    ) -> EditResult<ShapeReceipt> {
694        let mut txn = self.transact_for(context);
695        let slide = slide_ref(&txn, slide_id)?;
696        let order = slide_shape_order(&slide, &txn)?;
697        let ids = live_shape_order(&order, &txn)?;
698        let index =
699            ids.iter()
700                .position(|id| id == shape_id)
701                .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))? as u32;
702        remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
703        for (index, id) in string_array_ref(&order, &txn).iter().enumerate().rev() {
704            if id == shape_id {
705                order.remove(&mut txn, index as u32);
706            }
707        }
708        Ok(ShapeReceipt {
709            slide_id: slide_id.to_owned(),
710            shape_id: shape_id.to_owned(),
711            index,
712        })
713    }
714
715    pub fn move_shape(
716        &self,
717        context: &EditCtx,
718        slide_id: &str,
719        shape_id: &str,
720        x: i64,
721        y: i64,
722    ) -> EditResult<TransformReceipt> {
723        validate_coordinate(x)?;
724        validate_coordinate(y)?;
725        let mut txn = self.transact_for(context);
726        require_shape_membership(&txn, slide_id, shape_id)?;
727        let shape = shape_ref(&txn, shape_id)?;
728        self.materialize_inherited(&mut txn, slide_id, &shape)?;
729        let before = shape_rect(&shape, &txn)?;
730        shape.insert(&mut txn, "x", x as f64);
731        shape.insert(&mut txn, "y", y as f64);
732        Ok(TransformReceipt {
733            slide_id: slide_id.to_owned(),
734            shape_id: shape_id.to_owned(),
735            before,
736            after: ShapeRect { x, y, ..before },
737        })
738    }
739
740    /// Moves a shape to the top of its slide's paint order (drawn last).
741    pub fn bring_to_front(
742        &self,
743        context: &EditCtx,
744        slide_id: &str,
745        shape_id: &str,
746    ) -> EditResult<ShapeZOrderReceipt> {
747        self.reorder_shape(context, slide_id, shape_id, |length, _from| length - 1)
748    }
749
750    /// Moves a shape to the bottom of its slide's paint order (drawn first).
751    pub fn send_to_back(
752        &self,
753        context: &EditCtx,
754        slide_id: &str,
755        shape_id: &str,
756    ) -> EditResult<ShapeZOrderReceipt> {
757        self.reorder_shape(context, slide_id, shape_id, |_length, _from| 0)
758    }
759
760    /// Swaps a shape one step later in its slide's paint order.
761    pub fn bring_forward(
762        &self,
763        context: &EditCtx,
764        slide_id: &str,
765        shape_id: &str,
766    ) -> EditResult<ShapeZOrderReceipt> {
767        self.reorder_shape(context, slide_id, shape_id, |length, from| {
768            (from + 1).min(length - 1)
769        })
770    }
771
772    /// Swaps a shape one step earlier in its slide's paint order.
773    pub fn send_backward(
774        &self,
775        context: &EditCtx,
776        slide_id: &str,
777        shape_id: &str,
778    ) -> EditResult<ShapeZOrderReceipt> {
779        self.reorder_shape(context, slide_id, shape_id, |_length, from| {
780            from.saturating_sub(1)
781        })
782    }
783
784    fn reorder_shape(
785        &self,
786        context: &EditCtx,
787        slide_id: &str,
788        shape_id: &str,
789        to_index: impl FnOnce(u32, u32) -> u32,
790    ) -> EditResult<ShapeZOrderReceipt> {
791        let mut txn = self.transact_for(context);
792        let slide = slide_ref(&txn, slide_id)?;
793        let order = slide_shape_order(&slide, &txn)?;
794        let ids = live_shape_order(&order, &txn)?;
795        let length = ids.len() as u32;
796        let from_index =
797            ids.iter()
798                .position(|id| id == shape_id)
799                .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))? as u32;
800        let target = to_index(length, from_index).min(length - 1);
801        let mut seen = HashSet::new();
802        let live: HashSet<&str> = ids.iter().map(String::as_str).collect();
803        let stale: Vec<u32> = string_array_ref(&order, &txn)
804            .iter()
805            .enumerate()
806            .filter_map(|(index, id)| {
807                (!live.contains(id.as_str()) || !seen.insert(id.clone())).then_some(index as u32)
808            })
809            .collect();
810        for index in stale.into_iter().rev() {
811            order.remove(&mut txn, index);
812        }
813        if target != from_index {
814            order.remove(&mut txn, from_index);
815            order.insert(&mut txn, target, shape_id);
816        }
817        Ok(ShapeZOrderReceipt {
818            slide_id: slide_id.to_owned(),
819            shape_id: shape_id.to_owned(),
820            from_index,
821            to_index: target,
822        })
823    }
824
825    pub fn resize_shape(
826        &self,
827        context: &EditCtx,
828        slide_id: &str,
829        shape_id: &str,
830        width: i64,
831        height: i64,
832    ) -> EditResult<TransformReceipt> {
833        let rect = ShapeRect {
834            width,
835            height,
836            ..ShapeRect::default()
837        };
838        validate_rect(rect)?;
839        let mut txn = self.transact_for(context);
840        require_shape_membership(&txn, slide_id, shape_id)?;
841        let shape = shape_ref(&txn, shape_id)?;
842        self.materialize_inherited(&mut txn, slide_id, &shape)?;
843        let before = shape_rect(&shape, &txn)?;
844        shape.insert(&mut txn, "width", width as f64);
845        shape.insert(&mut txn, "height", height as f64);
846        Ok(TransformReceipt {
847            slide_id: slide_id.to_owned(),
848            shape_id: shape_id.to_owned(),
849            before,
850            after: ShapeRect {
851                width,
852                height,
853                ..before
854            },
855        })
856    }
857
858    pub fn set_shape_rect(
859        &self,
860        context: &EditCtx,
861        slide_id: &str,
862        shape_id: &str,
863        rect: ShapeRect,
864    ) -> EditResult<TransformReceipt> {
865        validate_rect(rect)?;
866        let mut txn = self.transact_for(context);
867        require_shape_membership(&txn, slide_id, shape_id)?;
868        let shape = shape_ref(&txn, shape_id)?;
869        self.materialize_inherited(&mut txn, slide_id, &shape)?;
870        let before = shape_rect(&shape, &txn)?;
871        shape.insert(&mut txn, "x", rect.x as f64);
872        shape.insert(&mut txn, "y", rect.y as f64);
873        shape.insert(&mut txn, "width", rect.width as f64);
874        shape.insert(&mut txn, "height", rect.height as f64);
875        Ok(TransformReceipt {
876            slide_id: slide_id.to_owned(),
877            shape_id: shape_id.to_owned(),
878            before,
879            after: rect,
880        })
881    }
882
883    /// Gives a placeholder without an extent the whole inherited transform, or,
884    /// beside a partial transform of its own, just the layout's extent, so a
885    /// geometry edit never leaves a zero extent behind.
886    fn materialize_inherited(
887        &self,
888        txn: &mut TransactionMut<'_>,
889        slide_id: &str,
890        shape: &MapRef,
891    ) -> EditResult<()> {
892        let rect = shape_rect(shape, txn)?;
893        if rect.width > 0 && rect.height > 0 {
894            return Ok(());
895        }
896        let placeholder: Option<Placeholder> = optional_json(shape, txn, "placeholderJson")?;
897        let source_id = required_u32(shape, txn, "sourceId")?;
898        let slide = slide_ref(txn, slide_id)?;
899        let source_part_path = map_string(&slide, txn, "sourcePartPath");
900        let layout_part_path = map_string(&slide, txn, "layoutPartPath");
901        let context = SlideContext::new(
902            &self.package,
903            source_part_path.as_deref(),
904            layout_part_path.as_deref(),
905        );
906        if let Some(transform) = inherited_transform(&context, source_id, placeholder.as_ref()) {
907            shape.insert(txn, "x", transform.x as f64);
908            shape.insert(txn, "y", transform.y as f64);
909            shape.insert(txn, "width", transform.width as f64);
910            shape.insert(txn, "height", transform.height as f64);
911            shape.insert(txn, "rotationDeg", transform.rotation_deg);
912            shape.insert(txn, "flipH", transform.flip_h);
913            shape.insert(txn, "flipV", transform.flip_v);
914        } else if let Some(transform) = placeholder
915            .as_ref()
916            .and_then(|placeholder| layout_transform(&context, placeholder))
917        {
918            shape.insert(txn, "width", transform.width as f64);
919            shape.insert(txn, "height", transform.height as f64);
920        }
921        Ok(())
922    }
923}
924
925pub(crate) fn validate_doc(doc: &Doc) -> EditResult<(PptxPackage, DeckSnapshot)> {
926    let (package, snapshot) = validate_doc_impl(doc, None)?;
927    Ok((package.expect("package_from_meta decodes"), snapshot))
928}
929
930/// Validates the doc and returns the snapshot it computed internally.
931/// Passing the session's parsed package skips decoding `meta.packageJson`.
932pub(crate) fn validated_snapshot(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
933    validate_doc_impl(doc, Some(package)).map(|(_, snapshot)| snapshot)
934}
935
936fn validate_doc_impl(
937    doc: &Doc,
938    package: Option<&PptxPackage>,
939) -> EditResult<(Option<PptxPackage>, DeckSnapshot)> {
940    let mut owned = None;
941    let package = {
942        let txn = doc.transact();
943        let meta = required_map(&txn, META)?;
944        validate_schema_version(&meta, &txn)?;
945        if map_string(&meta, &txn, "fingerprint").is_none() {
946            return Err(EditError::InvalidState("missing fingerprint".to_owned()));
947        }
948        match package {
949            Some(package) => {
950                if !matches!(
951                    meta.get(&txn, "packageJson"),
952                    Some(Out::Any(Any::Buffer(_)))
953                ) {
954                    return Err(EditError::InvalidState("missing package data".to_owned()));
955                }
956                package
957            }
958            None => {
959                owned = Some(package_from_meta(&meta, &txn)?);
960                owned.as_ref().unwrap()
961            }
962        }
963    };
964    let snapshot = snapshot_doc(doc, package)?;
965    if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
966        return Err(EditError::InvalidState(
967            "slide dimensions must be positive".to_owned(),
968        ));
969    }
970    let txn = doc.transact();
971    let stories = required_map(&txn, STORIES)?;
972    for (story_id, value) in stories.iter(&txn) {
973        let story = value
974            .cast::<TextRef>()
975            .map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
976        validate_story(&story, &txn, story_id)?;
977    }
978    Ok((owned, snapshot))
979}
980
981/// Shared state for the pending-source import passes run by
982/// [`DeckSession::open_from_update_with_source`]: one deserialization of the
983/// doc's package plus the seeded source snapshot built at most once.
984pub(crate) struct SourceImport<'a> {
985    /// The source package the update was seeded from.
986    pub(crate) source: &'a PptxPackage,
987    /// The doc's package, mutated by each pass and synced back once.
988    pub(crate) package: PptxPackage,
989    source_snapshot: Option<DeckSnapshot>,
990    pub(crate) recovery: crate::source_run_properties::RecoveryCache,
991}
992
993impl<'a> SourceImport<'a> {
994    pub(crate) fn new(package: PptxPackage, source: &'a PptxPackage) -> Self {
995        Self {
996            source,
997            package,
998            source_snapshot: None,
999            recovery: Default::default(),
1000        }
1001    }
1002
1003    /// A seeded snapshot of `source`, built once and reused across passes.
1004    pub(crate) fn source_snapshot(&mut self) -> EditResult<&DeckSnapshot> {
1005        if self.source_snapshot.is_none() {
1006            self.source_snapshot = Some(baseline_snapshot(self.source)?);
1007        }
1008        Ok(self.source_snapshot.as_ref().unwrap())
1009    }
1010
1011    /// Writes the accumulated package back iff an import changed it.
1012    pub(crate) fn sync_package_json(self, doc: &Doc, baseline: &PptxPackage) -> EditResult<()> {
1013        if &self.package == baseline {
1014            return Ok(());
1015        }
1016        let bytes = serde_json::to_vec(&self.package)
1017            .map_err(|error| EditError::Json(error.to_string()))?;
1018        let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1019        let meta = required_map(&txn, META)?;
1020        meta.insert(&mut txn, "packageJson", Any::Buffer(Arc::from(bytes)));
1021        Ok(())
1022    }
1023}
1024
1025#[cfg(test)]
1026pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
1027    let txn = doc.transact();
1028    let meta = required_map(&txn, META)?;
1029    validate_schema_version(&meta, &txn)?;
1030    package_from_meta(&meta, &txn)
1031}
1032
1033pub(crate) fn import_source_render_data(
1034    doc: &Doc,
1035    import: &mut SourceImport<'_>,
1036) -> EditResult<()> {
1037    let source = import.source;
1038    let package = &mut import.package;
1039    let sources = source
1040        .slides
1041        .iter()
1042        .map(|part| (&part.part_path, &part.shapes))
1043        .chain(
1044            source
1045                .layouts
1046                .iter()
1047                .map(|part| (&part.part_path, &part.shapes)),
1048        )
1049        .chain(
1050            source
1051                .masters
1052                .iter()
1053                .map(|part| (&part.part_path, &part.shapes)),
1054        )
1055        .collect::<HashMap<_, _>>();
1056    let mut changed = false;
1057    for (path, shapes) in package
1058        .slides
1059        .iter_mut()
1060        .map(|part| (&part.part_path, &mut part.shapes))
1061        .chain(
1062            package
1063                .layouts
1064                .iter_mut()
1065                .map(|part| (&part.part_path, &mut part.shapes)),
1066        )
1067        .chain(
1068            package
1069                .masters
1070                .iter_mut()
1071                .map(|part| (&part.part_path, &mut part.shapes)),
1072        )
1073    {
1074        if let Some(source) = sources.get(path) {
1075            changed |= merge_source_render_shapes(shapes, source);
1076        }
1077    }
1078    for master in &mut package.masters {
1079        if let Some(source) = source
1080            .masters
1081            .iter()
1082            .find(|source| source.part_path == master.part_path)
1083        {
1084            let target = &mut master.text_styles;
1085            let source = &source.text_styles;
1086            for (target, source) in target
1087                .title
1088                .iter_mut()
1089                .zip(&source.title)
1090                .chain(target.body.iter_mut().zip(&source.body))
1091                .chain(target.other.iter_mut().zip(&source.other))
1092            {
1093                changed |= merge_source_paragraph_properties(target, source);
1094            }
1095        }
1096    }
1097    for chart in &mut package.charts {
1098        if let Some(source) = source
1099            .charts
1100            .iter()
1101            .find(|source| source.part_path == chart.part_path)
1102        {
1103            changed |= merge_source_chart_properties(&mut chart.chart, &source.chart);
1104        }
1105    }
1106    for slide in &mut package.slides {
1107        if let Some(source) = source
1108            .slides
1109            .iter()
1110            .find(|source| source.part_path == slide.part_path)
1111            && slide.notes != source.notes
1112        {
1113            slide.notes.clone_from(&source.notes);
1114            changed = true;
1115        }
1116    }
1117    if package.table_styles != source.table_styles {
1118        package.table_styles.clone_from(&source.table_styles);
1119        changed = true;
1120    }
1121    if !changed {
1122        return Ok(());
1123    }
1124    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1125    backfill_blip_effects(&mut txn, package)?;
1126    backfill_tables(&mut txn, package)?;
1127    Ok(())
1128}
1129
1130fn merge_source_render_shapes(target: &mut [ShapeNode], source: &[ShapeNode]) -> bool {
1131    let mut changed = false;
1132    for source in source {
1133        let Some(target) = target
1134            .iter_mut()
1135            .find(|target| shape_base(target).id == shape_base(source).id)
1136        else {
1137            continue;
1138        };
1139        match (target, source) {
1140            (ShapeNode::Shape(target), ShapeNode::Shape(source)) => {
1141                changed |= target.picture_fill != source.picture_fill;
1142                target.picture_fill.clone_from(&source.picture_fill);
1143                if let (Some(target), Some(source)) = (&mut target.text, &source.text) {
1144                    changed |= merge_source_text_body(target, source);
1145                }
1146            }
1147            (ShapeNode::Picture(target), ShapeNode::Picture(source)) => {
1148                if target.effects.is_empty() && !source.effects.is_empty() {
1149                    target.effects.clone_from(&source.effects);
1150                    changed = true;
1151                }
1152            }
1153            (ShapeNode::Group(target), ShapeNode::Group(source)) => {
1154                changed |= merge_source_render_shapes(&mut target.children, &source.children);
1155            }
1156            (ShapeNode::GraphicFrame(target), ShapeNode::GraphicFrame(source)) => {
1157                if matches!(
1158                    target.data,
1159                    pptx_parse::GraphicFrameData::Unknown { picture: None, .. }
1160                ) && matches!(
1161                    source.data,
1162                    pptx_parse::GraphicFrameData::Unknown {
1163                        picture: Some(_),
1164                        ..
1165                    }
1166                ) {
1167                    target.data.clone_from(&source.data);
1168                    changed = true;
1169                }
1170                if let (
1171                    pptx_parse::GraphicFrameData::Table(target),
1172                    pptx_parse::GraphicFrameData::Table(source),
1173                ) = (&mut target.data, &source.data)
1174                {
1175                    changed |= merge_source_table(target, source);
1176                }
1177            }
1178            _ => {}
1179        }
1180    }
1181    changed
1182}
1183
1184/// Copies the chart-space fill, axis lines, series lines and text properties a
1185/// source chart part declares.
1186fn merge_source_chart_properties(target: &mut ChartSpace, source: &ChartSpace) -> bool {
1187    let mut changed = target.fill != source.fill;
1188    target.fill.clone_from(&source.fill);
1189    changed |= target.text != source.text || target.title_text != source.title_text;
1190    target.text.clone_from(&source.text);
1191    target.title_text.clone_from(&source.title_text);
1192    if let (Some(target), Some(source)) = (&mut target.legend, &source.legend) {
1193        changed |= target.text != source.text;
1194        target.text.clone_from(&source.text);
1195    }
1196    for (target, source) in target.plot_groups.iter_mut().zip(&source.plot_groups) {
1197        for (target, source) in target.series.iter_mut().zip(&source.series) {
1198            changed |= target.line != source.line;
1199            target.line.clone_from(&source.line);
1200        }
1201    }
1202    let mut merge_axis = |target: Option<&mut ChartAxis>, source: Option<&ChartAxis>| {
1203        if let (Some(target), Some(source)) = (target, source) {
1204            changed |= target.line != source.line || target.text != source.text;
1205            target.line.clone_from(&source.line);
1206            target.text.clone_from(&source.text);
1207        }
1208    };
1209    if let (Some(target), Some(source)) = (&mut target.axes, &source.axes) {
1210        merge_axis(target.category.as_mut(), source.category.as_ref());
1211        merge_axis(target.value.as_mut(), source.value.as_ref());
1212    }
1213    if let (Some(target), Some(source)) = (&mut target.axis_list, &source.axis_list) {
1214        for (target, source) in target.iter_mut().zip(source) {
1215            merge_axis(Some(target), Some(source));
1216        }
1217    }
1218    changed
1219}
1220
1221fn backfill_tables(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1222    let shapes = required_map(txn, SHAPES)?;
1223    for (index, (slide, reference)) in package
1224        .slides
1225        .iter()
1226        .zip(&package.presentation.slides)
1227        .enumerate()
1228    {
1229        let slide_id = seeded_slide_id(index, reference.id);
1230        for (index, node) in slide.shapes.iter().enumerate() {
1231            backfill_table(txn, &shapes, &slide_id, &index.to_string(), node)?;
1232        }
1233    }
1234    Ok(())
1235}
1236
1237fn backfill_table(
1238    txn: &mut TransactionMut<'_>,
1239    shapes: &MapRef,
1240    slide_id: &str,
1241    path: &str,
1242    node: &ShapeNode,
1243) -> EditResult<()> {
1244    if let ShapeNode::GraphicFrame(frame) = node
1245        && let pptx_parse::GraphicFrameData::Table(table) = &frame.data
1246        && !table.grid.is_empty()
1247        && let Some(shape) = shapes
1248            .get(txn, &seeded_shape_id(slide_id, path))
1249            .and_then(|value| value.cast::<MapRef>().ok())
1250        && matches!(
1251            optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
1252            Some(pptx_parse::GraphicFrameData::Table(stored)) if stored.grid.is_empty()
1253        )
1254    {
1255        insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
1256    }
1257    if let ShapeNode::Group(group) = node {
1258        for (index, child) in group.children.iter().enumerate() {
1259            backfill_table(
1260                txn,
1261                shapes,
1262                slide_id,
1263                &seeded_child_path(path, index),
1264                child,
1265            )?;
1266        }
1267    }
1268    Ok(())
1269}
1270
1271/// Restores the grid, spans and cell formatting a stored package dropped.
1272fn merge_source_table(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
1273    let mut changed = target.grid != source.grid || target.properties != source.properties;
1274    target.grid.clone_from(&source.grid);
1275    target.properties.clone_from(&source.properties);
1276    for (target, source) in target.rows.iter_mut().zip(&source.rows) {
1277        changed |= target.height != source.height;
1278        target.height = source.height;
1279        for (target, source) in target.cells.iter_mut().zip(&source.cells) {
1280            changed |= target.grid_span != source.grid_span
1281                || target.row_span != source.row_span
1282                || target.merged != source.merged
1283                || target.fill != source.fill
1284                || target.borders != source.borders;
1285            target.grid_span = source.grid_span;
1286            target.row_span = source.row_span;
1287            target.merged = source.merged;
1288            target.fill.clone_from(&source.fill);
1289            target.borders.clone_from(&source.borders);
1290            changed |= merge_source_text_body(&mut target.text, &source.text);
1291        }
1292    }
1293    changed
1294}
1295
1296fn merge_source_text_body(
1297    target: &mut pptx_parse::TextBody,
1298    source: &pptx_parse::TextBody,
1299) -> bool {
1300    let mut changed = target.list_style != source.list_style
1301        || target.default_list_style != source.default_list_style
1302        || target.compat_line_spacing != source.compat_line_spacing
1303        || target.vertical_overflow != source.vertical_overflow
1304        || target.horizontal_overflow != source.horizontal_overflow;
1305    target.vertical_overflow = source.vertical_overflow;
1306    target.horizontal_overflow = source.horizontal_overflow;
1307    target.compat_line_spacing = source.compat_line_spacing;
1308    target.list_style.clone_from(&source.list_style);
1309    target
1310        .default_list_style
1311        .clone_from(&source.default_list_style);
1312    for (target, source) in target.paragraphs.iter_mut().zip(&source.paragraphs) {
1313        changed |= merge_source_paragraph_properties(&mut target.properties, &source.properties);
1314    }
1315    changed
1316}
1317
1318fn merge_source_paragraph_properties(
1319    target: &mut pptx_parse::ParagraphProperties,
1320    source: &pptx_parse::ParagraphProperties,
1321) -> bool {
1322    let mut changed = target.bullet_font != source.bullet_font
1323        || target.bullet_color != source.bullet_color
1324        || target.bullet_size != source.bullet_size
1325        || target.line_spacing != source.line_spacing
1326        || target.margin_right != source.margin_right
1327        || target.space_before != source.space_before
1328        || target.space_after != source.space_after
1329        || target.default_tab_size != source.default_tab_size
1330        || target.tab_stops != source.tab_stops;
1331    target.line_spacing = source.line_spacing;
1332    target.margin_right = source.margin_right;
1333    target.space_before = source.space_before;
1334    target.space_after = source.space_after;
1335    target.default_tab_size = source.default_tab_size;
1336    target.tab_stops.clone_from(&source.tab_stops);
1337    if let (
1338        Some(pptx_parse::Bullet::AutoNumber {
1339            restart: target, ..
1340        }),
1341        Some(pptx_parse::Bullet::AutoNumber {
1342            restart: source, ..
1343        }),
1344    ) = (&mut target.bullet, &source.bullet)
1345    {
1346        changed |= *target != *source;
1347        *target = *source;
1348    }
1349    target.bullet_font.clone_from(&source.bullet_font);
1350    target.bullet_color.clone_from(&source.bullet_color);
1351    target.bullet_size.clone_from(&source.bullet_size);
1352    changed
1353}
1354
1355pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
1356    let txn = doc.transact();
1357    let meta = required_map(&txn, META)?;
1358    map_string(&meta, &txn, "fingerprint")
1359        .ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
1360}
1361
1362/// Carries a released 1.0, 2.0 or 2.1 document forward to the current schema.
1363pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
1364    let version = {
1365        let txn = doc.transact();
1366        let meta = required_map(&txn, META)?;
1367        schema_version(&meta, &txn)?
1368    };
1369    let package = if version < 2.1 {
1370        migrate_doc_to_v2_1(doc)?
1371    } else if version < SCHEMA_VERSION {
1372        migrate_doc_to_v2_2(doc)?
1373    } else {
1374        return Ok(());
1375    };
1376    restore_preset_defaults(doc, &package)
1377}
1378
1379/// Rewrites the stored package so media bytes ride as base64 strings rather
1380/// than the integer arrays 2.1 wrote, and defers the 2.1 seed's runs.
1381fn migrate_doc_to_v2_2(doc: &Doc) -> EditResult<PptxPackage> {
1382    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1383    let meta = required_map(&txn, META)?;
1384    let package = package_from_meta(&meta, &txn)?;
1385    let package_json =
1386        serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1387    meta.insert(
1388        &mut txn,
1389        "packageJson",
1390        Any::Buffer(Arc::from(package_json)),
1391    );
1392    defer_legacy_runs(&mut txn, &meta);
1393    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1394    Ok(package)
1395}
1396
1397/// Defers to the source the run caps a pre-2.2 seed lacked and the run colours
1398/// it resolved without the slide colour map.
1399fn defer_legacy_runs(txn: &mut TransactionMut<'_>, meta: &MapRef) {
1400    meta.insert(txn, "capsPendingSource", true);
1401    meta.insert(txn, "colorsPendingSource", true);
1402}
1403
1404/// Moves the preset adjust defaults a pre-2.2 seed stored onto the current
1405/// ones. Runs after the schema change commits, so the rewritten package the
1406/// seed view below would otherwise copy has been collected.
1407fn restore_preset_defaults(doc: &Doc, package: &PptxPackage) -> EditResult<()> {
1408    let seeded = seeded_adjustments(doc)?;
1409    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1410    let shapes = required_map(&txn, SHAPES)?;
1411    for (index, (slide, reference)) in package
1412        .slides
1413        .iter()
1414        .zip(&package.presentation.slides)
1415        .enumerate()
1416    {
1417        let slide_id = seeded_slide_id(index, reference.id);
1418        for (index, node) in slide.shapes.iter().enumerate() {
1419            restore_shape_defaults(
1420                &mut txn,
1421                &shapes,
1422                &seeded,
1423                &slide_id,
1424                &index.to_string(),
1425                node,
1426            )?;
1427        }
1428    }
1429    Ok(())
1430}
1431
1432/// The shapes whose `adjustValuesJson` is still the value the seed wrote: the
1433/// seed's own items, with every later deletion or overwrite applied.
1434fn seeded_adjustments(doc: &Doc) -> EditResult<HashSet<String>> {
1435    let bootstrap = ClientID::new(crate::BOOTSTRAP_CLIENT_ID);
1436    let update = {
1437        let txn = doc.transact();
1438        let mut others = StateVector::default();
1439        for (client, clock) in txn.state_vector().iter() {
1440            if *client != bootstrap {
1441                others.set_max(*client, *clock);
1442            }
1443        }
1444        txn.encode_state_as_update_v1(&others)
1445    };
1446    let view = Doc::new();
1447    let mut txn = view.transact_mut();
1448    txn.apply_update(
1449        Update::decode_v1(&update).map_err(|error| EditError::InvalidUpdate(error.to_string()))?,
1450    )
1451    .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
1452    let Some(shapes) = txn.get_map(SHAPES) else {
1453        return Ok(HashSet::new());
1454    };
1455    Ok(shapes
1456        .iter(&txn)
1457        .filter(|(_, shape)| {
1458            shape
1459                .clone()
1460                .cast::<MapRef>()
1461                .is_ok_and(|shape| shape.contains_key(&txn, "adjustValuesJson"))
1462        })
1463        .map(|(id, _)| id.to_owned())
1464        .collect())
1465}
1466
1467/// Replaces each adjust value the seed stored that is still a pre-2.2 default
1468/// the source never authored with the current default.
1469fn restore_shape_defaults(
1470    txn: &mut TransactionMut<'_>,
1471    shapes: &MapRef,
1472    seeded: &HashSet<String>,
1473    slide_id: &str,
1474    path: &str,
1475    node: &ShapeNode,
1476) -> EditResult<()> {
1477    match node {
1478        ShapeNode::Shape(source) => {
1479            let id = seeded_shape_id(slide_id, path);
1480            let Some(shape) = seeded
1481                .contains(&id)
1482                .then(|| shapes.get(txn, &id))
1483                .flatten()
1484                .and_then(|value| value.cast::<MapRef>().ok())
1485            else {
1486                return Ok(());
1487            };
1488            let legacy: BTreeMap<_, _> = legacy_preset_defaults(&source.geometry)
1489                .iter()
1490                .copied()
1491                .collect();
1492            let current = preset_geometry_default_adjustments(&source.geometry);
1493            let mut values: BTreeMap<String, f64> =
1494                optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default();
1495            let mut changed = false;
1496            for name in legacy
1497                .keys()
1498                .copied()
1499                .chain(current.keys().map(String::as_str))
1500            {
1501                let (before, after) = (legacy.get(name).copied(), current.get(name).copied());
1502                if before == after
1503                    || source.adjust_values.contains_key(name)
1504                    || values.get(name).copied() != before
1505                {
1506                    continue;
1507                }
1508                match after {
1509                    Some(value) => values.insert(name.to_owned(), value),
1510                    None => values.remove(name),
1511                };
1512                changed = true;
1513            }
1514            if changed {
1515                insert_json(&shape, txn, "adjustValuesJson", Some(&values))?;
1516            }
1517        }
1518        ShapeNode::Group(group) => {
1519            for (index, child) in group.children.iter().enumerate() {
1520                restore_shape_defaults(
1521                    txn,
1522                    shapes,
1523                    seeded,
1524                    slide_id,
1525                    &seeded_child_path(path, index),
1526                    child,
1527                )?;
1528            }
1529        }
1530        _ => {}
1531    }
1532    Ok(())
1533}
1534
1535/// The preset adjust defaults seeds before schema 2.2 stored.
1536fn legacy_preset_defaults(geometry: &str) -> &'static [(&'static str, f64)] {
1537    match geometry {
1538        "roundRect" => &[("adj", 0.166_67)],
1539        "plus" | "parallelogram" | "hexagon" => &[("adj", 0.25)],
1540        "triangle" | "isosTriangle" | "chevron" | "homePlate" => &[("adj", 0.5)],
1541        "trapezoid" => &[("adj", 0.2)],
1542        "octagon" => &[("adj", 0.292_89)],
1543        "rightArrow" | "leftArrow" | "upArrow" | "downArrow" => &[("adj1", 0.5), ("adj2", 0.5)],
1544        "star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16"
1545        | "star24" | "star32" => &[("adj", 0.45)],
1546        _ => &[],
1547    }
1548}
1549
1550/// Applies every schema change made since 2.0 in one transaction: the package is
1551/// rewritten through the current model, hidden flags and bitmap effects are
1552/// backfilled, the comment flavour is recorded, and everything a stored package
1553/// cannot carry -- baselines, outline gradients, character spacing, caps,
1554/// colour-mapped run colours, OLE picture previews, chart and paragraph
1555/// properties, table geometry -- is deferred to [`import_source_render_data`]
1556/// until the source is reattached.
1557fn migrate_doc_to_v2_1(doc: &Doc) -> EditResult<PptxPackage> {
1558    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1559    let meta = required_map(&txn, META)?;
1560    let package = package_from_meta(&meta, &txn)?;
1561    let package_json =
1562        serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1563    meta.insert(
1564        &mut txn,
1565        "packageJson",
1566        Any::Buffer(Arc::from(package_json)),
1567    );
1568    backfill_hidden(&mut txn, &package)?;
1569    record_comment_flavor(&mut txn, &meta, &package);
1570    meta.insert(&mut txn, "baselinesPendingSource", true);
1571    meta.insert(&mut txn, "outlineGradientsPendingSource", true);
1572    backfill_blip_effects(&mut txn, &package)?;
1573    backfill_tables(&mut txn, &package)?;
1574    meta.insert(&mut txn, "spacingPendingSource", true);
1575    if package_needs_ole_source(&package) {
1576        meta.insert(&mut txn, "olePicturesPendingSource", true);
1577    }
1578    defer_legacy_runs(&mut txn, &meta);
1579    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1580    Ok(package)
1581}
1582
1583fn record_comment_flavor(txn: &mut TransactionMut<'_>, meta: &MapRef, package: &PptxPackage) {
1584    if meta.contains_key(txn, "commentFlavor") {
1585        return;
1586    }
1587    if !package.comments.is_empty()
1588        || package
1589            .relationships
1590            .values()
1591            .flatten()
1592            .any(|relationship| {
1593                relationship.is_type(pptx_parse::relationship_types::COMMENTS)
1594                    || relationship.is_type(pptx_parse::relationship_types::MODERN_COMMENTS)
1595            })
1596    {
1597        meta.insert(txn, "commentsPendingSource", true);
1598    }
1599    meta.insert(
1600        txn,
1601        "commentFlavor",
1602        flavor_key(package.comment_flavor.unwrap_or_default()),
1603    );
1604}
1605
1606fn backfill_blip_effects(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1607    let shapes = required_map(txn, SHAPES)?;
1608    for (index, (slide, reference)) in package
1609        .slides
1610        .iter()
1611        .zip(&package.presentation.slides)
1612        .enumerate()
1613    {
1614        let slide_id = seeded_slide_id(index, reference.id);
1615        for (index, node) in slide.shapes.iter().enumerate() {
1616            backfill_picture_effects(txn, &shapes, &slide_id, &index.to_string(), node)?;
1617        }
1618    }
1619    Ok(())
1620}
1621
1622fn backfill_picture_effects(
1623    txn: &mut TransactionMut<'_>,
1624    shapes: &MapRef,
1625    slide_id: &str,
1626    path: &str,
1627    node: &ShapeNode,
1628) -> EditResult<()> {
1629    match node {
1630        ShapeNode::Picture(picture) if !picture.effects.is_empty() => {
1631            let id = seeded_shape_id(slide_id, path);
1632            if let Some(shape) = shapes
1633                .get(txn, &id)
1634                .and_then(|shape| shape.cast::<MapRef>().ok())
1635                && !shape.contains_key(txn, "blipEffectsJson")
1636            {
1637                insert_json(&shape, txn, "blipEffectsJson", Some(&picture.effects))?;
1638            }
1639        }
1640        ShapeNode::Group(group) => {
1641            for (index, child) in group.children.iter().enumerate() {
1642                backfill_picture_effects(
1643                    txn,
1644                    shapes,
1645                    slide_id,
1646                    &seeded_child_path(path, index),
1647                    child,
1648                )?;
1649            }
1650        }
1651        _ => {}
1652    }
1653    Ok(())
1654}
1655
1656fn package_needs_ole_source(package: &PptxPackage) -> bool {
1657    package
1658        .slides
1659        .iter()
1660        .any(|part| needs_ole_source(&part.shapes))
1661        || package
1662            .layouts
1663            .iter()
1664            .any(|part| needs_ole_source(&part.shapes))
1665        || package
1666            .masters
1667            .iter()
1668            .any(|part| needs_ole_source(&part.shapes))
1669}
1670
1671fn needs_ole_source(nodes: &[ShapeNode]) -> bool {
1672    nodes.iter().any(|node| match node {
1673        ShapeNode::GraphicFrame(frame) => matches!(&frame.data,
1674            pptx_parse::GraphicFrameData::Unknown { uri: Some(uri), picture: None }
1675                if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole"),
1676        ShapeNode::Group(group) => needs_ole_source(&group.children),
1677        _ => false,
1678    })
1679}
1680
1681pub(crate) fn import_source_ole_pictures(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
1682    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1683    let meta = required_map(&txn, META)?;
1684    if map_bool(&meta, &txn, "olePicturesPendingSource") != Some(true) {
1685        return Ok(());
1686    }
1687    let shapes = required_map(&txn, SHAPES)?;
1688    for (index, (slide, reference)) in source
1689        .slides
1690        .iter()
1691        .zip(&source.presentation.slides)
1692        .enumerate()
1693    {
1694        let slide_id = seeded_slide_id(index, reference.id);
1695        for (index, node) in slide.shapes.iter().enumerate() {
1696            backfill_ole_picture(&mut txn, &shapes, &slide_id, &index.to_string(), node)?;
1697        }
1698    }
1699    meta.remove(&mut txn, "olePicturesPendingSource");
1700    Ok(())
1701}
1702
1703fn backfill_ole_picture(
1704    txn: &mut TransactionMut<'_>,
1705    shapes: &MapRef,
1706    slide_id: &str,
1707    path: &str,
1708    node: &ShapeNode,
1709) -> EditResult<()> {
1710    if let ShapeNode::GraphicFrame(frame) = node
1711        && matches!(
1712            frame.data,
1713            pptx_parse::GraphicFrameData::Unknown {
1714                picture: Some(_),
1715                ..
1716            }
1717        )
1718        && let Some(shape) = shapes
1719            .get(txn, &seeded_shape_id(slide_id, path))
1720            .and_then(|value| value.cast::<MapRef>().ok())
1721        && matches!(
1722            optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
1723            Some(pptx_parse::GraphicFrameData::Unknown { picture: None, .. })
1724        )
1725    {
1726        insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
1727    }
1728    if let ShapeNode::Group(group) = node {
1729        for (index, child) in group.children.iter().enumerate() {
1730            backfill_ole_picture(
1731                txn,
1732                shapes,
1733                slide_id,
1734                &seeded_child_path(path, index),
1735                child,
1736            )?;
1737        }
1738    }
1739    Ok(())
1740}
1741
1742fn backfill_hidden(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1743    let shapes = required_map(txn, SHAPES)?;
1744    for shape_id in seeded_hidden_shape_ids(package) {
1745        if let Some(shape) = shapes
1746            .get(txn, &shape_id)
1747            .and_then(|value| value.cast::<MapRef>().ok())
1748        {
1749            shape.insert(txn, "hidden", true);
1750        }
1751    }
1752    Ok(())
1753}
1754
1755fn seeded_hidden_shape_ids(package: &PptxPackage) -> Vec<String> {
1756    let mut ids = Vec::new();
1757    for (slide_index, (slide, reference)) in package
1758        .slides
1759        .iter()
1760        .zip(&package.presentation.slides)
1761        .enumerate()
1762    {
1763        let slide_id = seeded_slide_id(slide_index, reference.id);
1764        for (shape_index, shape) in slide.shapes.iter().enumerate() {
1765            collect_hidden_shape_ids(&slide_id, &shape_index.to_string(), shape, &mut ids);
1766        }
1767    }
1768    ids
1769}
1770
1771fn collect_hidden_shape_ids(slide_id: &str, path: &str, shape: &ShapeNode, ids: &mut Vec<String>) {
1772    if shape_base(shape).hidden {
1773        ids.push(seeded_shape_id(slide_id, path));
1774    }
1775    if let ShapeNode::Group(group) = shape {
1776        for (child_index, child) in group.children.iter().enumerate() {
1777            collect_hidden_shape_ids(slide_id, &seeded_child_path(path, child_index), child, ids);
1778        }
1779    }
1780}
1781
1782fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
1783    map_number(meta, txn, "schemaVersion")
1784        .filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
1785        .ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
1786}
1787
1788fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
1789    schema_version(meta, txn).map(|_| ())
1790}
1791
1792fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
1793    let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
1794        return Err(EditError::InvalidState("missing package data".to_owned()));
1795    };
1796    serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
1797}
1798
1799pub(crate) fn live_shape_order<T: ReadTxn>(order: &ArrayRef, txn: &T) -> EditResult<Vec<String>> {
1800    let shapes = required_map(txn, SHAPES)?;
1801    let mut seen = HashSet::new();
1802    Ok(string_array_ref(order, txn)
1803        .into_iter()
1804        .filter(|id| shapes.contains_key(txn, id) && seen.insert(id.clone()))
1805        .collect())
1806}
1807
1808fn snapshot_slide<T: ReadTxn>(
1809    slides: &MapRef,
1810    shapes: &MapRef,
1811    stories: &MapRef,
1812    package: &PptxPackage,
1813    txn: &T,
1814    slide_id: &str,
1815) -> EditResult<SlideSnapshot> {
1816    let SlideParts {
1817        mut snapshot,
1818        shape_ids,
1819        theme,
1820    } = slide_parts(slides, package, txn, slide_id)?;
1821    let slide = slide_ref(txn, slide_id)?;
1822    snapshot.notes = slide_notes(&slide, txn, package);
1823    for shape_id in shape_ids {
1824        snapshot.shapes.push(snapshot_shape(
1825            shapes,
1826            stories,
1827            txn,
1828            &shape_id,
1829            &mut HashSet::new(),
1830            Some(&theme),
1831        )?);
1832    }
1833    record_inherited(
1834        &mut snapshot.shapes,
1835        &SlideContext::new(
1836            package,
1837            snapshot.source_part_path.as_deref(),
1838            snapshot.layout_part_path.as_deref(),
1839        ),
1840    );
1841    Ok(snapshot)
1842}
1843
1844/// A slide's own fields, without its notes or shapes, and the ids of its live top-level shapes.
1845pub(crate) struct SlideParts {
1846    pub snapshot: SlideSnapshot,
1847    pub shape_ids: Vec<String>,
1848    pub theme: Theme,
1849}
1850
1851pub(crate) fn slide_parts<T: ReadTxn>(
1852    slides: &MapRef,
1853    package: &PptxPackage,
1854    txn: &T,
1855    slide_id: &str,
1856) -> EditResult<SlideParts> {
1857    let slide = slides
1858        .get(txn, slide_id)
1859        .and_then(|value| value.cast::<MapRef>().ok())
1860        .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
1861    let source_part_path = map_string(&slide, txn, "sourcePartPath");
1862    let layout_part_path = map_string(&slide, txn, "layoutPartPath");
1863    let theme = pptx_parse::slide_theme(
1864        package,
1865        source_part_path.as_deref(),
1866        layout_part_path.as_deref(),
1867    );
1868    let shape_order = slide_shape_order(&slide, txn)?;
1869    Ok(SlideParts {
1870        shape_ids: live_shape_order(&shape_order, txn)?,
1871        snapshot: SlideSnapshot {
1872            id: slide_id.to_owned(),
1873            source_part_path,
1874            layout_part_path,
1875            name: map_string(&slide, txn, "name"),
1876            notes: String::new(),
1877            shapes: Vec::new(),
1878        },
1879        theme,
1880    })
1881}
1882
1883pub(crate) fn slide_scope(
1884    doc: &Doc,
1885    package: &PptxPackage,
1886    slide_index: usize,
1887) -> EditResult<SlideScope> {
1888    let txn = doc.transact();
1889    let meta = required_map(&txn, META)?;
1890    let order = required_order(&txn)?;
1891    let slides = required_map(&txn, SLIDES)?;
1892    let shapes = required_map(&txn, SHAPES)?;
1893    let stories = required_map(&txn, STORIES)?;
1894    let mut seen_slides = HashSet::new();
1895    let mut position = 0usize;
1896    let mut slide_id = None;
1897    for id in string_array_ref(&order, &txn) {
1898        if !seen_slides.insert(id.clone()) {
1899            continue;
1900        }
1901        if position == slide_index {
1902            slide_id = Some(id);
1903            break;
1904        }
1905        position += 1;
1906    }
1907    let slide_id = slide_id.ok_or(EditError::OutOfBounds {
1908        index: slide_index.min(u32::MAX as usize) as u32,
1909        length: seen_slides.len() as u32,
1910    })?;
1911    Ok(SlideScope {
1912        index: slide_index,
1913        slide: snapshot_slide(&slides, &shapes, &stories, package, &txn, &slide_id)?,
1914        width_emu: required_i64(&meta, &txn, "widthEmu")?,
1915        height_emu: required_i64(&meta, &txn, "heightEmu")?,
1916    })
1917}
1918
1919pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
1920    let txn = doc.transact();
1921    let meta = required_map(&txn, META)?;
1922    let order = required_order(&txn)?;
1923    let slides = required_map(&txn, SLIDES)?;
1924    let shapes = required_map(&txn, SHAPES)?;
1925    let stories = required_map(&txn, STORIES)?;
1926    let mut seen_slides = HashSet::new();
1927    let mut slide_snapshots = Vec::new();
1928    for slide_id in string_array_ref(&order, &txn) {
1929        if !seen_slides.insert(slide_id.clone()) {
1930            continue;
1931        }
1932        slide_snapshots.push(snapshot_slide(
1933            &slides, &shapes, &stories, package, &txn, &slide_id,
1934        )?);
1935    }
1936    Ok(DeckSnapshot {
1937        width_emu: required_i64(&meta, &txn, "widthEmu")?,
1938        height_emu: required_i64(&meta, &txn, "heightEmu")?,
1939        slides: slide_snapshots,
1940        comment_flavor: snapshot_flavor(&txn)?,
1941        comments: snapshot_comments(&txn)?,
1942    })
1943}
1944
1945pub(crate) fn slide_notes<T: ReadTxn>(slide: &MapRef, txn: &T, package: &PptxPackage) -> String {
1946    if let Some(notes) = map_string(slide, txn, "notes") {
1947        return notes;
1948    }
1949    let source_part_path = map_string(slide, txn, "sourcePartPath");
1950    package
1951        .slides
1952        .iter()
1953        .find(|source| Some(&source.part_path) == source_part_path.as_ref())
1954        .map(|source| source.notes.clone())
1955        .unwrap_or_default()
1956}
1957
1958pub(crate) fn snapshot_shape<T: ReadTxn>(
1959    shapes: &MapRef,
1960    stories: &MapRef,
1961    txn: &T,
1962    shape_id: &str,
1963    visiting: &mut HashSet<String>,
1964    theme: Option<&Theme>,
1965) -> EditResult<ShapeSnapshot> {
1966    if visiting.len() >= MAX_SHAPE_DEPTH {
1967        return Err(EditError::InvalidState(format!(
1968            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
1969        )));
1970    }
1971    if !visiting.insert(shape_id.to_owned()) {
1972        return Err(EditError::InvalidState(format!(
1973            "shape cycle at {shape_id}"
1974        )));
1975    }
1976    let ShapeParts {
1977        mut snapshot,
1978        story_ids,
1979        child_ids,
1980    } = shape_parts(shapes, txn, shape_id, theme)?;
1981    for story_id in story_ids {
1982        let story = stories
1983            .get(txn, &story_id)
1984            .and_then(|value| value.cast::<TextRef>().ok())
1985            .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
1986        snapshot
1987            .text_stories
1988            .push(snapshot_story(&story, txn, &story_id)?);
1989    }
1990    for child_id in child_ids {
1991        snapshot.children.push(snapshot_shape(
1992            shapes, stories, txn, &child_id, visiting, theme,
1993        )?);
1994    }
1995    visiting.remove(shape_id);
1996    let shape = shape_ref(txn, shape_id)?;
1997    snapshot.graphic = optional_json(&shape, txn, "graphicJson")?;
1998    snapshot.pending_media = match (
1999        map_string(&shape, txn, "pendingMediaBase64"),
2000        map_string(&shape, txn, "pendingMediaContentType"),
2001    ) {
2002        (Some(base64), Some(content_type)) => Some(PendingMedia {
2003            content_type,
2004            base64,
2005        }),
2006        _ => None,
2007    };
2008    Ok(snapshot)
2009}
2010
2011/// A shape's own fields, without its stories, children, graphic payload or unsaved image data,
2012/// and the ids of its stories and children.
2013pub(crate) struct ShapeParts {
2014    pub snapshot: ShapeSnapshot,
2015    pub story_ids: Vec<String>,
2016    pub child_ids: Vec<String>,
2017}
2018
2019pub(crate) fn shape_parts<T: ReadTxn>(
2020    shapes: &MapRef,
2021    txn: &T,
2022    shape_id: &str,
2023    theme: Option<&Theme>,
2024) -> EditResult<ShapeParts> {
2025    let shape = shapes
2026        .get(txn, shape_id)
2027        .and_then(|value| value.cast::<MapRef>().ok())
2028        .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
2029    let story_ids = map_string_array(&shape, txn, "textStories")?;
2030    let child_ids = map_string_array(&shape, txn, "children")?;
2031    let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
2032    let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
2033    let resolved_fill_color = fill
2034        .as_ref()
2035        .filter(|fill| fill.fill_type != "none")
2036        .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
2037    let resolved_outline_color = outline
2038        .as_ref()
2039        .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
2040    let snapshot = ShapeSnapshot {
2041        id: shape_id.to_owned(),
2042        source_id: required_u32(&shape, txn, "sourceId")?,
2043        kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
2044        name: required_string(&shape, txn, "name")?,
2045        x: required_i64(&shape, txn, "x")?,
2046        y: required_i64(&shape, txn, "y")?,
2047        width: required_i64(&shape, txn, "width")?,
2048        height: required_i64(&shape, txn, "height")?,
2049        rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
2050        flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
2051        flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
2052        inherited: None,
2053        hidden: map_bool(&shape, txn, "hidden").unwrap_or_default(),
2054        geometry: required_string(&shape, txn, "geometry")?,
2055        adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
2056        placeholder: optional_json(&shape, txn, "placeholderJson")?,
2057        fill,
2058        resolved_fill_color,
2059        outline,
2060        resolved_outline_color,
2061        media_part_path: map_string(&shape, txn, "mediaPartPath"),
2062        pending_media: None,
2063        blip_effects: optional_json(&shape, txn, "blipEffectsJson")?.unwrap_or_default(),
2064        graphic: None,
2065        text_stories: Vec::new(),
2066        children: Vec::new(),
2067    };
2068    Ok(ShapeParts {
2069        snapshot,
2070        story_ids,
2071        child_ids,
2072    })
2073}
2074
2075/// The seed state `snapshot_doc` reads back for `package`, computed without
2076/// materializing a scratch document. `save` diffs the live doc against this.
2077pub(crate) fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
2078    seeded_snapshot(package, true)
2079}
2080
2081/// [`baseline_snapshot`] as seeds before schema 2.2 resolved run colours:
2082/// without the slide colour map.
2083pub(crate) fn legacy_baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
2084    seeded_snapshot(package, false)
2085}
2086
2087fn seeded_snapshot(package: &PptxPackage, color_map: bool) -> EditResult<DeckSnapshot> {
2088    let mut slide_id_by_part = HashMap::new();
2089    let mut slides = Vec::with_capacity(package.slides.len());
2090    for (slide_index, slide) in package.slides.iter().enumerate() {
2091        let slide_id = seeded_slide_id(
2092            slide_index,
2093            package
2094                .presentation
2095                .slides
2096                .get(slide_index)
2097                .ok_or_else(|| {
2098                    EditError::InvalidState(format!(
2099                        "missing slide reference for slide {slide_index}"
2100                    ))
2101                })?
2102                .id,
2103        );
2104        slide_id_by_part.insert(slide.part_path.clone(), slide_id.clone());
2105        slides.push(baseline_slide(package, slide, slide_id, color_map)?);
2106    }
2107    Ok(DeckSnapshot {
2108        width_emu: baseline_integer("widthEmu", package.presentation.width_emu)?,
2109        height_emu: baseline_integer("heightEmu", package.presentation.height_emu)?,
2110        slides,
2111        comment_flavor: package.comment_flavor.unwrap_or_default(),
2112        comments: baseline_comments(package, &|part| slide_id_by_part.get(part).cloned()),
2113    })
2114}
2115
2116fn baseline_slide(
2117    package: &PptxPackage,
2118    slide: &Slide,
2119    slide_id: String,
2120    color_map: bool,
2121) -> EditResult<SlideSnapshot> {
2122    let mut theme = pptx_parse::slide_theme(
2123        package,
2124        Some(&slide.part_path),
2125        slide.layout_part_path.as_deref(),
2126    );
2127    if !color_map {
2128        theme.color_map = ColorMap::default();
2129    }
2130    let mut shapes = Vec::with_capacity(slide.shapes.len());
2131    for (shape_index, shape) in slide.shapes.iter().enumerate() {
2132        shapes.push(baseline_shape(
2133            &slide_id,
2134            &shape_index.to_string(),
2135            shape,
2136            Some(&theme),
2137        )?);
2138    }
2139    record_inherited(
2140        &mut shapes,
2141        &SlideContext::new(
2142            package,
2143            Some(&slide.part_path),
2144            slide.layout_part_path.as_deref(),
2145        ),
2146    );
2147    Ok(SlideSnapshot {
2148        id: slide_id,
2149        source_part_path: Some(slide.part_path.clone()),
2150        layout_part_path: slide.layout_part_path.clone(),
2151        name: slide.name.clone(),
2152        notes: slide.notes.clone(),
2153        shapes,
2154    })
2155}
2156
2157fn baseline_shape(
2158    slide_id: &str,
2159    path: &str,
2160    shape: &ShapeNode,
2161    theme: Option<&Theme>,
2162) -> EditResult<ShapeSnapshot> {
2163    let shape_id = seeded_shape_id(slide_id, path);
2164    let base = shape_base(shape);
2165    let transform = &base.transform;
2166    let kind;
2167    let geometry;
2168    let mut text_stories = Vec::new();
2169    let mut children = Vec::new();
2170    let mut adjust_values = BTreeMap::new();
2171    let mut fill = None;
2172    let mut outline = None;
2173    let mut media_part_path = None;
2174    let mut blip_effects = Vec::new();
2175    let mut graphic = None;
2176    match shape {
2177        ShapeNode::Shape(shape) => {
2178            kind = ShapeKind::Shape;
2179            geometry = shape.geometry.clone();
2180            let mut values = preset_geometry_default_adjustments(&shape.geometry)
2181                .into_iter()
2182                .collect::<BTreeMap<_, _>>();
2183            values.extend(shape.adjust_values.clone());
2184            adjust_values = baseline_json(&values)?;
2185            if let Some(body) = &shape.text {
2186                text_stories.push(baseline_story(&format!("story:{shape_id}:0"), body, theme)?);
2187            }
2188            fill = baseline_json_option(&shape.fill)?;
2189            outline = baseline_json_option(&shape.outline)?;
2190        }
2191        ShapeNode::Picture(picture) => {
2192            kind = ShapeKind::Picture;
2193            geometry = "rect".to_owned();
2194            fill = baseline_json_option(&picture.fill)?;
2195            outline = baseline_json_option(&picture.outline)?;
2196            if !picture.effects.is_empty() {
2197                blip_effects = baseline_json(&picture.effects)?;
2198            }
2199            media_part_path = picture.media_part_path.clone();
2200        }
2201        ShapeNode::GraphicFrame(frame) => {
2202            kind = ShapeKind::GraphicFrame;
2203            geometry = "rect".to_owned();
2204            graphic = Some(baseline_json(&frame.data)?);
2205            if let GraphicFrameData::Table(table) = &frame.data {
2206                for (row_index, row) in table.rows.iter().enumerate() {
2207                    for (cell_index, cell) in row.cells.iter().enumerate() {
2208                        text_stories.push(baseline_story(
2209                            &format!("story:{shape_id}:table:{row_index}:{cell_index}"),
2210                            &cell.text,
2211                            theme,
2212                        )?);
2213                    }
2214                }
2215            }
2216        }
2217        ShapeNode::Group(group) => {
2218            kind = ShapeKind::Group;
2219            geometry = "group".to_owned();
2220            for (child_index, child) in group.children.iter().enumerate() {
2221                children.push(baseline_shape(
2222                    slide_id,
2223                    &seeded_child_path(path, child_index),
2224                    child,
2225                    theme,
2226                )?);
2227            }
2228        }
2229    }
2230    let resolved_fill_color = fill
2231        .as_ref()
2232        .filter(|fill| fill.fill_type != "none")
2233        .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
2234    let resolved_outline_color = outline
2235        .as_ref()
2236        .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
2237    Ok(ShapeSnapshot {
2238        id: shape_id,
2239        source_id: base.id,
2240        kind,
2241        name: base.name.clone(),
2242        x: baseline_integer("x", transform.x)?,
2243        y: baseline_integer("y", transform.y)?,
2244        width: baseline_integer("width", transform.width)?,
2245        height: baseline_integer("height", transform.height)?,
2246        rotation_deg: if transform.rotation_deg.is_finite() {
2247            transform.rotation_deg
2248        } else {
2249            0.0
2250        },
2251        flip_h: transform.flip_h,
2252        flip_v: transform.flip_v,
2253        inherited: None,
2254        hidden: base.hidden,
2255        geometry,
2256        adjust_values,
2257        placeholder: baseline_json_option(&base.placeholder)?,
2258        fill,
2259        resolved_fill_color,
2260        outline,
2261        resolved_outline_color,
2262        media_part_path,
2263        pending_media: None,
2264        blip_effects,
2265        graphic,
2266        text_stories,
2267        children,
2268    })
2269}
2270
2271/// `required_i64` semantics for a value seeded as `f64`.
2272fn baseline_integer(key: &str, value: i64) -> EditResult<i64> {
2273    let number = value as f64;
2274    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
2275        return Err(EditError::InvalidState(format!("invalid integer {key}")));
2276    }
2277    Ok(number as i64)
2278}
2279
2280/// `insert_json` + `optional_json` without the document: seeded fields arrive
2281/// in the baseline snapshot JSON-normalized.
2282fn baseline_json<T: serde::Serialize + DeserializeOwned>(value: &T) -> EditResult<T> {
2283    let json = serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
2284    serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
2285}
2286
2287fn baseline_json_option<T: serde::Serialize + DeserializeOwned>(
2288    value: &Option<T>,
2289) -> EditResult<Option<T>> {
2290    value.as_ref().map(baseline_json).transpose()
2291}
2292
2293fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
2294    match value {
2295        "shape" => Ok(ShapeKind::Shape),
2296        "picture" => Ok(ShapeKind::Picture),
2297        "graphicFrame" => Ok(ShapeKind::GraphicFrame),
2298        "group" => Ok(ShapeKind::Group),
2299        _ => Err(EditError::InvalidState(format!(
2300            "unknown shape kind {value}"
2301        ))),
2302    }
2303}
2304
2305pub(crate) fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
2306    txn.get_array(SLIDE_ORDER)
2307        .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
2308}
2309
2310pub(crate) fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
2311    txn.get_map(name)
2312        .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
2313}
2314
2315pub(crate) fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
2316    required_map(txn, SLIDES)?
2317        .get(txn, slide_id)
2318        .and_then(|value| value.cast::<MapRef>().ok())
2319        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
2320}
2321
2322pub(crate) fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
2323    required_map(txn, SHAPES)?
2324        .get(txn, shape_id)
2325        .and_then(|value| value.cast::<MapRef>().ok())
2326        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
2327}
2328
2329fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
2330    if required_string(shape, txn, "kind")? == "shape" {
2331        Ok(())
2332    } else {
2333        Err(EditError::InvalidGeometry(
2334            "only preset shapes support shape styling".to_owned(),
2335        ))
2336    }
2337}
2338
2339pub(crate) fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
2340    slide
2341        .get(txn, "shapes")
2342        .and_then(|value| value.cast::<ArrayRef>().ok())
2343        .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
2344}
2345
2346fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
2347    let slide = slide_ref(txn, slide_id)?;
2348    let order = slide_shape_order(&slide, txn)?;
2349    if array_index(&order, txn, shape_id).is_some() {
2350        Ok(())
2351    } else {
2352        Err(EditError::ShapeNotFound(shape_id.to_owned()))
2353    }
2354}
2355
2356fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
2357    let shapes = required_map(txn, SHAPES)?;
2358    let stories = required_map(txn, STORIES)?;
2359    let mut entries = ShapeEntries::default();
2360    for shape_id in root_shape_ids {
2361        collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
2362    }
2363    for story_id in entries.story_ids {
2364        stories.remove(txn, &story_id);
2365    }
2366    for shape_id in entries.shape_ids {
2367        shapes.remove(txn, &shape_id);
2368    }
2369    Ok(())
2370}
2371
2372#[derive(Default)]
2373struct ShapeEntries {
2374    shape_ids: Vec<String>,
2375    seen_shape_ids: HashSet<String>,
2376    story_ids: Vec<String>,
2377    seen_story_ids: HashSet<String>,
2378}
2379
2380fn collect_shape_entries<T: ReadTxn>(
2381    shapes: &MapRef,
2382    txn: &T,
2383    shape_id: &str,
2384    visiting: &mut HashSet<String>,
2385    entries: &mut ShapeEntries,
2386) -> EditResult<()> {
2387    if visiting.len() >= MAX_SHAPE_DEPTH {
2388        return Err(EditError::InvalidState(format!(
2389            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
2390        )));
2391    }
2392    if !visiting.insert(shape_id.to_owned()) {
2393        return Err(EditError::InvalidState(format!(
2394            "shape cycle at {shape_id}"
2395        )));
2396    }
2397    if entries.seen_shape_ids.contains(shape_id) {
2398        visiting.remove(shape_id);
2399        return Ok(());
2400    }
2401    let shape = shapes
2402        .get(txn, shape_id)
2403        .and_then(|value| value.cast::<MapRef>().ok())
2404        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
2405    for story_id in map_string_array(&shape, txn, "textStories")? {
2406        if entries.seen_story_ids.insert(story_id.clone()) {
2407            entries.story_ids.push(story_id);
2408        }
2409    }
2410    for child_id in map_string_array(&shape, txn, "children")? {
2411        collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
2412    }
2413    visiting.remove(shape_id);
2414    entries.seen_shape_ids.insert(shape_id.to_owned());
2415    entries.shape_ids.push(shape_id.to_owned());
2416    Ok(())
2417}
2418
2419fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
2420    array
2421        .iter(txn)
2422        .enumerate()
2423        .find(|(_, item)| out_string(item).as_deref() == Some(value))
2424        .map(|(index, _)| index as u32)
2425}
2426
2427pub(crate) fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
2428    array
2429        .iter(txn)
2430        .filter_map(|value| out_string(&value))
2431        .collect()
2432}
2433
2434pub(crate) fn map_string_array<T: ReadTxn>(
2435    map: &MapRef,
2436    txn: &T,
2437    key: &str,
2438) -> EditResult<Vec<String>> {
2439    match map.get(txn, key) {
2440        Some(Out::Any(Any::Array(values))) => Ok(values
2441            .iter()
2442            .filter_map(|value| match value {
2443                Any::String(value) => Some(value.to_string()),
2444                _ => None,
2445            })
2446            .collect()),
2447        None => Ok(Vec::new()),
2448        _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
2449    }
2450}
2451
2452fn string_array(values: &[String]) -> Any {
2453    Any::Array(Arc::from(
2454        values
2455            .iter()
2456            .map(|value| Any::from(value.as_str()))
2457            .collect::<Vec<_>>(),
2458    ))
2459}
2460
2461fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
2462    Ok(ShapeRect {
2463        x: required_i64(shape, txn, "x")?,
2464        y: required_i64(shape, txn, "y")?,
2465        width: required_i64(shape, txn, "width")?,
2466        height: required_i64(shape, txn, "height")?,
2467    })
2468}
2469
2470fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
2471    let value = required_i64(map, txn, key)?;
2472    u32::try_from(value).map_err(|_| {
2473        EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
2474    })
2475}
2476
2477pub(crate) fn validate_rect(rect: ShapeRect) -> EditResult<()> {
2478    validate_coordinate(rect.x)?;
2479    validate_coordinate(rect.y)?;
2480    if rect.width <= 0 || rect.height <= 0 {
2481        return Err(EditError::InvalidGeometry(
2482            "shape width and height must be positive".to_owned(),
2483        ));
2484    }
2485    validate_coordinate(rect.width)?;
2486    validate_coordinate(rect.height)
2487}
2488
2489fn validate_coordinate(value: i64) -> EditResult<()> {
2490    if value.unsigned_abs() > MAX_GEOMETRY as u64 {
2491        return Err(EditError::InvalidGeometry(format!(
2492            "coordinate {value} exceeds the safe range"
2493        )));
2494    }
2495    Ok(())
2496}
2497
2498fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
2499    if adjustments.len() > MAX_ADJUSTMENTS {
2500        return Err(EditError::InvalidAdjustment(format!(
2501            "at most {MAX_ADJUSTMENTS} values are allowed"
2502        )));
2503    }
2504    for (name, value) in adjustments {
2505        if !valid_adjustment_name(name) {
2506            return Err(EditError::InvalidAdjustment(format!(
2507                "unrecognized guide name {name:?}"
2508            )));
2509        }
2510        if !value.is_finite() {
2511            return Err(EditError::InvalidAdjustment(format!(
2512                "guide {name:?} must be finite"
2513            )));
2514        }
2515    }
2516    Ok(())
2517}
2518
2519fn valid_adjustment_name(name: &str) -> bool {
2520    if name == "adj" {
2521        return true;
2522    }
2523    let Some(index) = name.strip_prefix("adj") else {
2524        return false;
2525    };
2526    index
2527        .parse::<usize>()
2528        .ok()
2529        .filter(|value| value.to_string() == index)
2530        .is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
2531}
2532
2533pub(crate) fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
2534    Ok(match color {
2535        Some(color) => ShapeFill {
2536            fill_type: "solid".to_owned(),
2537            color: Some(color_value(color)?),
2538            gradient: None,
2539        },
2540        None => ShapeFill::named("none"),
2541    })
2542}
2543
2544/// The outline a stroke leaves on a shape outlined by `existing`, after checking the stroke.
2545pub(crate) fn stroked_outline(
2546    existing: Option<ShapeOutline>,
2547    stroke: &ShapeStroke,
2548) -> EditResult<ShapeOutline> {
2549    let color = stroke.color.as_deref().map(color_value).transpose()?;
2550    if let Some(width) = stroke.width_pt
2551        && (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
2552    {
2553        return Err(EditError::InvalidGeometry(format!(
2554            "stroke width {width}pt is outside the safe range"
2555        )));
2556    }
2557    if stroke.color.is_none() && stroke.width_pt.is_none() {
2558        return Ok(ShapeOutline::default());
2559    }
2560    let mut outline = existing.unwrap_or_default();
2561    if let Some(color) = color {
2562        if outline.width.is_none() {
2563            outline.width = Some(EMU_PER_POINT);
2564        }
2565        outline.color = Some(color);
2566        outline.gradient = None;
2567    } else if outline.color.is_none() && outline.gradient.is_none() {
2568        outline.color = Some(color_value("#000000")?);
2569    }
2570    if let Some(width) = stroke.width_pt {
2571        outline.width = Some(width * EMU_PER_POINT);
2572    }
2573    Ok(outline)
2574}
2575
2576fn fill_color(fill: &ShapeFill) -> Option<String> {
2577    (fill.fill_type != "none")
2578        .then(|| resolve_color_value_to_hex(fill.color.as_ref()))
2579        .flatten()
2580}
2581
2582fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
2583    Some(ShapeStroke {
2584        color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
2585        width_pt: outline.width.map(|width| width / EMU_PER_POINT),
2586    })
2587}
2588
2589fn color_value(color: &str) -> EditResult<ColorValue> {
2590    let rgb = color.strip_prefix('#').unwrap_or(color);
2591    if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2592        return Err(EditError::InvalidGeometry(format!(
2593            "color {color:?} must be a six-digit hex value"
2594        )));
2595    }
2596    Ok(ColorValue {
2597        rgb: Some(rgb.to_ascii_uppercase()),
2598        ..ColorValue::default()
2599    })
2600}
2601
2602fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
2603    map_string(map, txn, key)
2604        .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
2605}
2606
2607/// A shape's stored graphic payload as JSON text, shared rather than copied.
2608pub(crate) fn graphic_json<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<Option<Arc<str>>> {
2609    Ok(match shape_ref(txn, shape_id)?.get(txn, "graphicJson") {
2610        Some(Out::Any(Any::String(json))) => Some(json),
2611        _ => None,
2612    })
2613}
2614
2615pub(crate) fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
2616    map.get(txn, key).and_then(|value| out_string(&value))
2617}
2618
2619fn out_string(value: &Out) -> Option<String> {
2620    match value {
2621        Out::Any(Any::String(value)) => Some(value.to_string()),
2622        _ => None,
2623    }
2624}
2625
2626fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
2627    let number = map_number(map, txn, key)
2628        .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
2629    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
2630        return Err(EditError::InvalidState(format!("invalid integer {key}")));
2631    }
2632    Ok(number as i64)
2633}
2634
2635pub(crate) fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
2636    match map.get(txn, key) {
2637        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
2638        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
2639        _ => None,
2640    }
2641}
2642
2643pub(crate) fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
2644    match map.get(txn, key) {
2645        Some(Out::Any(Any::Bool(value))) => Some(value),
2646        _ => None,
2647    }
2648}
2649
2650fn optional_json<T: DeserializeOwned, R: ReadTxn>(
2651    map: &MapRef,
2652    txn: &R,
2653    key: &str,
2654) -> EditResult<Option<T>> {
2655    map_string(map, txn, key)
2656        .map(|json| {
2657            serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
2658        })
2659        .transpose()
2660}
2661
2662#[cfg(test)]
2663mod tests {
2664    use super::*;
2665    use crate::TextStyle;
2666
2667    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
2668    const HIDDEN_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/hidden-shapes.pptx");
2669
2670    #[test]
2671    fn a_reattached_source_restores_the_series_lines_a_stored_package_lacks() {
2672        let source = ChartSpace {
2673            plot_groups: vec![pptx_parse::ChartPlotGroup {
2674                series: vec![pptx_parse::ChartSeries {
2675                    line: Some(ooxml_drawingml::chart::ChartLine {
2676                        none: false,
2677                        color: Some("#FFFFFF".to_owned()),
2678                        width_emu: Some(41275.0),
2679                    }),
2680                    ..pptx_parse::ChartSeries::default()
2681                }],
2682                ..pptx_parse::ChartPlotGroup::default()
2683            }],
2684            ..ChartSpace::default()
2685        };
2686        let mut target = source.clone();
2687        target.plot_groups[0].series[0].line = None;
2688
2689        assert!(merge_source_chart_properties(&mut target, &source));
2690        assert_eq!(target, source);
2691        assert!(!merge_source_chart_properties(&mut target, &source));
2692    }
2693
2694    #[test]
2695    fn a_reattached_source_restores_the_right_margin_a_stored_package_lacks() {
2696        let source = pptx_parse::ParagraphProperties {
2697            margin_right: Some(914_400),
2698            ..pptx_parse::ParagraphProperties::default()
2699        };
2700        let mut target = pptx_parse::ParagraphProperties::default();
2701
2702        assert!(merge_source_paragraph_properties(&mut target, &source));
2703        assert_eq!(target.margin_right, Some(914_400));
2704        assert!(!merge_source_paragraph_properties(&mut target, &source));
2705    }
2706
2707    #[test]
2708    fn a_reattached_source_restores_the_paragraph_spacing_a_stored_package_lacks() {
2709        let source = pptx_parse::ParagraphProperties {
2710            space_before: Some(pptx_parse::LineSpacing::Points { value: 10.0 }),
2711            space_after: Some(pptx_parse::LineSpacing::Percent { value: 0.2 }),
2712            ..pptx_parse::ParagraphProperties::default()
2713        };
2714        let mut target = pptx_parse::ParagraphProperties::default();
2715
2716        assert!(merge_source_paragraph_properties(&mut target, &source));
2717        assert_eq!(target.space_before, source.space_before);
2718        assert_eq!(target.space_after, source.space_after);
2719        assert!(!merge_source_paragraph_properties(&mut target, &source));
2720    }
2721
2722    #[test]
2723    fn attaching_a_source_refreshes_chart_text_a_stored_package_never_carried() {
2724        use ooxml_drawingml::chart::{ChartLegend, ChartTextProperties};
2725
2726        let styled = ChartTextProperties {
2727            spacing_pt: Some(3.0),
2728            ..ChartTextProperties::default()
2729        };
2730        let source = ChartSpace {
2731            text: Some(styled.clone()),
2732            title_text: Some(styled.clone()),
2733            legend: Some(ChartLegend {
2734                visible: true,
2735                text: Some(styled.clone()),
2736                ..ChartLegend::default()
2737            }),
2738            axis_list: Some(vec![ChartAxis {
2739                text: Some(styled.clone()),
2740                ..ChartAxis::default()
2741            }]),
2742            ..ChartSpace::default()
2743        };
2744        let mut target = ChartSpace {
2745            legend: Some(ChartLegend {
2746                visible: true,
2747                ..ChartLegend::default()
2748            }),
2749            axis_list: Some(vec![ChartAxis::default()]),
2750            ..ChartSpace::default()
2751        };
2752        assert!(merge_source_chart_properties(&mut target, &source));
2753        assert_eq!(target.text.as_ref(), Some(&styled));
2754        assert_eq!(target.title_text.as_ref(), Some(&styled));
2755        assert_eq!(target.legend.unwrap().text.as_ref(), Some(&styled));
2756        assert_eq!(target.axis_list.unwrap()[0].text.as_ref(), Some(&styled));
2757        assert!(!merge_source_chart_properties(&mut source.clone(), &source));
2758    }
2759
2760    #[test]
2761    fn an_unmigratable_schema_version_is_reported_before_package_json() {
2762        let session = DeckSession::open(FIXTURE, 100).unwrap();
2763        {
2764            let mut txn = session.doc.transact_mut();
2765            let meta = required_map(&txn, META).unwrap();
2766            meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
2767            meta.insert(
2768                &mut txn,
2769                "packageJson",
2770                Any::Buffer(Arc::from(b"{}".to_vec())),
2771            );
2772        }
2773
2774        assert!(matches!(
2775            validate_doc(&session.doc),
2776            Err(EditError::InvalidState(message))
2777                if message == "unsupported deck schema version"
2778        ));
2779        assert!(matches!(
2780            DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
2781            Err(EditError::InvalidState(message))
2782                if message == "unsupported deck schema version"
2783        ));
2784    }
2785
2786    #[test]
2787    fn migration_leaves_a_current_document_untouched() {
2788        let session = DeckSession::open(FIXTURE, 102).unwrap();
2789        let before = session.encode_state_as_update_v1();
2790        migrate_doc(&session.doc).unwrap();
2791        assert_eq!(session.encode_state_as_update_v1(), before);
2792    }
2793
2794    #[test]
2795    fn legacy_migrations_commit_once_and_normalise_the_package() {
2796        use std::sync::Mutex;
2797
2798        const V1: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v1.update.bin");
2799        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-connectors.update.bin");
2800        for update in [V1, V2] {
2801            let doc = crate::doc_with_client_id(920);
2802            crate::hydrate_doc(&doc, update).unwrap();
2803            let before = snapshot_doc(&doc, &package_from_doc(&doc).unwrap()).unwrap();
2804            let observed = Arc::new(Mutex::new(Vec::new()));
2805            let events = observed.clone();
2806            let _subscription = doc
2807                .observe_update_v1(move |txn, _| {
2808                    let meta = required_map(txn, META).unwrap();
2809                    let Some(Out::Any(Any::Buffer(package))) = meta.get(txn, "packageJson") else {
2810                        panic!("missing package data");
2811                    };
2812                    events
2813                        .lock()
2814                        .unwrap()
2815                        .push((map_number(&meta, txn, "schemaVersion").unwrap(), package));
2816                })
2817                .unwrap();
2818
2819            migrate_doc(&doc).unwrap();
2820
2821            let events = observed.lock().unwrap();
2822            assert_eq!(
2823                events
2824                    .iter()
2825                    .map(|(version, _)| *version)
2826                    .collect::<Vec<_>>(),
2827                [SCHEMA_VERSION]
2828            );
2829            for (_, package) in events.iter() {
2830                let json: serde_json::Value = serde_json::from_slice(package).unwrap();
2831                assert!(json.get("charts").unwrap().is_array());
2832                assert!(json.get("shapeElements").is_none());
2833                assert!(json["presentation"].get("firstSlideNum").is_none());
2834            }
2835            let package = package_from_doc(&doc).unwrap();
2836            assert_eq!(snapshot_doc(&doc, &package).unwrap(), before);
2837            assert!(!package.models_connectors());
2838            assert_eq!(package.presentation.first_slide_num, 1);
2839        }
2840    }
2841
2842    #[test]
2843    fn a_v2_hidden_snapshot_backfills_flags_without_rewriting_the_package() {
2844        use std::sync::Mutex;
2845        use yrs::Update;
2846        use yrs::updates::decoder::Decode;
2847
2848        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-hidden.update.bin");
2849        let doc = crate::doc_with_client_id(9430);
2850        doc.transact_mut()
2851            .apply_update(Update::decode_v1(V2).unwrap())
2852            .unwrap();
2853        let before = package_from_doc(&doc).unwrap();
2854        let observed = Arc::new(Mutex::new(Vec::new()));
2855        let events = observed.clone();
2856        let _subscription = doc
2857            .observe_update_v1(move |txn, _| {
2858                let meta = required_map(txn, META).unwrap();
2859                let shapes = required_map(txn, SHAPES).unwrap();
2860                let mut hidden: Vec<String> = shapes
2861                    .iter(txn)
2862                    .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2863                    .filter(|(_, shape)| map_bool(shape, txn, "hidden").unwrap_or(false))
2864                    .map(|(id, _)| id.to_owned())
2865                    .collect();
2866                hidden.sort();
2867                events.lock().unwrap().push((
2868                    map_number(&meta, txn, "schemaVersion").unwrap(),
2869                    package_from_meta(&meta, txn).unwrap(),
2870                    hidden,
2871                ));
2872            })
2873            .unwrap();
2874        migrate_doc(&doc).unwrap();
2875        let events = observed.lock().unwrap();
2876        assert_eq!(
2877            events
2878                .iter()
2879                .map(|(version, _, _)| *version)
2880                .collect::<Vec<_>>(),
2881            [SCHEMA_VERSION]
2882        );
2883        for (_, package, hidden) in events.iter() {
2884            assert_eq!(
2885                serde_json::to_vec(package).unwrap(),
2886                serde_json::to_vec(&before).unwrap()
2887            );
2888            assert_eq!(
2889                hidden,
2890                &[
2891                    "slide:0:256:shape:0",
2892                    "slide:0:256:shape:8",
2893                    "slide:0:256:shape:8.13",
2894                    "slide:1:257:shape:16",
2895                ]
2896            );
2897        }
2898    }
2899
2900    /// `baseline_snapshot` must reproduce exactly what a freshly seeded
2901    /// scratch document snapshots to, including run merging under yrs'
2902    /// format-gap cleanup.
2903    #[test]
2904    fn baseline_matches_seeded_doc_snapshot() {
2905        let mut files: Vec<Vec<u8>> = vec![
2906            FIXTURE.to_vec(),
2907            HIDDEN_FIXTURE.to_vec(),
2908            include_bytes!("../tests/fixtures/blip-shadow.pptx").to_vec(),
2909            include_bytes!("../tests/fixtures/chart-text-overflow.pptx").to_vec(),
2910            include_bytes!("../tests/fixtures/deck-schema-v2-connectors.pptx").to_vec(),
2911            include_bytes!("../tests/fixtures/deck-schema-v2-nested-connectors.pptx").to_vec(),
2912            include_bytes!("../tests/fixtures/metafile-tracking.pptx").to_vec(),
2913            include_bytes!("../tests/fixtures/modern-comments.pptx").to_vec(),
2914            include_bytes!("../tests/fixtures/run-spacing-shadow.pptx").to_vec(),
2915        ];
2916        if let Ok(dir) = std::env::var("PPTX_DIR") {
2917            let mut extra: Vec<_> = std::fs::read_dir(dir)
2918                .unwrap()
2919                .filter_map(|e| e.ok().map(|e| e.path()))
2920                .filter(|p| p.extension().is_some_and(|e| e == "pptx"))
2921                .collect();
2922            extra.sort();
2923            files.extend(extra.iter().map(|path| std::fs::read(path).unwrap()));
2924        }
2925        for (index, bytes) in files.iter().enumerate() {
2926            let package = pptx_parse::parse_pptx(bytes).unwrap();
2927            let doc = crate::doc_with_client_id(crate::BOOTSTRAP_CLIENT_ID);
2928            seed_doc(&doc, &package, "").unwrap();
2929            let seeded = snapshot_doc(&doc, &package).unwrap();
2930            let direct = baseline_snapshot(&package).unwrap();
2931            assert_eq!(seeded, direct, "baseline snapshot differs for file {index}");
2932        }
2933    }
2934
2935    #[test]
2936    fn seeding_stores_hidden_only_for_hidden_shapes() {
2937        assert!(hidden_keys(&DeckSession::open(FIXTURE, 103).unwrap()).is_empty());
2938
2939        let session = DeckSession::open(HIDDEN_FIXTURE, 104).unwrap();
2940        assert_eq!(
2941            hidden_keys(&session),
2942            [
2943                "slide:0:256:shape:0",
2944                "slide:0:256:shape:8",
2945                "slide:0:256:shape:8.13",
2946                "slide:1:257:shape:16",
2947                "slide:1:257:shape:4",
2948            ]
2949        );
2950        let snapshot = session.snapshot().unwrap();
2951        let group = &snapshot.slides[0].shapes[8];
2952        assert!(snapshot.slides[0].shapes[0].hidden);
2953        assert!(group.hidden);
2954        assert_eq!(group.children.len(), 14);
2955        assert!(group.children[..13].iter().all(|child| !child.hidden));
2956        assert!(group.children[13].hidden);
2957    }
2958
2959    #[test]
2960    fn hidden_flags_serialise_sparsely_and_round_trip() {
2961        let snapshot = DeckSession::open(HIDDEN_FIXTURE, 106)
2962            .unwrap()
2963            .snapshot()
2964            .unwrap();
2965        let json = serde_json::to_string(&snapshot).unwrap();
2966        assert_eq!(json.matches("\"hidden\":true").count(), 5);
2967        assert!(!json.contains("\"hidden\":false"));
2968        assert_eq!(
2969            serde_json::from_str::<DeckSnapshot>(&json).unwrap(),
2970            snapshot
2971        );
2972    }
2973
2974    fn hidden_keys(session: &DeckSession) -> Vec<String> {
2975        let txn = session.doc.transact();
2976        let shapes = required_map(&txn, SHAPES).unwrap();
2977        let mut ids: Vec<String> = shapes
2978            .iter(&txn)
2979            .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2980            .filter(|(_, shape)| shape.get(&txn, "hidden").is_some())
2981            .map(|(id, _)| id.to_owned())
2982            .collect();
2983        ids.sort();
2984        ids
2985    }
2986
2987    #[test]
2988    fn delete_operations_remove_owned_map_entries() {
2989        let session = DeckSession::open(FIXTURE, 101).unwrap();
2990        let context = EditCtx::local("test");
2991        let baseline = map_lengths(&session);
2992
2993        for cycle in 0..3 {
2994            let index = session.snapshot().unwrap().slides.len() as u32;
2995            let slide = session.insert_slide(&context, index, None).unwrap();
2996            let first = session
2997                .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
2998                .unwrap();
2999            session
3000                .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
3001                .unwrap();
3002            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
3003
3004            session
3005                .remove_shape(&context, &slide.slide_id, &first.shape_id)
3006                .unwrap();
3007            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
3008
3009            session.delete_slide(&context, &slide.slide_id).unwrap();
3010            assert_eq!(map_lengths(&session), baseline);
3011        }
3012    }
3013
3014    #[test]
3015    fn deleting_seeded_slide_cascades_descendant_entries() {
3016        let session = DeckSession::open(FIXTURE, 202).unwrap();
3017        let slide = session.snapshot().unwrap().slides[0].clone();
3018        let before = map_lengths(&session);
3019        let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
3020            let next = shape_entry_lengths(shape);
3021            (total.0 + next.0, total.1 + next.1)
3022        });
3023
3024        session
3025            .delete_slide(&EditCtx::local("test"), &slide.id)
3026            .unwrap();
3027
3028        assert!(owned.0 > slide.shapes.len() as u32);
3029        assert_eq!(
3030            map_lengths(&session),
3031            (before.0 - 1, before.1 - owned.0, before.2 - owned.1)
3032        );
3033    }
3034
3035    fn draft(cycle: u32, index: u32) -> ShapeDraft {
3036        ShapeDraft {
3037            name: format!("Shape {cycle}:{index}"),
3038            rect: ShapeRect {
3039                x: 100_000,
3040                y: 100_000,
3041                width: 1_000_000,
3042                height: 500_000,
3043            },
3044            text: "Text".to_owned(),
3045            style: TextStyle::default(),
3046        }
3047    }
3048
3049    fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
3050        let txn = session.doc.transact();
3051        (
3052            required_map(&txn, SLIDES).unwrap().len(&txn),
3053            required_map(&txn, SHAPES).unwrap().len(&txn),
3054            required_map(&txn, STORIES).unwrap().len(&txn),
3055        )
3056    }
3057
3058    fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
3059        shape.children.iter().fold(
3060            (1, shape.text_stories.len() as u32),
3061            |(shape_count, story_count), child| {
3062                let child_counts = shape_entry_lengths(child);
3063                (shape_count + child_counts.0, story_count + child_counts.1)
3064            },
3065        )
3066    }
3067
3068    fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
3069        (left.0 + right.0, left.1 + right.1, left.2 + right.2)
3070    }
3071
3072    #[test]
3073    fn a_v2_comment_snapshot_records_its_flavour_while_deferring_the_import() {
3074        use std::sync::Mutex;
3075        use yrs::Update;
3076        use yrs::updates::decoder::Decode;
3077
3078        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-comments.update.bin");
3079        let doc = crate::doc_with_client_id(9620);
3080        doc.transact_mut()
3081            .apply_update(Update::decode_v1(V2).unwrap())
3082            .unwrap();
3083        let before = {
3084            let txn = doc.transact();
3085            let meta = required_map(&txn, META).unwrap();
3086            assert_eq!(map_number(&meta, &txn, "schemaVersion"), Some(2.0));
3087            assert!(meta.get(&txn, "commentFlavor").is_none());
3088            package_from_meta(&meta, &txn).unwrap()
3089        };
3090        let observed = Arc::new(Mutex::new(Vec::new()));
3091        let events = observed.clone();
3092        let _subscription = doc
3093            .observe_update_v1(move |txn, _| {
3094                let meta = required_map(txn, META).unwrap();
3095                events.lock().unwrap().push((
3096                    map_number(&meta, txn, "schemaVersion").unwrap(),
3097                    package_from_meta(&meta, txn).unwrap(),
3098                    map_string(&meta, txn, "commentFlavor"),
3099                    map_bool(&meta, txn, "commentsPendingSource"),
3100                ));
3101            })
3102            .unwrap();
3103        migrate_doc(&doc).unwrap();
3104        let events = observed.lock().unwrap();
3105        assert_eq!(
3106            *events,
3107            [(
3108                SCHEMA_VERSION,
3109                before,
3110                Some("legacy".to_owned()),
3111                Some(true)
3112            )]
3113        );
3114    }
3115}