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, ShapeStyle, Theme,
7    preset_geometry_default_adjustments, preset_geometry_to_path, resolve_color_value_to_hex,
8    resolve_color_value_to_hex_with_theme,
9};
10use pptx_parse::{
11    ChartAxis, ChartSpace, GraphicFrameData, Placeholder, PptxPackage, ShapeBase, ShapeNode, Slide,
12    SlideLayout,
13};
14#[cfg(any(feature = "wasm", test))]
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17use yrs::types::AsPrelim;
18use yrs::types::text::YChange;
19use yrs::updates::decoder::Decode;
20use yrs::{
21    Any, Array, ArrayPrelim, ArrayRef, ClientID, Doc, In, Map, MapPrelim, MapRef, Out, ReadTxn,
22    StateVector, Text, TextRef, Transact, TransactionMut, Update, WriteTxn,
23};
24
25use crate::comments::{
26    baseline_comments, flavor_key, seed_comments, snapshot_comments, snapshot_flavor,
27};
28use crate::inherit::{SlideContext, inherited_transform, layout_transform, record_inherited};
29use crate::story::{baseline_story, seed_plain_story, seed_story, snapshot_story, validate_story};
30use crate::{
31    DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN, PendingMedia,
32    PictureDraft, PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt,
33    ShapeDraft, ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
34    ShapeStrokeReceipt, ShapeZOrderReceipt, SlideBackgroundReceipt, SlideReceipt, SlideScope,
35    SlideSnapshot, TransformReceipt,
36};
37
38const SCHEMA_VERSION: f64 = 2.3;
39/// Versions [`migrate_doc`] can carry forward. Anything else is unreadable.
40const MIGRATABLE_SCHEMA_VERSIONS: [f64; 5] = [1.0, 2.0, 2.1, 2.2, SCHEMA_VERSION];
41const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
42const MAX_SHAPE_DEPTH: usize = 128;
43const EMU_PER_POINT: f64 = 12_700.0;
44const MAX_ADJUSTMENTS: usize = 32;
45const MAX_ADJUSTMENT_INDEX: usize = 32;
46/// Stays well under the 16 MiB collaboration frame cap once base64-encoded.
47const MAX_PENDING_PICTURE_BYTES: usize = 8 * 1024 * 1024;
48
49#[cfg(any(feature = "wasm", test))]
50#[derive(Debug, PartialEq, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub(crate) struct SlideMetadata {
53    id: String,
54    index: usize,
55    name: Option<String>,
56    layout_part_path: Option<String>,
57}
58
59#[cfg(any(feature = "wasm", test))]
60#[derive(Debug, PartialEq, Serialize)]
61pub(crate) struct DeckSize {
62    width: i64,
63    height: i64,
64}
65
66#[cfg(any(feature = "wasm", test))]
67#[derive(Debug, PartialEq, Serialize)]
68pub(crate) struct DeckMetadata {
69    slides: Vec<SlideMetadata>,
70    size: DeckSize,
71}
72
73pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
74    let package_json =
75        serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
76    let mut txn = doc.transact_mut_with("pptx:bootstrap");
77    let meta = txn.get_or_insert_map(META);
78    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
79    meta.insert(&mut txn, "fingerprint", fingerprint);
80    meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
81    meta.insert(
82        &mut txn,
83        "heightEmu",
84        package.presentation.height_emu as f64,
85    );
86    meta.insert(
87        &mut txn,
88        "packageJson",
89        Any::Buffer(Arc::from(package_json)),
90    );
91    meta.insert(
92        &mut txn,
93        "commentFlavor",
94        flavor_key(package.comment_flavor.unwrap_or_default()),
95    );
96    let order = txn.get_or_insert_array(SLIDE_ORDER);
97    let slides = txn.get_or_insert_map(SLIDES);
98    let shapes = txn.get_or_insert_map(SHAPES);
99    let stories = txn.get_or_insert_map(STORIES);
100    let mut slide_id_by_part: HashMap<String, String> = HashMap::new();
101
102    for (slide_index, slide) in package.slides.iter().enumerate() {
103        let theme = pptx_parse::slide_theme(package, Some(&slide.part_path), None);
104        let slide_id = seeded_slide_id(slide_index, package.presentation.slides[slide_index].id);
105        order.push_back(&mut txn, slide_id.as_str());
106        let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
107        slide_map.insert(&mut txn, "id", slide_id.as_str());
108        slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
109        if let Some(layout) = &slide.layout_part_path {
110            slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
111        }
112        if let Some(name) = &slide.name {
113            slide_map.insert(&mut txn, "name", name.as_str());
114        }
115        if !slide.notes.is_empty() {
116            slide_map.insert(&mut txn, "notes", slide.notes.as_str());
117        }
118        let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
119        for (shape_index, shape) in slide.shapes.iter().enumerate() {
120            let shape_id = seed_shape(
121                &shapes,
122                &stories,
123                &mut txn,
124                &slide_id,
125                &shape_index.to_string(),
126                shape,
127                Some(&theme),
128            )?;
129            shape_order.push_back(&mut txn, shape_id.as_str());
130        }
131        slide_id_by_part.insert(slide.part_path.clone(), slide_id);
132    }
133    seed_comments(&mut txn, package, &|part| {
134        slide_id_by_part.get(part).cloned()
135    })?;
136    Ok(())
137}
138
139fn seed_shape(
140    shapes: &MapRef,
141    stories: &MapRef,
142    txn: &mut TransactionMut<'_>,
143    slide_id: &str,
144    path: &str,
145    shape: &ShapeNode,
146    theme: Option<&Theme>,
147) -> EditResult<String> {
148    let shape_id = seeded_shape_id(slide_id, path);
149    let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
150    let base = shape_base(shape);
151    shape_map.insert(txn, "id", shape_id.as_str());
152    shape_map.insert(txn, "sourceId", base.id as f64);
153    shape_map.insert(txn, "name", base.name.as_str());
154    shape_map.insert(txn, "x", base.transform.x as f64);
155    shape_map.insert(txn, "y", base.transform.y as f64);
156    shape_map.insert(txn, "width", base.transform.width as f64);
157    shape_map.insert(txn, "height", base.transform.height as f64);
158    shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
159    shape_map.insert(txn, "flipH", base.transform.flip_h);
160    shape_map.insert(txn, "flipV", base.transform.flip_v);
161    if base.hidden {
162        shape_map.insert(txn, "hidden", true);
163    }
164    insert_json(
165        &shape_map,
166        txn,
167        "placeholderJson",
168        base.placeholder.as_ref(),
169    )?;
170
171    let mut text_story_ids = Vec::new();
172    let mut child_ids = Vec::new();
173    match shape {
174        ShapeNode::Shape(shape) => {
175            shape_map.insert(txn, "kind", "shape");
176            shape_map.insert(txn, "geometry", shape.geometry.as_str());
177            if !shape.has_preset_geometry {
178                shape_map.insert(txn, "hasPresetGeometry", false);
179            }
180            let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
181                .into_iter()
182                .collect::<BTreeMap<_, _>>();
183            adjust_values.extend(shape.adjust_values.clone());
184            insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
185            insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
186            insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
187            if let Some(body) = &shape.text {
188                let story_id = format!("story:{shape_id}:0");
189                seed_story(stories, txn, &story_id, body, theme)?;
190                text_story_ids.push(story_id);
191            }
192        }
193        ShapeNode::Picture(picture) => {
194            shape_map.insert(txn, "kind", "picture");
195            if picture.aspect_ratio_locked {
196                shape_map.insert(txn, "aspectRatioLocked", true);
197            }
198            shape_map.insert(txn, "geometry", "rect");
199            insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
200            insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
201            if !picture.effects.is_empty() {
202                insert_json(&shape_map, txn, "blipEffectsJson", Some(&picture.effects))?;
203            }
204            if let Some(media) = &picture.media_part_path {
205                shape_map.insert(txn, "mediaPartPath", media.as_str());
206            }
207        }
208        ShapeNode::GraphicFrame(frame) => {
209            shape_map.insert(txn, "kind", "graphicFrame");
210            shape_map.insert(txn, "geometry", "rect");
211            insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
212            if let pptx_parse::GraphicFrameData::Table(table) = &frame.data {
213                for (row_index, row) in table.rows.iter().enumerate() {
214                    for (cell_index, cell) in row.cells.iter().enumerate() {
215                        let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
216                        seed_story(stories, txn, &story_id, &cell.text, theme)?;
217                        text_story_ids.push(story_id);
218                    }
219                }
220            }
221        }
222        ShapeNode::Group(group) => {
223            shape_map.insert(txn, "kind", "group");
224            shape_map.insert(txn, "geometry", "group");
225            for (child_index, child) in group.children.iter().enumerate() {
226                child_ids.push(seed_shape(
227                    shapes,
228                    stories,
229                    txn,
230                    slide_id,
231                    &seeded_child_path(path, child_index),
232                    child,
233                    theme,
234                )?);
235            }
236        }
237    }
238    shape_map.insert(txn, "textStories", string_array(&text_story_ids));
239    shape_map.insert(txn, "children", string_array(&child_ids));
240    Ok(shape_id)
241}
242
243fn seeded_slide_id(slide_index: usize, reference_id: u32) -> String {
244    format!("slide:{slide_index}:{reference_id}")
245}
246
247fn seeded_shape_id(slide_id: &str, path: &str) -> String {
248    format!("{slide_id}:shape:{path}")
249}
250
251fn seeded_child_path(path: &str, child_index: usize) -> String {
252    format!("{path}.{child_index}")
253}
254
255pub(crate) fn shape_base(shape: &ShapeNode) -> &ShapeBase {
256    match shape {
257        ShapeNode::Shape(shape) => &shape.base,
258        ShapeNode::Picture(shape) => &shape.base,
259        ShapeNode::GraphicFrame(shape) => &shape.base,
260        ShapeNode::Group(shape) => &shape.base,
261    }
262}
263
264fn insert_json<T: serde::Serialize>(
265    map: &MapRef,
266    txn: &mut TransactionMut<'_>,
267    key: &str,
268    value: Option<&T>,
269) -> EditResult<()> {
270    if let Some(value) = value {
271        let json =
272            serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
273        map.insert(txn, key, json);
274    }
275    Ok(())
276}
277
278impl DeckSession {
279    pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
280        snapshot_doc(&self.doc, &self.package)
281    }
282
283    #[cfg(any(feature = "wasm", test))]
284    pub(crate) fn session_metadata(&self) -> EditResult<DeckMetadata> {
285        let txn = self.doc.transact();
286        let meta = required_map(&txn, META)?;
287        let slides = required_map(&txn, SLIDES)?;
288        let mut summaries = Vec::new();
289        for slide_id in live_slide_ids(&txn)? {
290            let slide = slides
291                .get(&txn, &slide_id)
292                .and_then(|value| value.cast::<MapRef>().ok())
293                .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
294            summaries.push(SlideMetadata {
295                id: slide_id,
296                index: summaries.len(),
297                name: map_string(&slide, &txn, "name"),
298                layout_part_path: map_string(&slide, &txn, "layoutPartPath"),
299            });
300        }
301        Ok(DeckMetadata {
302            slides: summaries,
303            size: DeckSize {
304                width: required_i64(&meta, &txn, "widthEmu")?,
305                height: required_i64(&meta, &txn, "heightEmu")?,
306            },
307        })
308    }
309
310    /// Slide ids in deck order, matching `snapshot().slides` without walking shapes.
311    pub fn slide_ids(&self) -> EditResult<Vec<String>> {
312        live_slide_ids(&self.doc.transact())
313    }
314
315    /// Snapshot one slide without materializing the rest of the deck.
316    pub fn slide_scope(&self, slide_index: usize) -> EditResult<SlideScope> {
317        slide_scope(&self.doc, &self.package, slide_index)
318    }
319
320    pub fn insert_slide(
321        &self,
322        context: &EditCtx,
323        index: u32,
324        layout_part_path: Option<&str>,
325    ) -> EditResult<SlideReceipt> {
326        if let Some(path) = layout_part_path
327            && !self
328                .package
329                .layouts
330                .iter()
331                .any(|layout| layout.part_path == path)
332        {
333            return Err(EditError::InvalidState(format!(
334                "unknown slide layout {path:?}"
335            )));
336        }
337        let (order, slides, position, length) = {
338            let txn = self.doc.transact();
339            let order = required_order(&txn)?;
340            let (position, length) = slide_insert_position(&order, &txn, index)?;
341            (order, required_map(&txn, SLIDES)?, position, length)
342        };
343        let slide_id = self.next_id("slide");
344        let mut txn = self.transact_for(context);
345        let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
346        slide.insert(&mut txn, "id", slide_id.as_str());
347        slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
348        if let Some(layout_part_path) = layout_part_path {
349            slide.insert(&mut txn, "layoutPartPath", layout_part_path);
350        }
351        slide.insert(&mut txn, "shapes", ArrayPrelim::default());
352        order.insert(&mut txn, position, slide_id.as_str());
353        Ok(SlideReceipt {
354            slide_id,
355            from_index: None,
356            to_index: Some(index),
357        })
358    }
359
360    pub fn set_slide_layout(
361        &self,
362        context: &EditCtx,
363        slide_id: &str,
364        layout_part_path: &str,
365        mapping: &[(String, u32)],
366    ) -> EditResult<()> {
367        let target = self
368            .package
369            .layouts
370            .iter()
371            .find(|layout| layout.part_path == layout_part_path)
372            .ok_or_else(|| {
373                EditError::InvalidState(format!("unknown slide layout {layout_part_path:?}"))
374            })?;
375        let txn = self.doc.transact();
376        let slide = slide_ref(&txn, slide_id)?;
377        let current_path = map_string(&slide, &txn, "layoutPartPath");
378        if current_path.as_deref() == Some(layout_part_path) && mapping.is_empty() {
379            return Ok(());
380        }
381        let current = self
382            .package
383            .layouts
384            .iter()
385            .find(|layout| Some(layout.part_path.as_str()) == current_path.as_deref())
386            .ok_or_else(|| EditError::InvalidState("slide has no known layout".into()))?;
387        let master = layout_master_path(&self.package, current);
388        if master.is_none() || master != layout_master_path(&self.package, target) {
389            return Err(EditError::InvalidState(
390                "layout belongs to another master".into(),
391            ));
392        }
393        let mut source_shapes = ShapeEntries::default();
394        if !mapping.is_empty() {
395            let shapes = required_map(&txn, SHAPES)?;
396            for id in live_shape_order(&slide_shape_order(&slide, &txn)?, &txn)? {
397                collect_shape_entries(&shapes, &txn, &id, &mut HashSet::new(), &mut source_shapes)?;
398            }
399        }
400        let mut used_targets = HashSet::new();
401        let mut used_shapes = HashSet::new();
402        let mut changes = Vec::new();
403        for (shape_id, target_id) in mapping {
404            if !source_shapes.seen_shape_ids.contains(shape_id) {
405                return Err(EditError::ShapeNotFound(shape_id.to_owned()));
406            }
407            let shape = shape_ref(&txn, shape_id)?;
408            let placeholder: Option<Placeholder> = optional_json(&shape, &txn, "placeholderJson")?;
409            if placeholder.is_none() {
410                return Err(EditError::InvalidState(
411                    "mapped shape is not a placeholder".into(),
412                ));
413            }
414            if !used_targets.insert(target_id) || !used_shapes.insert(shape_id) {
415                return Err(EditError::InvalidState(
416                    "placeholder mapping must be one-to-one".into(),
417                ));
418            }
419            let target_placeholder =
420                layout_placeholder(&target.shapes, *target_id).ok_or_else(|| {
421                    EditError::InvalidState(format!("unknown layout placeholder {target_id}"))
422                })?;
423            let json = serde_json::to_string(target_placeholder)
424                .map_err(|error| EditError::Json(error.to_string()))?;
425            if placeholder.as_ref() != Some(target_placeholder) {
426                changes.push((shape, json));
427            }
428        }
429        let layout_changed = current_path.as_deref() != Some(layout_part_path);
430        drop(txn);
431        if !layout_changed && changes.is_empty() {
432            return Ok(());
433        }
434        let mut txn = self.transact_for(context);
435        if layout_changed {
436            slide.insert(&mut txn, "layoutPartPath", layout_part_path);
437        }
438        for (shape, json) in changes {
439            shape.insert(&mut txn, "placeholderJson", json);
440        }
441        Ok(())
442    }
443
444    pub fn duplicate_slide(
445        &self,
446        context: &EditCtx,
447        slide_id: &str,
448        index: u32,
449    ) -> EditResult<SlideReceipt> {
450        let (new_id, order, position, slides, shapes, stories, mut slide, copies) = {
451            let txn = self.doc.transact();
452            let order = required_order(&txn)?;
453            let (position, _) = slide_insert_position(&order, &txn, index)?;
454            let source = slide_ref(&txn, slide_id)?;
455            let shapes = required_map(&txn, SHAPES)?;
456            let stories = required_map(&txn, STORIES)?;
457            let mut slide = source.as_prelim(&txn);
458            slide.insert(
459                "notes".into(),
460                slide_notes(&source, &txn, &self.package).into(),
461            );
462            let roots = live_shape_order(&slide_shape_order(&source, &txn)?, &txn)?;
463            let mut entries = ShapeEntries::default();
464            for id in &roots {
465                collect_shape_entries(&shapes, &txn, id, &mut HashSet::new(), &mut entries)?;
466            }
467            let new_id = self.next_id("slide");
468            let rename = |id: &str| -> String {
469                if let Some(tail) = id.strip_prefix(slide_id) {
470                    format!("{new_id}{tail}")
471                } else {
472                    format!("{new_id}:copy:{id}")
473                }
474            };
475            let mut copies = Vec::new();
476            for id in entries.shape_ids {
477                let source_shape = shape_ref(&txn, &id)?;
478                let mut shape = source_shape.as_prelim(&txn);
479                let new_shape_id = rename(&id);
480                shape.insert("id".into(), new_shape_id.clone().into());
481                let children = map_string_array(&source_shape, &txn, "children")?;
482                shape.insert(
483                    "children".into(),
484                    string_array(&children.iter().map(|id| rename(id)).collect::<Vec<_>>()).into(),
485                );
486                let texts = map_string_array(&source_shape, &txn, "textStories")?;
487                let mut new_texts = Vec::new();
488                for text_id in texts {
489                    let new_text_id = text_id.replacen(&id, &new_shape_id, 1);
490                    let key = crate::table::storage_key(&txn, &text_id)?;
491                    let text = stories
492                        .get(&txn, &key)
493                        .ok_or_else(|| EditError::StoryNotFound(text_id.clone()))?
494                        .cast::<TextRef>()
495                        .map_err(|_| EditError::StoryNotFound(text_id.clone()))?;
496                    copies.push((
497                        Some((text_id, key)),
498                        new_text_id.clone(),
499                        In::Text(text.as_prelim(&txn)),
500                    ));
501                    new_texts.push(new_text_id);
502                }
503                let mut structure: Option<pptx_parse::TableStructure> =
504                    optional_json(&source_shape, &txn, "tableStructureJson")?;
505                if let Some(structure) = &mut structure {
506                    structure.story_keys.clone_from(&new_texts);
507                    shape.insert("tableStoryKeys".into(), string_array(&new_texts).into());
508                    shape.insert(
509                        "tableStructureJson".into(),
510                        serde_json::to_string(structure)
511                            .map_err(|error| EditError::Json(error.to_string()))?
512                            .into(),
513                    );
514                }
515                shape.insert("textStories".into(), string_array(&new_texts).into());
516                copies.push((None, new_shape_id, shape.into()));
517            }
518            slide.insert(
519                "shapes".into(),
520                ArrayPrelim::from(roots.iter().map(|id| rename(id)).collect::<Vec<_>>()).into(),
521            );
522            (
523                new_id,
524                order,
525                position,
526                required_map(&txn, SLIDES)?,
527                shapes,
528                stories,
529                slide,
530                copies,
531            )
532        };
533        let (comments, comment_copies) = {
534            let txn = self.doc.transact();
535            (
536                required_map(&txn, crate::COMMENTS)?,
537                crate::comments::duplicate_comments(&txn, slide_id, &new_id)?,
538            )
539        };
540        slide.insert("id".into(), new_id.clone().into());
541        self.automatic_undo_barrier();
542        let mut txn = self.transact_for(context);
543        for (story, id, value) in copies {
544            if let Some((source_id, source_key)) = story {
545                let text = stories
546                    .insert(&mut txn, id.as_str(), value)
547                    .cast::<TextRef>()
548                    .map_err(|_| EditError::StoryNotFound(id.clone()))?;
549                crate::story::materialize_paragraph_joins(&text, &mut txn, &source_id)?;
550                let prefix = format!("para:{source_key}:");
551                for diff in text.diff(&txn, YChange::identity) {
552                    if let Out::YMap(marker) = diff.insert {
553                        let copied_id = map_string(&marker, &txn, crate::PARA_ID)
554                            .and_then(|old| {
555                                old.strip_prefix(&prefix)
556                                    .filter(|index| index.parse::<usize>().is_ok())
557                                    .map(|index| format!("para:{id}:{index}"))
558                            })
559                            .unwrap_or_else(|| self.next_id("para"));
560                        marker.insert(&mut txn, crate::PARA_ID, copied_id);
561                    }
562                }
563            } else {
564                shapes.insert(&mut txn, id, value);
565            }
566        }
567        for (id, comment) in comment_copies {
568            comments.insert(&mut txn, id, comment);
569        }
570        slides.insert(&mut txn, new_id.as_str(), slide);
571        order.insert(&mut txn, position, new_id.as_str());
572        drop(txn);
573        self.automatic_undo_barrier();
574        Ok(SlideReceipt {
575            slide_id: new_id,
576            from_index: None,
577            to_index: Some(index),
578        })
579    }
580
581    pub fn set_slide_notes(&self, context: &EditCtx, slide_id: &str, text: &str) -> EditResult<()> {
582        crate::model::validate_xml_text(text)?;
583        let slide = slide_ref(&self.doc.transact(), slide_id)?;
584        self.automatic_undo_barrier();
585        let mut txn = self.transact_for(context);
586        slide.insert(&mut txn, "notes", text);
587        drop(txn);
588        self.automatic_undo_barrier();
589        Ok(())
590    }
591
592    /// Replaces a slide's background; `None` removes the declaration so the
593    /// layout or master background shows.
594    pub fn set_slide_background(
595        &self,
596        context: &EditCtx,
597        slide_id: &str,
598        background: Option<&ShapeFill>,
599    ) -> EditResult<SlideBackgroundReceipt> {
600        if let Some(background) = background {
601            validate_background(background)?;
602        }
603        let background = background.map(normalized_background);
604        let read = self.doc.transact();
605        let slide = slide_ref(&read, slide_id)?;
606        let (before, _) = slide_background(&slide, &read, &self.package);
607        drop(read);
608        self.automatic_undo_barrier();
609        let mut txn = self.transact_for(context);
610        match &background {
611            Some(background) => {
612                insert_json(&slide, &mut txn, "backgroundJson", Some(background))?;
613            }
614            None => {
615                slide.remove(&mut txn, "backgroundJson");
616                slide.insert(&mut txn, "backgroundCleared", true);
617            }
618        }
619        drop(txn);
620        self.automatic_undo_barrier();
621        Ok(SlideBackgroundReceipt {
622            slide_id: slide_id.to_owned(),
623            before,
624            after: background,
625        })
626    }
627
628    pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
629        let mut txn = self.transact_for(context);
630        let order = required_order(&txn)?;
631        let index = live_slide_index(&txn, slide_id)?;
632        let slide = slide_ref(&txn, slide_id)?;
633        let shape_order = slide_shape_order(&slide, &txn)?;
634        let shape_ids = live_shape_order(&shape_order, &txn)?;
635        tombstone_shapes(&mut txn, &shape_ids)?;
636        remove_slide_entries(&order, &mut txn, slide_id);
637        slide.remove(&mut txn, "id");
638        Ok(SlideReceipt {
639            slide_id: slide_id.to_owned(),
640            from_index: Some(index),
641            to_index: None,
642        })
643    }
644
645    pub fn move_slide(
646        &self,
647        context: &EditCtx,
648        slide_id: &str,
649        to_index: u32,
650    ) -> EditResult<SlideReceipt> {
651        let mut txn = self.transact_for(context);
652        let order = required_order(&txn)?;
653        let length = live_slide_order(&txn)?.len() as u32;
654        if to_index >= length {
655            return Err(EditError::OutOfBounds {
656                index: to_index,
657                length,
658            });
659        }
660        let from_index = live_slide_index(&txn, slide_id)?;
661        if from_index != to_index {
662            remove_slide_entries(&order, &mut txn, slide_id);
663            let (position, _) = slide_insert_position(&order, &txn, to_index)?;
664            order.insert(&mut txn, position, slide_id);
665        }
666        Ok(SlideReceipt {
667            slide_id: slide_id.to_owned(),
668            from_index: Some(from_index),
669            to_index: Some(to_index),
670        })
671    }
672
673    pub fn add_text_box(
674        &self,
675        context: &EditCtx,
676        slide_id: &str,
677        draft: &ShapeDraft,
678    ) -> EditResult<ShapeReceipt> {
679        validate_rect(draft.rect)?;
680        crate::model::validate_xml_text(&draft.name)?;
681        crate::model::validate_xml_text(&draft.text)?;
682        crate::story::validate_style_values(
683            draft.style.font_family.as_deref(),
684            draft.style.underline.as_deref(),
685            draft.style.color.as_deref(),
686            draft.style.font_size_pt,
687            draft.style.spacing_pt,
688            draft.style.baseline_pct,
689            draft.style.kern_pt,
690        )?;
691        let fill_json = serde_json::to_string(&ShapeFill::named("none"))
692            .map_err(|error| EditError::Json(error.to_string()))?;
693        let (order, index, shapes, stories) = {
694            let txn = self.doc.transact();
695            let slide = slide_ref(&txn, slide_id)?;
696            let order = slide_shape_order(&slide, &txn)?;
697            let index = order.len(&txn);
698            (
699                order,
700                index,
701                required_map(&txn, SHAPES)?,
702                required_map(&txn, STORIES)?,
703            )
704        };
705        let shape_id = self.next_id("shape");
706        let story_id = format!("story:{shape_id}:0");
707        let paragraph_id = self.next_id("para");
708        let mut txn = self.transact_for(context);
709        seed_plain_story(
710            &stories,
711            &mut txn,
712            &story_id,
713            &paragraph_id,
714            &draft.text,
715            &draft.style,
716        );
717        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
718        shape.insert(&mut txn, "id", shape_id.as_str());
719        shape.insert(&mut txn, "sourceId", 0_f64);
720        shape.insert(&mut txn, "kind", "shape");
721        shape.insert(&mut txn, "textBox", true);
722        shape.insert(&mut txn, "name", draft.name.as_str());
723        shape.insert(&mut txn, "x", draft.rect.x as f64);
724        shape.insert(&mut txn, "y", draft.rect.y as f64);
725        shape.insert(&mut txn, "width", draft.rect.width as f64);
726        shape.insert(&mut txn, "height", draft.rect.height as f64);
727        shape.insert(&mut txn, "rotationDeg", 0_f64);
728        shape.insert(&mut txn, "flipH", false);
729        shape.insert(&mut txn, "flipV", false);
730        shape.insert(&mut txn, "geometry", "rect");
731        shape.insert(&mut txn, "fillJson", fill_json);
732        shape.insert(
733            &mut txn,
734            "textStories",
735            string_array(std::slice::from_ref(&story_id)),
736        );
737        shape.insert(&mut txn, "children", string_array(&[]));
738        order.push_back(&mut txn, shape_id.as_str());
739        Ok(ShapeReceipt {
740            slide_id: slide_id.to_owned(),
741            shape_id,
742            index,
743        })
744    }
745
746    pub fn add_shape(
747        &self,
748        context: &EditCtx,
749        slide_id: &str,
750        draft: &PresetShapeDraft,
751    ) -> EditResult<ShapeReceipt> {
752        validate_rect(draft.rect)?;
753        crate::model::validate_xml_text(&draft.name)?;
754        let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
755        if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none()
756            && ooxml_drawingml::preset_geometry_layers(
757                &draft.geometry,
758                &Default::default(),
759                aspect_ratio,
760            )
761            .is_none()
762        {
763            return Err(EditError::InvalidGeometry(format!(
764                "unsupported preset geometry {}",
765                draft.geometry
766            )));
767        }
768        let fill = shape_fill(draft.fill.as_deref())?;
769        let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
770            .into_iter()
771            .collect::<BTreeMap<_, _>>();
772        let adjust_values_json = serde_json::to_string(&adjust_values)
773            .map_err(|error| EditError::Json(error.to_string()))?;
774        let fill_json =
775            serde_json::to_string(&fill).map_err(|error| EditError::Json(error.to_string()))?;
776        let (order, index, shapes) = {
777            let txn = self.doc.transact();
778            let slide = slide_ref(&txn, slide_id)?;
779            let order = slide_shape_order(&slide, &txn)?;
780            let index = order.len(&txn);
781            (order, index, required_map(&txn, SHAPES)?)
782        };
783        let shape_id = self.next_id("shape");
784        let mut txn = self.transact_for(context);
785        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
786        shape.insert(&mut txn, "id", shape_id.as_str());
787        shape.insert(&mut txn, "sourceId", 0_f64);
788        shape.insert(&mut txn, "kind", "shape");
789        shape.insert(&mut txn, "name", draft.name.as_str());
790        shape.insert(&mut txn, "x", draft.rect.x as f64);
791        shape.insert(&mut txn, "y", draft.rect.y as f64);
792        shape.insert(&mut txn, "width", draft.rect.width as f64);
793        shape.insert(&mut txn, "height", draft.rect.height as f64);
794        shape.insert(&mut txn, "rotationDeg", 0_f64);
795        shape.insert(&mut txn, "flipH", false);
796        shape.insert(&mut txn, "flipV", false);
797        shape.insert(&mut txn, "geometry", draft.geometry.as_str());
798        shape.insert(&mut txn, "adjustValuesJson", adjust_values_json);
799        shape.insert(&mut txn, "fillJson", fill_json);
800        shape.insert(&mut txn, "textStories", string_array(&[]));
801        shape.insert(&mut txn, "children", string_array(&[]));
802        order.push_back(&mut txn, shape_id.as_str());
803        Ok(ShapeReceipt {
804            slide_id: slide_id.to_owned(),
805            shape_id,
806            index,
807        })
808    }
809
810    /// Resolves a display-list image from the source package or shared edits.
811    pub fn media_bytes(&self, asset_id: &str) -> EditResult<Vec<u8>> {
812        if let Some(shape_id) = asset_id.strip_prefix("pending-media:") {
813            let txn = self.doc.transact();
814            let shape = shape_ref(&txn, shape_id)?;
815            let encoded = map_string(&shape, &txn, "pendingMediaBase64")
816                .ok_or_else(|| EditError::InvalidState("pending media was not found".to_owned()))?;
817            return base64::engine::general_purpose::STANDARD
818                .decode(encoded)
819                .map_err(|error| {
820                    EditError::InvalidState(format!("invalid pending image data: {error}"))
821                });
822        }
823        self.package()
824            .media
825            .iter()
826            .find(|media| media.part_path == asset_id)
827            .map(|media| media.bytes.clone())
828            .ok_or_else(|| EditError::InvalidState("media part was not found".to_owned()))
829    }
830
831    /// `save` mints the media part, content-type default and relationship.
832    pub fn add_picture(
833        &self,
834        context: &EditCtx,
835        slide_id: &str,
836        draft: &PictureDraft,
837    ) -> EditResult<ShapeReceipt> {
838        validate_rect(draft.rect)?;
839        crate::model::validate_xml_text(&draft.name)?;
840        if draft.media_bytes.is_empty() {
841            return Err(EditError::InvalidState(
842                "a picture needs image data".to_owned(),
843            ));
844        }
845        if draft.media_bytes.len() > MAX_PENDING_PICTURE_BYTES {
846            return Err(EditError::InvalidState(format!(
847                "image is {} bytes, exceeds the {MAX_PENDING_PICTURE_BYTES}-byte limit",
848                draft.media_bytes.len()
849            )));
850        }
851        if !pptx_parse::is_supported_image_content_type(&draft.content_type) {
852            return Err(EditError::InvalidState(format!(
853                "unsupported image type {:?}",
854                draft.content_type
855            )));
856        }
857        let (order, index, shapes) = {
858            let txn = self.doc.transact();
859            let slide = slide_ref(&txn, slide_id)?;
860            let order = slide_shape_order(&slide, &txn)?;
861            let index = order.len(&txn);
862            (order, index, required_map(&txn, SHAPES)?)
863        };
864        let shape_id = self.next_id("shape");
865        let mut txn = self.transact_for(context);
866        let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
867        shape.insert(&mut txn, "id", shape_id.as_str());
868        shape.insert(&mut txn, "sourceId", 0_f64);
869        shape.insert(&mut txn, "kind", "picture");
870        shape.insert(&mut txn, "aspectRatioLocked", true);
871        shape.insert(&mut txn, "name", draft.name.as_str());
872        shape.insert(&mut txn, "x", draft.rect.x as f64);
873        shape.insert(&mut txn, "y", draft.rect.y as f64);
874        shape.insert(&mut txn, "width", draft.rect.width as f64);
875        shape.insert(&mut txn, "height", draft.rect.height as f64);
876        shape.insert(&mut txn, "rotationDeg", 0_f64);
877        shape.insert(&mut txn, "flipH", false);
878        shape.insert(&mut txn, "flipV", false);
879        shape.insert(&mut txn, "geometry", "rect");
880        shape.insert(
881            &mut txn,
882            "pendingMediaBase64",
883            base64::engine::general_purpose::STANDARD.encode(&draft.media_bytes),
884        );
885        shape.insert(
886            &mut txn,
887            "pendingMediaContentType",
888            draft.content_type.as_str(),
889        );
890        shape.insert(&mut txn, "textStories", string_array(&[]));
891        shape.insert(&mut txn, "children", string_array(&[]));
892        order.push_back(&mut txn, shape_id.as_str());
893        Ok(ShapeReceipt {
894            slide_id: slide_id.to_owned(),
895            shape_id,
896            index,
897        })
898    }
899
900    pub fn set_shape_fill(
901        &self,
902        context: &EditCtx,
903        slide_id: &str,
904        shape_id: &str,
905        color: Option<&str>,
906    ) -> EditResult<ShapeFillReceipt> {
907        let fill = shape_fill(color)?;
908        let mut txn = self.transact_for(context);
909        require_shape_membership(&txn, slide_id, shape_id)?;
910        let shape = shape_ref(&txn, shape_id)?;
911        require_shape_kind(&shape, &txn)?;
912        let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
913            .as_ref()
914            .and_then(fill_color);
915        insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
916        Ok(ShapeFillReceipt {
917            slide_id: slide_id.to_owned(),
918            shape_id: shape_id.to_owned(),
919            before,
920            after: fill_color(&fill),
921        })
922    }
923
924    pub fn set_shape_stroke(
925        &self,
926        context: &EditCtx,
927        slide_id: &str,
928        shape_id: &str,
929        stroke: &ShapeStroke,
930    ) -> EditResult<ShapeStrokeReceipt> {
931        stroked_outline(None, stroke)?;
932        let mut txn = self.transact_for(context);
933        require_shape_membership(&txn, slide_id, shape_id)?;
934        let shape = shape_ref(&txn, shape_id)?;
935        require_shape_kind(&shape, &txn)?;
936        let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
937        let before = existing.as_ref().and_then(outline_stroke);
938        let outline = stroked_outline(existing, stroke)?;
939        insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
940        Ok(ShapeStrokeReceipt {
941            slide_id: slide_id.to_owned(),
942            shape_id: shape_id.to_owned(),
943            before,
944            after: outline_stroke(&outline),
945        })
946    }
947
948    pub fn set_shape_adjust(
949        &self,
950        context: &EditCtx,
951        slide_id: &str,
952        shape_id: &str,
953        adjustments: &BTreeMap<String, f64>,
954    ) -> EditResult<ShapeAdjustReceipt> {
955        validate_adjustments(adjustments)?;
956        let mut txn = self.transact_for(context);
957        require_shape_membership(&txn, slide_id, shape_id)?;
958        let shape = shape_ref(&txn, shape_id)?;
959        require_shape_kind(&shape, &txn)?;
960        let geometry = required_string(&shape, &txn, "geometry")?;
961        if geometry == "custom" {
962            return Err(EditError::InvalidGeometry(
963                "custom geometry does not support shape adjustments".to_owned(),
964            ));
965        }
966        if !map_bool(&shape, &txn, "hasPresetGeometry").unwrap_or(true) {
967            return Err(EditError::InvalidGeometry(
968                "shape without preset geometry does not support shape adjustments".to_owned(),
969            ));
970        }
971        let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
972        let mut after = BTreeMap::new();
973        for (name, value) in adjustments {
974            let maximum = if geometry == "roundRect" && name == "adj" {
975                0.5
976            } else {
977                1.0
978            };
979            after.insert(name.clone(), value.clamp(0.0, maximum));
980        }
981        insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
982        Ok(ShapeAdjustReceipt {
983            slide_id: slide_id.to_owned(),
984            shape_id: shape_id.to_owned(),
985            before,
986            after,
987        })
988    }
989
990    pub fn remove_shape(
991        &self,
992        context: &EditCtx,
993        slide_id: &str,
994        shape_id: &str,
995    ) -> EditResult<ShapeReceipt> {
996        let mut txn = self.transact_for(context);
997        let slide = slide_ref(&txn, slide_id)?;
998        let order = slide_shape_order(&slide, &txn)?;
999        let ids = live_shape_order(&order, &txn)?;
1000        let index =
1001            ids.iter()
1002                .position(|id| id == shape_id)
1003                .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))? as u32;
1004        tombstone_shapes(&mut txn, &[shape_id.to_owned()])?;
1005        for (index, id) in string_array_ref(&order, &txn).iter().enumerate().rev() {
1006            if id == shape_id {
1007                order.remove(&mut txn, index as u32);
1008            }
1009        }
1010        Ok(ShapeReceipt {
1011            slide_id: slide_id.to_owned(),
1012            shape_id: shape_id.to_owned(),
1013            index,
1014        })
1015    }
1016
1017    pub fn move_shape(
1018        &self,
1019        context: &EditCtx,
1020        slide_id: &str,
1021        shape_id: &str,
1022        x: i64,
1023        y: i64,
1024    ) -> EditResult<TransformReceipt> {
1025        validate_coordinate(x)?;
1026        validate_coordinate(y)?;
1027        let mut txn = self.transact_for(context);
1028        require_shape_membership(&txn, slide_id, shape_id)?;
1029        let shape = shape_ref(&txn, shape_id)?;
1030        self.materialize_inherited(&mut txn, slide_id, &shape)?;
1031        let before = shape_rect(&shape, &txn)?;
1032        shape.insert(&mut txn, "x", x as f64);
1033        shape.insert(&mut txn, "y", y as f64);
1034        Ok(TransformReceipt {
1035            slide_id: slide_id.to_owned(),
1036            shape_id: shape_id.to_owned(),
1037            before,
1038            after: ShapeRect { x, y, ..before },
1039        })
1040    }
1041
1042    /// Moves a shape to the top of its slide's paint order (drawn last).
1043    pub fn bring_to_front(
1044        &self,
1045        context: &EditCtx,
1046        slide_id: &str,
1047        shape_id: &str,
1048    ) -> EditResult<ShapeZOrderReceipt> {
1049        self.reorder_shape(context, slide_id, shape_id, |length, _from| length - 1)
1050    }
1051
1052    /// Moves a shape to the bottom of its slide's paint order (drawn first).
1053    pub fn send_to_back(
1054        &self,
1055        context: &EditCtx,
1056        slide_id: &str,
1057        shape_id: &str,
1058    ) -> EditResult<ShapeZOrderReceipt> {
1059        self.reorder_shape(context, slide_id, shape_id, |_length, _from| 0)
1060    }
1061
1062    /// Swaps a shape one step later in its slide's paint order.
1063    pub fn bring_forward(
1064        &self,
1065        context: &EditCtx,
1066        slide_id: &str,
1067        shape_id: &str,
1068    ) -> EditResult<ShapeZOrderReceipt> {
1069        self.reorder_shape(context, slide_id, shape_id, |length, from| {
1070            (from + 1).min(length - 1)
1071        })
1072    }
1073
1074    /// Swaps a shape one step earlier in its slide's paint order.
1075    pub fn send_backward(
1076        &self,
1077        context: &EditCtx,
1078        slide_id: &str,
1079        shape_id: &str,
1080    ) -> EditResult<ShapeZOrderReceipt> {
1081        self.reorder_shape(context, slide_id, shape_id, |_length, from| {
1082            from.saturating_sub(1)
1083        })
1084    }
1085
1086    fn reorder_shape(
1087        &self,
1088        context: &EditCtx,
1089        slide_id: &str,
1090        shape_id: &str,
1091        to_index: impl FnOnce(u32, u32) -> u32,
1092    ) -> EditResult<ShapeZOrderReceipt> {
1093        let mut txn = self.transact_for(context);
1094        let slide = slide_ref(&txn, slide_id)?;
1095        let order = slide_shape_order(&slide, &txn)?;
1096        let ids = live_shape_order(&order, &txn)?;
1097        let length = ids.len() as u32;
1098        let from_index =
1099            ids.iter()
1100                .position(|id| id == shape_id)
1101                .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))? as u32;
1102        let target = to_index(length, from_index).min(length - 1);
1103        let mut seen = HashSet::new();
1104        let live: HashSet<&str> = ids.iter().map(String::as_str).collect();
1105        let stale: Vec<u32> = string_array_ref(&order, &txn)
1106            .iter()
1107            .enumerate()
1108            .filter_map(|(index, id)| {
1109                (!live.contains(id.as_str()) || !seen.insert(id.clone())).then_some(index as u32)
1110            })
1111            .collect();
1112        for index in stale.into_iter().rev() {
1113            order.remove(&mut txn, index);
1114        }
1115        if target != from_index {
1116            order.remove(&mut txn, from_index);
1117            order.insert(&mut txn, target, shape_id);
1118        }
1119        Ok(ShapeZOrderReceipt {
1120            slide_id: slide_id.to_owned(),
1121            shape_id: shape_id.to_owned(),
1122            from_index,
1123            to_index: target,
1124        })
1125    }
1126
1127    pub fn resize_shape(
1128        &self,
1129        context: &EditCtx,
1130        slide_id: &str,
1131        shape_id: &str,
1132        width: i64,
1133        height: i64,
1134    ) -> EditResult<TransformReceipt> {
1135        let rect = ShapeRect {
1136            width,
1137            height,
1138            ..ShapeRect::default()
1139        };
1140        validate_rect(rect)?;
1141        let mut txn = self.transact_for(context);
1142        require_shape_membership(&txn, slide_id, shape_id)?;
1143        let shape = shape_ref(&txn, shape_id)?;
1144        self.materialize_inherited(&mut txn, slide_id, &shape)?;
1145        let before = shape_rect(&shape, &txn)?;
1146        shape.insert(&mut txn, "width", width as f64);
1147        shape.insert(&mut txn, "height", height as f64);
1148        Ok(TransformReceipt {
1149            slide_id: slide_id.to_owned(),
1150            shape_id: shape_id.to_owned(),
1151            before,
1152            after: ShapeRect {
1153                width,
1154                height,
1155                ..before
1156            },
1157        })
1158    }
1159
1160    pub fn set_shape_rect(
1161        &self,
1162        context: &EditCtx,
1163        slide_id: &str,
1164        shape_id: &str,
1165        rect: ShapeRect,
1166    ) -> EditResult<TransformReceipt> {
1167        validate_rect(rect)?;
1168        let mut txn = self.transact_for(context);
1169        require_shape_membership(&txn, slide_id, shape_id)?;
1170        let shape = shape_ref(&txn, shape_id)?;
1171        self.materialize_inherited(&mut txn, slide_id, &shape)?;
1172        let before = shape_rect(&shape, &txn)?;
1173        shape.insert(&mut txn, "x", rect.x as f64);
1174        shape.insert(&mut txn, "y", rect.y as f64);
1175        shape.insert(&mut txn, "width", rect.width as f64);
1176        shape.insert(&mut txn, "height", rect.height as f64);
1177        Ok(TransformReceipt {
1178            slide_id: slide_id.to_owned(),
1179            shape_id: shape_id.to_owned(),
1180            before,
1181            after: rect,
1182        })
1183    }
1184
1185    /// Gives a placeholder without an extent the whole inherited transform, or,
1186    /// beside a partial transform of its own, just the layout's extent, so a
1187    /// geometry edit never leaves a zero extent behind.
1188    fn materialize_inherited(
1189        &self,
1190        txn: &mut TransactionMut<'_>,
1191        slide_id: &str,
1192        shape: &MapRef,
1193    ) -> EditResult<()> {
1194        let rect = shape_rect(shape, txn)?;
1195        if rect.width > 0 && rect.height > 0 {
1196            return Ok(());
1197        }
1198        let placeholder: Option<Placeholder> = optional_json(shape, txn, "placeholderJson")?;
1199        let source_id = required_u32(shape, txn, "sourceId")?;
1200        let slide = slide_ref(txn, slide_id)?;
1201        let source_part_path = map_string(&slide, txn, "sourcePartPath");
1202        let layout_part_path = map_string(&slide, txn, "layoutPartPath");
1203        let context = SlideContext::new(
1204            &self.package,
1205            source_part_path.as_deref(),
1206            layout_part_path.as_deref(),
1207        );
1208        if let Some(transform) = inherited_transform(&context, source_id, placeholder.as_ref()) {
1209            shape.insert(txn, "x", transform.x as f64);
1210            shape.insert(txn, "y", transform.y as f64);
1211            shape.insert(txn, "width", transform.width as f64);
1212            shape.insert(txn, "height", transform.height as f64);
1213            shape.insert(txn, "rotationDeg", transform.rotation_deg);
1214            shape.insert(txn, "flipH", transform.flip_h);
1215            shape.insert(txn, "flipV", transform.flip_v);
1216        } else if let Some(transform) = placeholder
1217            .as_ref()
1218            .and_then(|placeholder| layout_transform(&context, placeholder))
1219        {
1220            shape.insert(txn, "width", transform.width as f64);
1221            shape.insert(txn, "height", transform.height as f64);
1222        }
1223        Ok(())
1224    }
1225}
1226
1227fn layout_master_path<'a>(package: &'a PptxPackage, layout: &'a SlideLayout) -> Option<&'a str> {
1228    layout.master_part_path.as_deref().or_else(|| {
1229        package
1230            .masters
1231            .iter()
1232            .find(|master| master.layout_part_paths.contains(&layout.part_path))
1233            .map(|master| master.part_path.as_str())
1234    })
1235}
1236
1237fn layout_placeholder(shapes: &[ShapeNode], id: u32) -> Option<&Placeholder> {
1238    shapes.iter().find_map(|shape| {
1239        let base = match shape {
1240            ShapeNode::Shape(shape) => &shape.base,
1241            ShapeNode::Picture(shape) => &shape.base,
1242            ShapeNode::GraphicFrame(shape) => &shape.base,
1243            ShapeNode::Group(shape) => {
1244                if let Some(placeholder) = layout_placeholder(&shape.children, id) {
1245                    return Some(placeholder);
1246                }
1247                &shape.base
1248            }
1249        };
1250        (base.id == id)
1251            .then_some(base.placeholder.as_ref())
1252            .flatten()
1253    })
1254}
1255
1256pub(crate) fn validate_doc(doc: &Doc) -> EditResult<(PptxPackage, DeckSnapshot)> {
1257    let (package, snapshot) = validate_doc_impl(doc, None)?;
1258    Ok((package.expect("package_from_meta decodes"), snapshot))
1259}
1260
1261/// Validates the doc and returns the snapshot it computed internally.
1262/// Passing the session's parsed package skips decoding `meta.packageJson`.
1263pub(crate) fn validated_snapshot(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
1264    validate_doc_impl(doc, Some(package)).map(|(_, snapshot)| snapshot)
1265}
1266
1267fn validate_doc_impl(
1268    doc: &Doc,
1269    package: Option<&PptxPackage>,
1270) -> EditResult<(Option<PptxPackage>, DeckSnapshot)> {
1271    let mut owned = None;
1272    let package = {
1273        let txn = doc.transact();
1274        let meta = required_map(&txn, META)?;
1275        validate_schema_version(&meta, &txn)?;
1276        if map_string(&meta, &txn, "fingerprint").is_none() {
1277            return Err(EditError::InvalidState("missing fingerprint".to_owned()));
1278        }
1279        match package {
1280            Some(package) => {
1281                if !matches!(
1282                    meta.get(&txn, "packageJson"),
1283                    Some(Out::Any(Any::Buffer(_)))
1284                ) {
1285                    return Err(EditError::InvalidState("missing package data".to_owned()));
1286                }
1287                package
1288            }
1289            None => {
1290                owned = Some(package_from_meta(&meta, &txn)?);
1291                owned.as_ref().unwrap()
1292            }
1293        }
1294    };
1295    let snapshot = snapshot_doc(doc, package)?;
1296    if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
1297        return Err(EditError::InvalidState(
1298            "slide dimensions must be positive".to_owned(),
1299        ));
1300    }
1301    for slide in &snapshot.slides {
1302        if let Some(path) = &slide.layout_part_path
1303            && !package
1304                .layouts
1305                .iter()
1306                .any(|layout| &layout.part_path == path)
1307        {
1308            return Err(EditError::InvalidState(format!(
1309                "unknown slide layout {path:?}"
1310            )));
1311        }
1312    }
1313    let txn = doc.transact();
1314    let stories = required_map(&txn, STORIES)?;
1315    let mut shapes: Vec<_> = snapshot
1316        .slides
1317        .iter()
1318        .flat_map(|slide| &slide.shapes)
1319        .collect();
1320    while let Some(shape) = shapes.pop() {
1321        shapes.extend(&shape.children);
1322        crate::table::validate_structure(shape)?;
1323        crate::table::validate_history(&txn, shape)?;
1324        for story in &shape.text_stories {
1325            let key = crate::table::storage_key(&txn, &story.id)?;
1326            let text = stories
1327                .get(&txn, &key)
1328                .and_then(|value| value.cast::<TextRef>().ok())
1329                .ok_or_else(|| {
1330                    EditError::InvalidState(format!("story {} is not text", story.id))
1331                })?;
1332            validate_story(&text, &txn, &story.id)?;
1333        }
1334    }
1335    Ok((owned, snapshot))
1336}
1337
1338/// Shared state for the pending-source import passes run by
1339/// [`DeckSession::open_from_update_with_source`]: one deserialization of the
1340/// doc's package plus the seeded source snapshot built at most once.
1341pub(crate) struct SourceImport<'a> {
1342    /// The source package the update was seeded from.
1343    pub(crate) source: &'a PptxPackage,
1344    /// The doc's package, mutated by each pass and synced back once.
1345    pub(crate) package: PptxPackage,
1346    source_snapshot: Option<DeckSnapshot>,
1347    pub(crate) recovery: crate::source_run_properties::RecoveryCache,
1348}
1349
1350impl<'a> SourceImport<'a> {
1351    pub(crate) fn new(package: PptxPackage, source: &'a PptxPackage) -> Self {
1352        Self {
1353            source,
1354            package,
1355            source_snapshot: None,
1356            recovery: Default::default(),
1357        }
1358    }
1359
1360    /// A seeded snapshot of `source`, built once and reused across passes.
1361    pub(crate) fn source_snapshot(&mut self) -> EditResult<&DeckSnapshot> {
1362        if self.source_snapshot.is_none() {
1363            self.source_snapshot = Some(baseline_snapshot(self.source)?);
1364        }
1365        Ok(self.source_snapshot.as_ref().unwrap())
1366    }
1367
1368    /// Writes the accumulated package back iff an import changed it.
1369    pub(crate) fn sync_package_json(self, doc: &Doc, baseline: &PptxPackage) -> EditResult<()> {
1370        if &self.package == baseline {
1371            return Ok(());
1372        }
1373        let bytes = serde_json::to_vec(&self.package)
1374            .map_err(|error| EditError::Json(error.to_string()))?;
1375        let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1376        let meta = required_map(&txn, META)?;
1377        meta.insert(&mut txn, "packageJson", Any::Buffer(Arc::from(bytes)));
1378        Ok(())
1379    }
1380}
1381
1382#[cfg(test)]
1383pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
1384    let txn = doc.transact();
1385    let meta = required_map(&txn, META)?;
1386    validate_schema_version(&meta, &txn)?;
1387    package_from_meta(&meta, &txn)
1388}
1389
1390pub(crate) fn import_source_render_data(
1391    doc: &Doc,
1392    import: &mut SourceImport<'_>,
1393) -> EditResult<()> {
1394    let source = import.source;
1395    let package = &mut import.package;
1396    let sources = source
1397        .slides
1398        .iter()
1399        .map(|part| (&part.part_path, (&part.shapes, &part.background_picture)))
1400        .chain(
1401            source
1402                .layouts
1403                .iter()
1404                .map(|part| (&part.part_path, (&part.shapes, &part.background_picture))),
1405        )
1406        .chain(
1407            source
1408                .masters
1409                .iter()
1410                .map(|part| (&part.part_path, (&part.shapes, &part.background_picture))),
1411        )
1412        .collect::<HashMap<_, _>>();
1413    let mut changed = false;
1414    for (path, (shapes, picture)) in package
1415        .slides
1416        .iter_mut()
1417        .map(|part| {
1418            (
1419                &part.part_path,
1420                (&mut part.shapes, &mut part.background_picture),
1421            )
1422        })
1423        .chain(package.layouts.iter_mut().map(|part| {
1424            (
1425                &part.part_path,
1426                (&mut part.shapes, &mut part.background_picture),
1427            )
1428        }))
1429        .chain(package.masters.iter_mut().map(|part| {
1430            (
1431                &part.part_path,
1432                (&mut part.shapes, &mut part.background_picture),
1433            )
1434        }))
1435    {
1436        if let Some((source_shapes, source_picture)) = sources.get(path) {
1437            changed |= merge_source_render_shapes(shapes, source_shapes);
1438            if picture != *source_picture {
1439                picture.clone_from(source_picture);
1440                changed = true;
1441            }
1442        }
1443    }
1444    for theme in &mut package.themes {
1445        if let Some(source) = source
1446            .themes
1447            .iter()
1448            .find(|source| source.part_path == theme.part_path)
1449        {
1450            if theme.background_pictures != source.background_pictures {
1451                theme
1452                    .background_pictures
1453                    .clone_from(&source.background_pictures);
1454                changed = true;
1455            }
1456            if theme.format_scheme.effects.is_empty() && !source.format_scheme.effects.is_empty() {
1457                theme
1458                    .format_scheme
1459                    .effects
1460                    .clone_from(&source.format_scheme.effects);
1461                changed = true;
1462            }
1463        }
1464    }
1465    for master in &mut package.masters {
1466        if let Some(source) = source
1467            .masters
1468            .iter()
1469            .find(|source| source.part_path == master.part_path)
1470        {
1471            let target = &mut master.text_styles;
1472            let source = &source.text_styles;
1473            for (target, source) in target
1474                .title
1475                .iter_mut()
1476                .zip(&source.title)
1477                .chain(target.body.iter_mut().zip(&source.body))
1478                .chain(target.other.iter_mut().zip(&source.other))
1479            {
1480                changed |= merge_source_paragraph_properties(target, source);
1481            }
1482        }
1483    }
1484    for chart in &mut package.charts {
1485        if let Some(source) = source
1486            .charts
1487            .iter()
1488            .find(|source| source.part_path == chart.part_path)
1489        {
1490            changed |= merge_source_chart_properties(&mut chart.chart, &source.chart);
1491        }
1492    }
1493    for slide in &mut package.slides {
1494        let Some(source) = source
1495            .slides
1496            .iter()
1497            .find(|source| source.part_path == slide.part_path)
1498        else {
1499            continue;
1500        };
1501        if slide.notes != source.notes {
1502            slide.notes.clone_from(&source.notes);
1503            changed = true;
1504        }
1505        if slide.background != source.background {
1506            slide.background.clone_from(&source.background);
1507            changed = true;
1508        }
1509        if slide.declares_background != source.declares_background {
1510            slide.declares_background = source.declares_background;
1511            changed = true;
1512        }
1513    }
1514    if package.table_styles != source.table_styles {
1515        package.table_styles.clone_from(&source.table_styles);
1516        changed = true;
1517    }
1518    if !changed {
1519        return Ok(());
1520    }
1521    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1522    backfill_blip_effects(&mut txn, package)?;
1523    backfill_tables(&mut txn, package)?;
1524    Ok(())
1525}
1526
1527fn merge_source_render_shapes(target: &mut [ShapeNode], source: &[ShapeNode]) -> bool {
1528    let mut changed = false;
1529    for source in source {
1530        let Some(target) = target
1531            .iter_mut()
1532            .find(|target| shape_base(target).id == shape_base(source).id)
1533        else {
1534            continue;
1535        };
1536        match (target, source) {
1537            (ShapeNode::Shape(target), ShapeNode::Shape(source)) => {
1538                changed |= merge_source_effect_reference(&mut target.style, &source.style);
1539                changed |= target.picture_fill != source.picture_fill;
1540                target.picture_fill.clone_from(&source.picture_fill);
1541                if let (Some(target), Some(source)) = (&mut target.text, &source.text) {
1542                    changed |= merge_source_text_body(target, source);
1543                }
1544            }
1545            (ShapeNode::Picture(target), ShapeNode::Picture(source)) => {
1546                changed |= merge_source_effect_reference(&mut target.style, &source.style);
1547                if target.effects.is_empty() && !source.effects.is_empty() {
1548                    target.effects.clone_from(&source.effects);
1549                    changed = true;
1550                }
1551                if target.paths.is_empty() && !source.paths.is_empty() {
1552                    target.paths.clone_from(&source.paths);
1553                    changed = true;
1554                }
1555            }
1556            (ShapeNode::Group(target), ShapeNode::Group(source)) => {
1557                changed |= merge_source_render_shapes(&mut target.children, &source.children);
1558            }
1559            (ShapeNode::GraphicFrame(target), ShapeNode::GraphicFrame(source)) => {
1560                if matches!(
1561                    target.data,
1562                    pptx_parse::GraphicFrameData::Unknown { picture: None, .. }
1563                ) && matches!(
1564                    source.data,
1565                    pptx_parse::GraphicFrameData::Unknown {
1566                        picture: Some(_),
1567                        ..
1568                    }
1569                ) {
1570                    target.data.clone_from(&source.data);
1571                    changed = true;
1572                }
1573                if let (
1574                    pptx_parse::GraphicFrameData::Table(target),
1575                    pptx_parse::GraphicFrameData::Table(source),
1576                ) = (&mut target.data, &source.data)
1577                {
1578                    changed |= merge_source_table(target, source);
1579                }
1580            }
1581            _ => {}
1582        }
1583    }
1584    changed
1585}
1586
1587fn merge_source_effect_reference(
1588    target: &mut Option<Box<ShapeStyle>>,
1589    source: &Option<Box<ShapeStyle>>,
1590) -> bool {
1591    let Some(effect) = source.as_ref().and_then(|style| style.effect.as_ref()) else {
1592        return false;
1593    };
1594    if target.as_ref().is_some_and(|style| style.effect.is_some()) {
1595        return false;
1596    }
1597    target.get_or_insert_with(Default::default).effect = Some(effect.clone());
1598    true
1599}
1600
1601/// Copies the chart-space fill, axis lines, series lines and text properties a
1602/// source chart part declares.
1603fn merge_source_chart_properties(target: &mut ChartSpace, source: &ChartSpace) -> bool {
1604    let mut changed = target.fill != source.fill;
1605    target.fill.clone_from(&source.fill);
1606    changed |= target.text != source.text || target.title_text != source.title_text;
1607    target.text.clone_from(&source.text);
1608    target.title_text.clone_from(&source.title_text);
1609    if let (Some(target), Some(source)) = (&mut target.legend, &source.legend) {
1610        changed |= target.text != source.text;
1611        target.text.clone_from(&source.text);
1612    }
1613    for (target, source) in target.plot_groups.iter_mut().zip(&source.plot_groups) {
1614        for (target, source) in target.series.iter_mut().zip(&source.series) {
1615            changed |= target.line != source.line;
1616            target.line.clone_from(&source.line);
1617        }
1618    }
1619    let mut merge_axis = |target: Option<&mut ChartAxis>, source: Option<&ChartAxis>| {
1620        if let (Some(target), Some(source)) = (target, source) {
1621            changed |= target.line != source.line || target.text != source.text;
1622            target.line.clone_from(&source.line);
1623            target.text.clone_from(&source.text);
1624        }
1625    };
1626    if let (Some(target), Some(source)) = (&mut target.axes, &source.axes) {
1627        merge_axis(target.category.as_mut(), source.category.as_ref());
1628        merge_axis(target.value.as_mut(), source.value.as_ref());
1629    }
1630    if let (Some(target), Some(source)) = (&mut target.axis_list, &source.axis_list) {
1631        for (target, source) in target.iter_mut().zip(source) {
1632            merge_axis(Some(target), Some(source));
1633        }
1634    }
1635    changed
1636}
1637
1638fn backfill_tables(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1639    let shapes = required_map(txn, SHAPES)?;
1640    for (index, (slide, reference)) in package
1641        .slides
1642        .iter()
1643        .zip(&package.presentation.slides)
1644        .enumerate()
1645    {
1646        let slide_id = seeded_slide_id(index, reference.id);
1647        for (index, node) in slide.shapes.iter().enumerate() {
1648            backfill_table(txn, &shapes, &slide_id, &index.to_string(), node)?;
1649        }
1650    }
1651    Ok(())
1652}
1653
1654fn backfill_table(
1655    txn: &mut TransactionMut<'_>,
1656    shapes: &MapRef,
1657    slide_id: &str,
1658    path: &str,
1659    node: &ShapeNode,
1660) -> EditResult<()> {
1661    if let ShapeNode::GraphicFrame(frame) = node
1662        && let pptx_parse::GraphicFrameData::Table(table) = &frame.data
1663        && !table.grid.is_empty()
1664        && let Some(shape) = shapes
1665            .get(txn, &seeded_shape_id(slide_id, path))
1666            .and_then(|value| value.cast::<MapRef>().ok())
1667        && let Some(pptx_parse::GraphicFrameData::Table(mut stored)) =
1668            optional_json(&shape, txn, "graphicJson")?
1669    {
1670        let changed = if map_string(&shape, txn, "tableStructureJson").is_some() {
1671            false
1672        } else if stored.grid.is_empty() {
1673            stored = table.clone();
1674            true
1675        } else {
1676            merge_source_cell_pictures(&mut stored, table)
1677        };
1678        if changed {
1679            insert_json(
1680                &shape,
1681                txn,
1682                "graphicJson",
1683                Some(&pptx_parse::GraphicFrameData::Table(stored)),
1684            )?;
1685        }
1686    }
1687    if let ShapeNode::Group(group) = node {
1688        for (index, child) in group.children.iter().enumerate() {
1689            backfill_table(
1690                txn,
1691                shapes,
1692                slide_id,
1693                &seeded_child_path(path, index),
1694                child,
1695            )?;
1696        }
1697    }
1698    Ok(())
1699}
1700
1701/// Restores the grid, spans and cell formatting a stored package dropped.
1702fn merge_source_table(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
1703    let mut changed = target.grid != source.grid || target.properties != source.properties;
1704    changed |= merge_source_cell_pictures(target, source);
1705    target.grid.clone_from(&source.grid);
1706    target.properties.clone_from(&source.properties);
1707    for (target, source) in target.rows.iter_mut().zip(&source.rows) {
1708        changed |= target.height != source.height;
1709        target.height = source.height;
1710        for (target, source) in target.cells.iter_mut().zip(&source.cells) {
1711            changed |= target.grid_span != source.grid_span
1712                || target.row_span != source.row_span
1713                || target.merged != source.merged
1714                || target.fill != source.fill
1715                || target.borders != source.borders;
1716            target.grid_span = source.grid_span;
1717            target.row_span = source.row_span;
1718            target.merged = source.merged;
1719            target.fill.clone_from(&source.fill);
1720            target.borders.clone_from(&source.borders);
1721            changed |= merge_source_text_body(&mut target.text, &source.text);
1722        }
1723    }
1724    changed
1725}
1726
1727fn merge_source_cell_pictures(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
1728    let mut changed = false;
1729    for (target, source) in target.rows.iter_mut().zip(&source.rows) {
1730        for (target, source) in target.cells.iter_mut().zip(&source.cells) {
1731            changed |= target.picture_fill != source.picture_fill;
1732            target.picture_fill.clone_from(&source.picture_fill);
1733        }
1734    }
1735    changed
1736}
1737
1738fn merge_source_text_body(
1739    target: &mut pptx_parse::TextBody,
1740    source: &pptx_parse::TextBody,
1741) -> bool {
1742    let mut changed = target.list_style != source.list_style
1743        || target.default_list_style != source.default_list_style
1744        || target.compat_line_spacing != source.compat_line_spacing
1745        || target.num_col != source.num_col
1746        || target.spc_col != source.spc_col
1747        || target.rtl_col != source.rtl_col
1748        || target.vertical_overflow != source.vertical_overflow
1749        || target.horizontal_overflow != source.horizontal_overflow;
1750    target.vertical_overflow = source.vertical_overflow;
1751    target.horizontal_overflow = source.horizontal_overflow;
1752    target.compat_line_spacing = source.compat_line_spacing;
1753    target.num_col = source.num_col;
1754    target.spc_col = source.spc_col;
1755    target.rtl_col = source.rtl_col;
1756    target.list_style.clone_from(&source.list_style);
1757    target
1758        .default_list_style
1759        .clone_from(&source.default_list_style);
1760    for (target, source) in target.paragraphs.iter_mut().zip(&source.paragraphs) {
1761        changed |= merge_source_paragraph_properties(&mut target.properties, &source.properties);
1762    }
1763    changed
1764}
1765
1766fn merge_source_paragraph_properties(
1767    target: &mut pptx_parse::ParagraphProperties,
1768    source: &pptx_parse::ParagraphProperties,
1769) -> bool {
1770    let mut changed = target.bullet_font != source.bullet_font
1771        || target.bullet_color != source.bullet_color
1772        || target.bullet_size != source.bullet_size
1773        || target.line_spacing != source.line_spacing
1774        || target.margin_right != source.margin_right
1775        || target.space_before != source.space_before
1776        || target.space_after != source.space_after
1777        || target.default_tab_size != source.default_tab_size
1778        || target.tab_stops != source.tab_stops;
1779    target.line_spacing = source.line_spacing;
1780    target.margin_right = source.margin_right;
1781    target.space_before = source.space_before;
1782    target.space_after = source.space_after;
1783    target.default_tab_size = source.default_tab_size;
1784    target.tab_stops.clone_from(&source.tab_stops);
1785    if let (
1786        Some(pptx_parse::Bullet::AutoNumber {
1787            restart: target, ..
1788        }),
1789        Some(pptx_parse::Bullet::AutoNumber {
1790            restart: source, ..
1791        }),
1792    ) = (&mut target.bullet, &source.bullet)
1793    {
1794        changed |= *target != *source;
1795        *target = *source;
1796    }
1797    target.bullet_font.clone_from(&source.bullet_font);
1798    target.bullet_color.clone_from(&source.bullet_color);
1799    target.bullet_size.clone_from(&source.bullet_size);
1800    changed
1801}
1802
1803pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
1804    let txn = doc.transact();
1805    let meta = required_map(&txn, META)?;
1806    map_string(&meta, &txn, "fingerprint")
1807        .ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
1808}
1809
1810/// Carries a released 1.0, 2.0, 2.1 or 2.2 document forward to the current schema.
1811pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
1812    let version = {
1813        let txn = doc.transact();
1814        let meta = required_map(&txn, META)?;
1815        schema_version(&meta, &txn)?
1816    };
1817    let package = if version < 2.1 {
1818        migrate_doc_to_v2_1(doc)?
1819    } else if version < 2.2 {
1820        migrate_doc_to_v2_2(doc)?
1821    } else if version < SCHEMA_VERSION {
1822        return migrate_doc_to_v2_3(doc);
1823    } else {
1824        return Ok(());
1825    };
1826    restore_preset_defaults(doc, &package)
1827}
1828
1829/// Defers the run kern thresholds a 2.2 story and package lack to the source.
1830fn migrate_doc_to_v2_3(doc: &Doc) -> EditResult<()> {
1831    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1832    let meta = required_map(&txn, META)?;
1833    meta.insert(&mut txn, "kernPendingSource", true);
1834    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1835    Ok(())
1836}
1837
1838/// Rewrites the stored package so media bytes ride as base64 strings rather
1839/// than the integer arrays 2.1 wrote, and defers the 2.1 seed's runs.
1840fn migrate_doc_to_v2_2(doc: &Doc) -> EditResult<PptxPackage> {
1841    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1842    let meta = required_map(&txn, META)?;
1843    let package = package_from_meta(&meta, &txn)?;
1844    let package_json =
1845        serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1846    meta.insert(
1847        &mut txn,
1848        "packageJson",
1849        Any::Buffer(Arc::from(package_json)),
1850    );
1851    meta.insert(&mut txn, "kernPendingSource", true);
1852    defer_legacy_runs(&mut txn, &meta);
1853    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1854    Ok(package)
1855}
1856
1857/// Defers to the source the run caps a pre-2.2 seed lacked and the run colours
1858/// it resolved without the slide colour map.
1859fn defer_legacy_runs(txn: &mut TransactionMut<'_>, meta: &MapRef) {
1860    meta.insert(txn, "capsPendingSource", true);
1861    meta.insert(txn, "colorsPendingSource", true);
1862}
1863
1864/// Moves the preset adjust defaults a pre-2.2 seed stored onto the current
1865/// ones. Runs after the schema change commits, so the rewritten package the
1866/// seed view below would otherwise copy has been collected.
1867fn restore_preset_defaults(doc: &Doc, package: &PptxPackage) -> EditResult<()> {
1868    let seeded = seeded_adjustments(doc)?;
1869    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1870    let shapes = required_map(&txn, SHAPES)?;
1871    for (index, (slide, reference)) in package
1872        .slides
1873        .iter()
1874        .zip(&package.presentation.slides)
1875        .enumerate()
1876    {
1877        let slide_id = seeded_slide_id(index, reference.id);
1878        for (index, node) in slide.shapes.iter().enumerate() {
1879            restore_shape_defaults(
1880                &mut txn,
1881                &shapes,
1882                &seeded,
1883                &slide_id,
1884                &index.to_string(),
1885                node,
1886            )?;
1887        }
1888    }
1889    Ok(())
1890}
1891
1892/// The shapes whose `adjustValuesJson` is still the value the seed wrote: the
1893/// seed's own items, with every later deletion or overwrite applied.
1894fn seeded_adjustments(doc: &Doc) -> EditResult<HashSet<String>> {
1895    let bootstrap = ClientID::new(crate::BOOTSTRAP_CLIENT_ID);
1896    let update = {
1897        let txn = doc.transact();
1898        let mut others = StateVector::default();
1899        for (client, clock) in txn.state_vector().iter() {
1900            if *client != bootstrap {
1901                others.set_max(*client, *clock);
1902            }
1903        }
1904        txn.encode_state_as_update_v1(&others)
1905    };
1906    let view = Doc::new();
1907    let mut txn = view.transact_mut();
1908    txn.apply_update(
1909        Update::decode_v1(&update).map_err(|error| EditError::InvalidUpdate(error.to_string()))?,
1910    )
1911    .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
1912    let Some(shapes) = txn.get_map(SHAPES) else {
1913        return Ok(HashSet::new());
1914    };
1915    Ok(shapes
1916        .iter(&txn)
1917        .filter(|(_, shape)| {
1918            shape
1919                .clone()
1920                .cast::<MapRef>()
1921                .is_ok_and(|shape| shape.contains_key(&txn, "adjustValuesJson"))
1922        })
1923        .map(|(id, _)| id.to_owned())
1924        .collect())
1925}
1926
1927/// Replaces each adjust value the seed stored that is still a pre-2.2 default
1928/// the source never authored with the current default.
1929fn restore_shape_defaults(
1930    txn: &mut TransactionMut<'_>,
1931    shapes: &MapRef,
1932    seeded: &HashSet<String>,
1933    slide_id: &str,
1934    path: &str,
1935    node: &ShapeNode,
1936) -> EditResult<()> {
1937    match node {
1938        ShapeNode::Shape(source) => {
1939            let id = seeded_shape_id(slide_id, path);
1940            let Some(shape) = seeded
1941                .contains(&id)
1942                .then(|| shapes.get(txn, &id))
1943                .flatten()
1944                .and_then(|value| value.cast::<MapRef>().ok())
1945            else {
1946                return Ok(());
1947            };
1948            let legacy: BTreeMap<_, _> = legacy_preset_defaults(&source.geometry)
1949                .iter()
1950                .copied()
1951                .collect();
1952            let current = preset_geometry_default_adjustments(&source.geometry);
1953            let mut values: BTreeMap<String, f64> =
1954                optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default();
1955            let mut changed = false;
1956            for name in legacy
1957                .keys()
1958                .copied()
1959                .chain(current.keys().map(String::as_str))
1960            {
1961                let (before, after) = (legacy.get(name).copied(), current.get(name).copied());
1962                if before == after
1963                    || source.adjust_values.contains_key(name)
1964                    || values.get(name).copied() != before
1965                {
1966                    continue;
1967                }
1968                match after {
1969                    Some(value) => values.insert(name.to_owned(), value),
1970                    None => values.remove(name),
1971                };
1972                changed = true;
1973            }
1974            if changed {
1975                insert_json(&shape, txn, "adjustValuesJson", Some(&values))?;
1976            }
1977        }
1978        ShapeNode::Group(group) => {
1979            for (index, child) in group.children.iter().enumerate() {
1980                restore_shape_defaults(
1981                    txn,
1982                    shapes,
1983                    seeded,
1984                    slide_id,
1985                    &seeded_child_path(path, index),
1986                    child,
1987                )?;
1988            }
1989        }
1990        _ => {}
1991    }
1992    Ok(())
1993}
1994
1995/// The preset adjust defaults seeds before schema 2.2 stored.
1996fn legacy_preset_defaults(geometry: &str) -> &'static [(&'static str, f64)] {
1997    match geometry {
1998        "roundRect" => &[("adj", 0.166_67)],
1999        "plus" | "parallelogram" | "hexagon" => &[("adj", 0.25)],
2000        "triangle" | "isosTriangle" | "chevron" | "homePlate" => &[("adj", 0.5)],
2001        "trapezoid" => &[("adj", 0.2)],
2002        "octagon" => &[("adj", 0.292_89)],
2003        "rightArrow" | "leftArrow" | "upArrow" | "downArrow" => &[("adj1", 0.5), ("adj2", 0.5)],
2004        "star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16"
2005        | "star24" | "star32" => &[("adj", 0.45)],
2006        _ => &[],
2007    }
2008}
2009
2010/// Applies every schema change made since 2.0 in one transaction: the package is
2011/// rewritten through the current model, hidden flags and bitmap effects are
2012/// backfilled, the comment flavour is recorded, and everything a stored package
2013/// cannot carry -- baselines, outline gradients, character spacing, caps,
2014/// colour-mapped run colours, OLE picture previews, chart and paragraph
2015/// properties, table geometry -- is deferred to [`import_source_render_data`]
2016/// until the source is reattached.
2017fn migrate_doc_to_v2_1(doc: &Doc) -> EditResult<PptxPackage> {
2018    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
2019    let meta = required_map(&txn, META)?;
2020    let package = package_from_meta(&meta, &txn)?;
2021    let package_json =
2022        serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
2023    meta.insert(
2024        &mut txn,
2025        "packageJson",
2026        Any::Buffer(Arc::from(package_json)),
2027    );
2028    backfill_hidden(&mut txn, &package)?;
2029    record_comment_flavor(&mut txn, &meta, &package);
2030    meta.insert(&mut txn, "baselinesPendingSource", true);
2031    meta.insert(&mut txn, "outlineGradientsPendingSource", true);
2032    backfill_blip_effects(&mut txn, &package)?;
2033    backfill_tables(&mut txn, &package)?;
2034    meta.insert(&mut txn, "spacingPendingSource", true);
2035    meta.insert(&mut txn, "kernPendingSource", true);
2036    if package_needs_ole_source(&package) {
2037        meta.insert(&mut txn, "olePicturesPendingSource", true);
2038    }
2039    defer_legacy_runs(&mut txn, &meta);
2040    meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
2041    Ok(package)
2042}
2043
2044fn record_comment_flavor(txn: &mut TransactionMut<'_>, meta: &MapRef, package: &PptxPackage) {
2045    if meta.contains_key(txn, "commentFlavor") {
2046        return;
2047    }
2048    if !package.comments.is_empty()
2049        || package
2050            .relationships
2051            .values()
2052            .flatten()
2053            .any(|relationship| {
2054                relationship.is_type(pptx_parse::relationship_types::COMMENTS)
2055                    || relationship.is_type(pptx_parse::relationship_types::MODERN_COMMENTS)
2056            })
2057    {
2058        meta.insert(txn, "commentsPendingSource", true);
2059    }
2060    meta.insert(
2061        txn,
2062        "commentFlavor",
2063        flavor_key(package.comment_flavor.unwrap_or_default()),
2064    );
2065}
2066
2067fn backfill_blip_effects(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
2068    let shapes = required_map(txn, SHAPES)?;
2069    for (index, (slide, reference)) in package
2070        .slides
2071        .iter()
2072        .zip(&package.presentation.slides)
2073        .enumerate()
2074    {
2075        let slide_id = seeded_slide_id(index, reference.id);
2076        for (index, node) in slide.shapes.iter().enumerate() {
2077            backfill_picture_effects(txn, &shapes, &slide_id, &index.to_string(), node)?;
2078        }
2079    }
2080    Ok(())
2081}
2082
2083fn backfill_picture_effects(
2084    txn: &mut TransactionMut<'_>,
2085    shapes: &MapRef,
2086    slide_id: &str,
2087    path: &str,
2088    node: &ShapeNode,
2089) -> EditResult<()> {
2090    match node {
2091        ShapeNode::Picture(picture) if !picture.effects.is_empty() => {
2092            let id = seeded_shape_id(slide_id, path);
2093            if let Some(shape) = shapes
2094                .get(txn, &id)
2095                .and_then(|shape| shape.cast::<MapRef>().ok())
2096                && !shape.contains_key(txn, "blipEffectsJson")
2097            {
2098                insert_json(&shape, txn, "blipEffectsJson", Some(&picture.effects))?;
2099            }
2100        }
2101        ShapeNode::Group(group) => {
2102            for (index, child) in group.children.iter().enumerate() {
2103                backfill_picture_effects(
2104                    txn,
2105                    shapes,
2106                    slide_id,
2107                    &seeded_child_path(path, index),
2108                    child,
2109                )?;
2110            }
2111        }
2112        _ => {}
2113    }
2114    Ok(())
2115}
2116
2117fn package_needs_ole_source(package: &PptxPackage) -> bool {
2118    package
2119        .slides
2120        .iter()
2121        .any(|part| needs_ole_source(&part.shapes))
2122        || package
2123            .layouts
2124            .iter()
2125            .any(|part| needs_ole_source(&part.shapes))
2126        || package
2127            .masters
2128            .iter()
2129            .any(|part| needs_ole_source(&part.shapes))
2130}
2131
2132fn needs_ole_source(nodes: &[ShapeNode]) -> bool {
2133    nodes.iter().any(|node| match node {
2134        ShapeNode::GraphicFrame(frame) => matches!(&frame.data,
2135            pptx_parse::GraphicFrameData::Unknown { uri: Some(uri), picture: None }
2136                if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole"),
2137        ShapeNode::Group(group) => needs_ole_source(&group.children),
2138        _ => false,
2139    })
2140}
2141
2142pub(crate) fn import_source_ole_pictures(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
2143    let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
2144    let meta = required_map(&txn, META)?;
2145    if map_bool(&meta, &txn, "olePicturesPendingSource") != Some(true) {
2146        return Ok(());
2147    }
2148    let shapes = required_map(&txn, SHAPES)?;
2149    for (index, (slide, reference)) in source
2150        .slides
2151        .iter()
2152        .zip(&source.presentation.slides)
2153        .enumerate()
2154    {
2155        let slide_id = seeded_slide_id(index, reference.id);
2156        for (index, node) in slide.shapes.iter().enumerate() {
2157            backfill_ole_picture(&mut txn, &shapes, &slide_id, &index.to_string(), node)?;
2158        }
2159    }
2160    meta.remove(&mut txn, "olePicturesPendingSource");
2161    Ok(())
2162}
2163
2164fn backfill_ole_picture(
2165    txn: &mut TransactionMut<'_>,
2166    shapes: &MapRef,
2167    slide_id: &str,
2168    path: &str,
2169    node: &ShapeNode,
2170) -> EditResult<()> {
2171    if let ShapeNode::GraphicFrame(frame) = node
2172        && matches!(
2173            frame.data,
2174            pptx_parse::GraphicFrameData::Unknown {
2175                picture: Some(_),
2176                ..
2177            }
2178        )
2179        && let Some(shape) = shapes
2180            .get(txn, &seeded_shape_id(slide_id, path))
2181            .and_then(|value| value.cast::<MapRef>().ok())
2182        && matches!(
2183            optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
2184            Some(pptx_parse::GraphicFrameData::Unknown { picture: None, .. })
2185        )
2186    {
2187        insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
2188    }
2189    if let ShapeNode::Group(group) = node {
2190        for (index, child) in group.children.iter().enumerate() {
2191            backfill_ole_picture(
2192                txn,
2193                shapes,
2194                slide_id,
2195                &seeded_child_path(path, index),
2196                child,
2197            )?;
2198        }
2199    }
2200    Ok(())
2201}
2202
2203fn backfill_hidden(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
2204    let shapes = required_map(txn, SHAPES)?;
2205    for shape_id in seeded_hidden_shape_ids(package) {
2206        if let Some(shape) = shapes
2207            .get(txn, &shape_id)
2208            .and_then(|value| value.cast::<MapRef>().ok())
2209        {
2210            shape.insert(txn, "hidden", true);
2211        }
2212    }
2213    Ok(())
2214}
2215
2216fn seeded_hidden_shape_ids(package: &PptxPackage) -> Vec<String> {
2217    let mut ids = Vec::new();
2218    for (slide_index, (slide, reference)) in package
2219        .slides
2220        .iter()
2221        .zip(&package.presentation.slides)
2222        .enumerate()
2223    {
2224        let slide_id = seeded_slide_id(slide_index, reference.id);
2225        for (shape_index, shape) in slide.shapes.iter().enumerate() {
2226            collect_hidden_shape_ids(&slide_id, &shape_index.to_string(), shape, &mut ids);
2227        }
2228    }
2229    ids
2230}
2231
2232fn collect_hidden_shape_ids(slide_id: &str, path: &str, shape: &ShapeNode, ids: &mut Vec<String>) {
2233    if shape_base(shape).hidden {
2234        ids.push(seeded_shape_id(slide_id, path));
2235    }
2236    if let ShapeNode::Group(group) = shape {
2237        for (child_index, child) in group.children.iter().enumerate() {
2238            collect_hidden_shape_ids(slide_id, &seeded_child_path(path, child_index), child, ids);
2239        }
2240    }
2241}
2242
2243fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
2244    map_number(meta, txn, "schemaVersion")
2245        .filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
2246        .ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
2247}
2248
2249fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
2250    schema_version(meta, txn).map(|_| ())
2251}
2252
2253fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
2254    let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
2255        return Err(EditError::InvalidState("missing package data".to_owned()));
2256    };
2257    serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
2258}
2259
2260pub(crate) fn live_shape_order<T: ReadTxn>(order: &ArrayRef, txn: &T) -> EditResult<Vec<String>> {
2261    let shapes = required_map(txn, SHAPES)?;
2262    let mut seen = HashSet::new();
2263    Ok(string_array_ref(order, txn)
2264        .into_iter()
2265        .filter(|id| {
2266            shapes.contains_key(txn, id)
2267                && !entry_is_deleted(&shapes, txn, id)
2268                && seen.insert(id.clone())
2269        })
2270        .collect())
2271}
2272
2273fn snapshot_slide<T: ReadTxn>(
2274    slides: &MapRef,
2275    shapes: &MapRef,
2276    stories: &MapRef,
2277    package: &PptxPackage,
2278    txn: &T,
2279    slide_id: &str,
2280) -> EditResult<SlideSnapshot> {
2281    let SlideParts {
2282        mut snapshot,
2283        shape_ids,
2284        theme,
2285    } = slide_parts(slides, package, txn, slide_id)?;
2286    let slide = slide_ref(txn, slide_id)?;
2287    snapshot.notes = slide_notes(&slide, txn, package);
2288    (snapshot.background, snapshot.background_edited) = slide_background(&slide, txn, package);
2289    for shape_id in shape_ids {
2290        snapshot.shapes.push(snapshot_shape(
2291            shapes,
2292            stories,
2293            txn,
2294            &shape_id,
2295            &mut HashSet::new(),
2296            Some(&theme),
2297        )?);
2298    }
2299    record_inherited(
2300        &mut snapshot.shapes,
2301        &SlideContext::new(
2302            package,
2303            snapshot.source_part_path.as_deref(),
2304            snapshot.layout_part_path.as_deref(),
2305        ),
2306    );
2307    Ok(snapshot)
2308}
2309
2310/// A slide's own fields, without its notes or shapes, and the ids of its live top-level shapes.
2311pub(crate) struct SlideParts {
2312    pub snapshot: SlideSnapshot,
2313    pub shape_ids: Vec<String>,
2314    pub theme: Theme,
2315}
2316
2317pub(crate) fn slide_parts<T: ReadTxn>(
2318    slides: &MapRef,
2319    package: &PptxPackage,
2320    txn: &T,
2321    slide_id: &str,
2322) -> EditResult<SlideParts> {
2323    let slide = live_entry(slides, txn, slide_id)
2324        .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
2325    let source_part_path = map_string(&slide, txn, "sourcePartPath");
2326    let layout_part_path = map_string(&slide, txn, "layoutPartPath");
2327    let theme = pptx_parse::slide_theme(
2328        package,
2329        source_part_path.as_deref(),
2330        layout_part_path.as_deref(),
2331    );
2332    let shape_order = slide_shape_order(&slide, txn)?;
2333    Ok(SlideParts {
2334        shape_ids: live_shape_order(&shape_order, txn)?,
2335        snapshot: SlideSnapshot {
2336            title: None,
2337            hidden: None,
2338            layout: None,
2339            id: slide_id.to_owned(),
2340            source_part_path,
2341            layout_part_path,
2342            name: map_string(&slide, txn, "name"),
2343            notes: String::new(),
2344            background: None,
2345            background_edited: false,
2346            shapes: Vec::new(),
2347        },
2348        theme,
2349    })
2350}
2351
2352pub(crate) fn slide_scope(
2353    doc: &Doc,
2354    package: &PptxPackage,
2355    slide_index: usize,
2356) -> EditResult<SlideScope> {
2357    let txn = doc.transact();
2358    let meta = required_map(&txn, META)?;
2359    let slides = required_map(&txn, SLIDES)?;
2360    let shapes = required_map(&txn, SHAPES)?;
2361    let stories = required_map(&txn, STORIES)?;
2362    let mut ids = live_slide_ids(&txn)?;
2363    if slide_index >= ids.len() {
2364        return Err(EditError::OutOfBounds {
2365            index: slide_index.min(u32::MAX as usize) as u32,
2366            length: ids.len() as u32,
2367        });
2368    }
2369    let slide_id = ids.swap_remove(slide_index);
2370    Ok(SlideScope {
2371        index: slide_index,
2372        slide: snapshot_slide(&slides, &shapes, &stories, package, &txn, &slide_id)?,
2373        width_emu: required_i64(&meta, &txn, "widthEmu")?,
2374        height_emu: required_i64(&meta, &txn, "heightEmu")?,
2375    })
2376}
2377
2378pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
2379    let txn = doc.transact();
2380    let meta = required_map(&txn, META)?;
2381    let slides = required_map(&txn, SLIDES)?;
2382    let shapes = required_map(&txn, SHAPES)?;
2383    let stories = required_map(&txn, STORIES)?;
2384    let mut slide_snapshots = Vec::new();
2385    for slide_id in live_slide_ids(&txn)? {
2386        slide_snapshots.push(snapshot_slide(
2387            &slides, &shapes, &stories, package, &txn, &slide_id,
2388        )?);
2389    }
2390    Ok(DeckSnapshot {
2391        width_emu: required_i64(&meta, &txn, "widthEmu")?,
2392        height_emu: required_i64(&meta, &txn, "heightEmu")?,
2393        slides: slide_snapshots,
2394        comment_flavor: snapshot_flavor(&txn)?,
2395        comments: snapshot_comments(&txn)?,
2396    })
2397}
2398
2399#[cfg(any(feature = "wasm", test))]
2400pub(crate) fn snapshot_selected_slides(
2401    doc: &Doc,
2402    package: &PptxPackage,
2403    ids: &[String],
2404) -> EditResult<DeckSnapshot> {
2405    let txn = doc.transact();
2406    let meta = required_map(&txn, META)?;
2407    let slides = required_map(&txn, SLIDES)?;
2408    let shapes = required_map(&txn, SHAPES)?;
2409    let stories = required_map(&txn, STORIES)?;
2410    let mut seen = HashSet::new();
2411    let mut selected = Vec::new();
2412    for id in ids {
2413        if seen.insert(id) {
2414            selected.push(snapshot_slide(
2415                &slides, &shapes, &stories, package, &txn, id,
2416            )?);
2417        }
2418    }
2419    Ok(DeckSnapshot {
2420        width_emu: required_i64(&meta, &txn, "widthEmu")?,
2421        height_emu: required_i64(&meta, &txn, "heightEmu")?,
2422        slides: selected,
2423        comment_flavor: snapshot_flavor(&txn)?,
2424        comments: Vec::new(),
2425    })
2426}
2427
2428pub(crate) fn slide_notes<T: ReadTxn>(slide: &MapRef, txn: &T, package: &PptxPackage) -> String {
2429    if let Some(notes) = map_string(slide, txn, "notes") {
2430        return notes;
2431    }
2432    let source_part_path = map_string(slide, txn, "sourcePartPath");
2433    package
2434        .slides
2435        .iter()
2436        .find(|source| Some(&source.part_path) == source_part_path.as_ref())
2437        .map(|source| source.notes.clone())
2438        .unwrap_or_default()
2439}
2440
2441/// The slide's effective background: a stored override, an explicit clear, or
2442/// the declaration on the source part, and whether an edit changed it from that
2443/// declaration. A stored fill the writer cannot read or serialize, as a peer may
2444/// send, is ignored.
2445pub(crate) fn slide_background<T: ReadTxn>(
2446    slide: &MapRef,
2447    txn: &T,
2448    package: &PptxPackage,
2449) -> (Option<ShapeFill>, bool) {
2450    let source_part_path = map_string(slide, txn, "sourcePartPath");
2451    let source = source_slide(package, source_part_path.as_deref());
2452    let declared = source.and_then(|source| source.background.clone());
2453    let stored = map_string(slide, txn, "backgroundJson")
2454        .and_then(|json| serde_json::from_str::<ShapeFill>(&json).ok())
2455        .filter(|fill| validate_background(fill).is_ok())
2456        .map(|fill| normalized_background(&fill));
2457    if let Some(background) = stored {
2458        if declared.as_ref().map(normalized_background).as_ref() == Some(&background) {
2459            return (declared, false);
2460        }
2461        return (Some(background), true);
2462    }
2463    if map_bool(slide, txn, "backgroundCleared") == Some(true) {
2464        let declares =
2465            declared.is_some() || source.is_some_and(|source| source.declares_background);
2466        return (None, declares);
2467    }
2468    (declared, false)
2469}
2470
2471pub(crate) fn source_slide<'a>(
2472    package: &'a PptxPackage,
2473    part_path: Option<&str>,
2474) -> Option<&'a pptx_parse::Slide> {
2475    package
2476        .slides
2477        .iter()
2478        .find(|source| Some(source.part_path.as_str()) == part_path)
2479}
2480
2481/// `fill` with scheme aliases spelled the way the parser reads them and hex
2482/// digits in upper case, so equal fills compare equal.
2483pub(crate) fn normalized_background(fill: &ShapeFill) -> ShapeFill {
2484    let mut fill = fill.clone();
2485    let stops = fill
2486        .gradient
2487        .iter_mut()
2488        .flat_map(|gradient| gradient.stops.iter_mut().map(|stop| &mut stop.color));
2489    for color in fill.color.iter_mut().chain(stops) {
2490        if let Some(rgb) = &mut color.rgb {
2491            rgb.make_ascii_uppercase();
2492        }
2493        let canonical = match color.theme_color.as_deref() {
2494            Some("bg1") => "background1",
2495            Some("tx1") => "text1",
2496            Some("bg2") => "background2",
2497            Some("tx2") => "text2",
2498            _ => continue,
2499        };
2500        color.theme_color = Some(canonical.to_owned());
2501    }
2502    fill
2503}
2504
2505/// Rejects backgrounds the writer cannot serialize; `theme` fills only come
2506/// from parsed sources and are not settable.
2507pub(crate) fn validate_background(fill: &ShapeFill) -> EditResult<()> {
2508    match fill.fill_type.as_str() {
2509        "none" => Ok(()),
2510        "solid" => {
2511            let color = fill.color.as_ref().ok_or_else(|| {
2512                EditError::InvalidFill("the background fill is missing its colour".to_owned())
2513            })?;
2514            validate_background_color(color)
2515        }
2516        "gradient" => {
2517            let gradient = fill.gradient.as_ref().ok_or_else(|| {
2518                EditError::InvalidFill(
2519                    "the background fill is missing its gradient stops".to_owned(),
2520                )
2521            })?;
2522            if gradient.stops.len() < 2 {
2523                return Err(EditError::InvalidFill(
2524                    "a gradient background needs at least two stops".to_owned(),
2525                ));
2526            }
2527            if let Some(angle) = gradient.angle
2528                && !(angle >= 0.0 && (angle * 60_000.0).round() < 21_600_000.0)
2529            {
2530                return Err(EditError::InvalidFill(format!(
2531                    "gradient angle {angle} is outside 0..360 degrees"
2532                )));
2533            }
2534            for stop in &gradient.stops {
2535                if !stop.position.is_finite() || !(0.0..=100_000.0).contains(&stop.position) {
2536                    return Err(EditError::InvalidFill(format!(
2537                        "gradient stop position {} is outside 0..=100000",
2538                        stop.position
2539                    )));
2540                }
2541                validate_background_color(&stop.color)?;
2542            }
2543            Ok(())
2544        }
2545        other => Err(EditError::InvalidFill(format!(
2546            "background fill type {other:?} is not supported"
2547        ))),
2548    }
2549}
2550
2551fn validate_background_color(color: &ColorValue) -> EditResult<()> {
2552    if let Some(rgb) = &color.rgb {
2553        if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2554            return Err(EditError::InvalidFill(format!(
2555                "background colour {rgb:?} must be a six-digit hex value"
2556            )));
2557        }
2558    } else if !color.theme_color.as_deref().is_some_and(known_scheme_color) {
2559        return Err(EditError::InvalidFill(
2560            "the background colour needs an RGB or theme value".to_owned(),
2561        ));
2562    }
2563    for (name, value) in [
2564        ("alpha", color.alpha),
2565        ("luminance modulation", color.luminance_modulation),
2566        ("luminance offset", color.luminance_offset),
2567        ("saturation modulation", color.saturation_modulation),
2568    ] {
2569        if value.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) {
2570            return Err(EditError::InvalidFill(format!(
2571                "background colour {name} is outside 0..=1"
2572            )));
2573        }
2574    }
2575    for (name, value) in [("tint", &color.theme_tint), ("shade", &color.theme_shade)] {
2576        if let Some(value) = value
2577            && (value.len() != 2 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()))
2578        {
2579            return Err(EditError::InvalidFill(format!(
2580                "background colour {name} {value:?} must be two hex digits"
2581            )));
2582        }
2583    }
2584    for transform in &color.transforms {
2585        use ooxml_drawingml::ColorTransform::*;
2586
2587        let valid = match *transform {
2588            Tint(value) | Shade(value) | LumMod(value) | LumOff(value) | SatMod(value)
2589            | Alpha(value) => value.is_finite() && (0.0..=1.0).contains(&value),
2590            Hue(value) | HueMod(value) | HueOff(value) | Sat(value) | SatOff(value)
2591            | Lum(value) | Red(value) | RedMod(value) | RedOff(value) | Green(value)
2592            | GreenMod(value) | GreenOff(value) | Blue(value) | BlueMod(value) | BlueOff(value)
2593            | AlphaMod(value) | AlphaOff(value) => value.is_finite(),
2594            Comp | Inv | Gray | Gamma | InvGamma => true,
2595        };
2596        if !valid {
2597            return Err(EditError::InvalidFill(format!(
2598                "background colour transform {} has an invalid value",
2599                transform.xml_name()
2600            )));
2601        }
2602    }
2603    Ok(())
2604}
2605
2606fn known_scheme_color(color: &str) -> bool {
2607    matches!(
2608        color,
2609        "background1"
2610            | "text1"
2611            | "background2"
2612            | "text2"
2613            | "bg1"
2614            | "tx1"
2615            | "bg2"
2616            | "tx2"
2617            | "dk1"
2618            | "lt1"
2619            | "dk2"
2620            | "lt2"
2621            | "accent1"
2622            | "accent2"
2623            | "accent3"
2624            | "accent4"
2625            | "accent5"
2626            | "accent6"
2627            | "hlink"
2628            | "folHlink"
2629    )
2630}
2631
2632pub(crate) fn snapshot_shape<T: ReadTxn>(
2633    shapes: &MapRef,
2634    stories: &MapRef,
2635    txn: &T,
2636    shape_id: &str,
2637    visiting: &mut HashSet<String>,
2638    theme: Option<&Theme>,
2639) -> EditResult<ShapeSnapshot> {
2640    if visiting.len() >= MAX_SHAPE_DEPTH {
2641        return Err(EditError::InvalidState(format!(
2642            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
2643        )));
2644    }
2645    if !visiting.insert(shape_id.to_owned()) {
2646        return Err(EditError::InvalidState(format!(
2647            "shape cycle at {shape_id}"
2648        )));
2649    }
2650    let ShapeParts {
2651        mut snapshot,
2652        story_ids,
2653        child_ids,
2654    } = shape_parts(shapes, txn, shape_id, theme)?;
2655    for story_id in story_ids {
2656        let key = crate::table::storage_key(txn, &story_id)?;
2657        let story = stories
2658            .get(txn, &key)
2659            .and_then(|value| value.cast::<TextRef>().ok())
2660            .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
2661        snapshot
2662            .text_stories
2663            .push(snapshot_story(&story, txn, &story_id)?);
2664    }
2665    for child_id in child_ids {
2666        snapshot.children.push(snapshot_shape(
2667            shapes, stories, txn, &child_id, visiting, theme,
2668        )?);
2669    }
2670    visiting.remove(shape_id);
2671    let shape = shape_ref(txn, shape_id)?;
2672    snapshot.graphic = optional_json(&shape, txn, "graphicJson")?;
2673    snapshot.pending_media = match (
2674        map_string(&shape, txn, "pendingMediaBase64"),
2675        map_string(&shape, txn, "pendingMediaContentType"),
2676    ) {
2677        (Some(base64), Some(content_type)) => Some(PendingMedia {
2678            content_type,
2679            base64,
2680        }),
2681        _ => None,
2682    };
2683    Ok(snapshot)
2684}
2685
2686/// A shape's own fields, without its stories, children, graphic payload or unsaved image data,
2687/// and the ids of its stories and children.
2688pub(crate) struct ShapeParts {
2689    pub snapshot: ShapeSnapshot,
2690    pub story_ids: Vec<String>,
2691    pub child_ids: Vec<String>,
2692}
2693
2694pub(crate) fn shape_parts<T: ReadTxn>(
2695    shapes: &MapRef,
2696    txn: &T,
2697    shape_id: &str,
2698    theme: Option<&Theme>,
2699) -> EditResult<ShapeParts> {
2700    let shape = live_entry(shapes, txn, shape_id)
2701        .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
2702    let story_ids = map_string_array(&shape, txn, "textStories")?;
2703    let child_ids = map_string_array(&shape, txn, "children")?;
2704    let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
2705    let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
2706    let resolved_fill_color = fill
2707        .as_ref()
2708        .filter(|fill| fill.fill_type != "none")
2709        .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
2710    let resolved_outline_color = outline
2711        .as_ref()
2712        .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
2713    let snapshot = ShapeSnapshot {
2714        id: shape_id.to_owned(),
2715        source_id: required_u32(&shape, txn, "sourceId")?,
2716        kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
2717        name: required_string(&shape, txn, "name")?,
2718        x: required_i64(&shape, txn, "x")?,
2719        y: required_i64(&shape, txn, "y")?,
2720        width: required_i64(&shape, txn, "width")?,
2721        height: required_i64(&shape, txn, "height")?,
2722        rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
2723        flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
2724        flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
2725        inherited: None,
2726        hidden: map_bool(&shape, txn, "hidden").unwrap_or_default(),
2727        text_box: map_bool(&shape, txn, "textBox").unwrap_or_default(),
2728        aspect_ratio_locked: map_bool(&shape, txn, "aspectRatioLocked").unwrap_or_default(),
2729        geometry: required_string(&shape, txn, "geometry")?,
2730        adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
2731        placeholder: optional_json(&shape, txn, "placeholderJson")?,
2732        fill,
2733        resolved_fill_color,
2734        outline,
2735        resolved_outline_color,
2736        media_part_path: map_string(&shape, txn, "mediaPartPath"),
2737        pending_media: None,
2738        blip_effects: optional_json(&shape, txn, "blipEffectsJson")?.unwrap_or_default(),
2739        table_structure: optional_json(&shape, txn, "tableStructureJson")?,
2740        graphic: None,
2741        text_stories: Vec::new(),
2742        children: Vec::new(),
2743    };
2744    Ok(ShapeParts {
2745        snapshot,
2746        story_ids,
2747        child_ids,
2748    })
2749}
2750
2751/// The seed state `snapshot_doc` reads back for `package`, computed without
2752/// materializing a scratch document. `save` diffs the live doc against this.
2753pub fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
2754    seeded_snapshot(package, true)
2755}
2756
2757/// [`baseline_snapshot`] as seeds before schema 2.2 resolved run colours:
2758/// without the slide colour map.
2759pub(crate) fn legacy_baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
2760    seeded_snapshot(package, false)
2761}
2762
2763fn seeded_snapshot(package: &PptxPackage, color_map: bool) -> EditResult<DeckSnapshot> {
2764    let mut slide_id_by_part = HashMap::new();
2765    let mut slides = Vec::with_capacity(package.slides.len());
2766    for (slide_index, slide) in package.slides.iter().enumerate() {
2767        let slide_id = seeded_slide_id(
2768            slide_index,
2769            package
2770                .presentation
2771                .slides
2772                .get(slide_index)
2773                .ok_or_else(|| {
2774                    EditError::InvalidState(format!(
2775                        "missing slide reference for slide {slide_index}"
2776                    ))
2777                })?
2778                .id,
2779        );
2780        slide_id_by_part.insert(slide.part_path.clone(), slide_id.clone());
2781        slides.push(baseline_slide(package, slide, slide_id, color_map)?);
2782    }
2783    Ok(DeckSnapshot {
2784        width_emu: baseline_integer("widthEmu", package.presentation.width_emu)?,
2785        height_emu: baseline_integer("heightEmu", package.presentation.height_emu)?,
2786        slides,
2787        comment_flavor: package.comment_flavor.unwrap_or_default(),
2788        comments: baseline_comments(package, &|part| slide_id_by_part.get(part).cloned()),
2789    })
2790}
2791
2792fn baseline_slide(
2793    package: &PptxPackage,
2794    slide: &Slide,
2795    slide_id: String,
2796    color_map: bool,
2797) -> EditResult<SlideSnapshot> {
2798    let mut theme = pptx_parse::slide_theme(
2799        package,
2800        Some(&slide.part_path),
2801        slide.layout_part_path.as_deref(),
2802    );
2803    if !color_map {
2804        theme.color_map = ColorMap::default();
2805    }
2806    let mut shapes = Vec::with_capacity(slide.shapes.len());
2807    for (shape_index, shape) in slide.shapes.iter().enumerate() {
2808        shapes.push(baseline_shape(
2809            &slide_id,
2810            &shape_index.to_string(),
2811            shape,
2812            Some(&theme),
2813        )?);
2814    }
2815    record_inherited(
2816        &mut shapes,
2817        &SlideContext::new(
2818            package,
2819            Some(&slide.part_path),
2820            slide.layout_part_path.as_deref(),
2821        ),
2822    );
2823    Ok(SlideSnapshot {
2824        title: None,
2825        hidden: None,
2826        layout: None,
2827        id: slide_id,
2828        source_part_path: Some(slide.part_path.clone()),
2829        layout_part_path: slide.layout_part_path.clone(),
2830        name: slide.name.clone(),
2831        notes: slide.notes.clone(),
2832        background: slide.background.clone(),
2833        background_edited: false,
2834        shapes,
2835    })
2836}
2837
2838fn baseline_shape(
2839    slide_id: &str,
2840    path: &str,
2841    shape: &ShapeNode,
2842    theme: Option<&Theme>,
2843) -> EditResult<ShapeSnapshot> {
2844    let shape_id = seeded_shape_id(slide_id, path);
2845    let base = shape_base(shape);
2846    let transform = &base.transform;
2847    let kind;
2848    let geometry;
2849    let mut text_stories = Vec::new();
2850    let mut children = Vec::new();
2851    let mut adjust_values = BTreeMap::new();
2852    let mut fill = None;
2853    let mut outline = None;
2854    let mut media_part_path = None;
2855    let mut blip_effects = Vec::new();
2856    let mut graphic = None;
2857    match shape {
2858        ShapeNode::Shape(shape) => {
2859            kind = ShapeKind::Shape;
2860            geometry = shape.geometry.clone();
2861            let mut values = preset_geometry_default_adjustments(&shape.geometry)
2862                .into_iter()
2863                .collect::<BTreeMap<_, _>>();
2864            values.extend(shape.adjust_values.clone());
2865            adjust_values = baseline_json(&values)?;
2866            if let Some(body) = &shape.text {
2867                text_stories.push(baseline_story(&format!("story:{shape_id}:0"), body, theme)?);
2868            }
2869            fill = baseline_json_option(&shape.fill)?;
2870            outline = baseline_json_option(&shape.outline)?;
2871        }
2872        ShapeNode::Picture(picture) => {
2873            kind = ShapeKind::Picture;
2874            geometry = "rect".to_owned();
2875            fill = baseline_json_option(&picture.fill)?;
2876            outline = baseline_json_option(&picture.outline)?;
2877            if !picture.effects.is_empty() {
2878                blip_effects = baseline_json(&picture.effects)?;
2879            }
2880            media_part_path = picture.media_part_path.clone();
2881        }
2882        ShapeNode::GraphicFrame(frame) => {
2883            kind = ShapeKind::GraphicFrame;
2884            geometry = "rect".to_owned();
2885            graphic = Some(baseline_json(&frame.data)?);
2886            if let GraphicFrameData::Table(table) = &frame.data {
2887                for (row_index, row) in table.rows.iter().enumerate() {
2888                    for (cell_index, cell) in row.cells.iter().enumerate() {
2889                        text_stories.push(baseline_story(
2890                            &format!("story:{shape_id}:table:{row_index}:{cell_index}"),
2891                            &cell.text,
2892                            theme,
2893                        )?);
2894                    }
2895                }
2896            }
2897        }
2898        ShapeNode::Group(group) => {
2899            kind = ShapeKind::Group;
2900            geometry = "group".to_owned();
2901            for (child_index, child) in group.children.iter().enumerate() {
2902                children.push(baseline_shape(
2903                    slide_id,
2904                    &seeded_child_path(path, child_index),
2905                    child,
2906                    theme,
2907                )?);
2908            }
2909        }
2910    }
2911    let resolved_fill_color = fill
2912        .as_ref()
2913        .filter(|fill| fill.fill_type != "none")
2914        .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
2915    let resolved_outline_color = outline
2916        .as_ref()
2917        .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
2918    Ok(ShapeSnapshot {
2919        id: shape_id,
2920        source_id: base.id,
2921        kind,
2922        name: base.name.clone(),
2923        x: baseline_integer("x", transform.x)?,
2924        y: baseline_integer("y", transform.y)?,
2925        width: baseline_integer("width", transform.width)?,
2926        height: baseline_integer("height", transform.height)?,
2927        rotation_deg: if transform.rotation_deg.is_finite() {
2928            transform.rotation_deg
2929        } else {
2930            0.0
2931        },
2932        flip_h: transform.flip_h,
2933        flip_v: transform.flip_v,
2934        inherited: None,
2935        hidden: base.hidden,
2936        text_box: false,
2937        aspect_ratio_locked: matches!(shape, ShapeNode::Picture(picture) if picture.aspect_ratio_locked),
2938        geometry,
2939        adjust_values,
2940        placeholder: baseline_json_option(&base.placeholder)?,
2941        fill,
2942        resolved_fill_color,
2943        outline,
2944        resolved_outline_color,
2945        media_part_path,
2946        pending_media: None,
2947        blip_effects,
2948        table_structure: None,
2949        graphic,
2950        text_stories,
2951        children,
2952    })
2953}
2954
2955/// `required_i64` semantics for a value seeded as `f64`.
2956fn baseline_integer(key: &str, value: i64) -> EditResult<i64> {
2957    let number = value as f64;
2958    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
2959        return Err(EditError::InvalidState(format!("invalid integer {key}")));
2960    }
2961    Ok(number as i64)
2962}
2963
2964/// `insert_json` + `optional_json` without the document: seeded fields arrive
2965/// in the baseline snapshot JSON-normalized.
2966fn baseline_json<T: serde::Serialize + DeserializeOwned>(value: &T) -> EditResult<T> {
2967    let json = serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
2968    serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
2969}
2970
2971fn baseline_json_option<T: serde::Serialize + DeserializeOwned>(
2972    value: &Option<T>,
2973) -> EditResult<Option<T>> {
2974    value.as_ref().map(baseline_json).transpose()
2975}
2976
2977fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
2978    match value {
2979        "shape" => Ok(ShapeKind::Shape),
2980        "picture" => Ok(ShapeKind::Picture),
2981        "graphicFrame" => Ok(ShapeKind::GraphicFrame),
2982        "group" => Ok(ShapeKind::Group),
2983        _ => Err(EditError::InvalidState(format!(
2984            "unknown shape kind {value}"
2985        ))),
2986    }
2987}
2988
2989pub(crate) fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
2990    txn.get_array(SLIDE_ORDER)
2991        .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
2992}
2993
2994/// The visible slide order as `(raw order index, id)`: the first entry of each id that has a slide.
2995pub(crate) fn live_slide_order<T: ReadTxn>(txn: &T) -> EditResult<Vec<(u32, String)>> {
2996    let order = required_order(txn)?;
2997    let slides = required_map(txn, SLIDES)?;
2998    let mut seen = HashSet::new();
2999    Ok(order
3000        .iter(txn)
3001        .enumerate()
3002        .filter_map(|(index, value)| {
3003            let id = out_string(&value)?;
3004            let live = slides.contains_key(txn, &id)
3005                && !entry_is_deleted(&slides, txn, &id)
3006                && seen.insert(id.clone());
3007            live.then_some((index as u32, id))
3008        })
3009        .collect())
3010}
3011
3012pub(crate) fn live_slide_ids<T: ReadTxn>(txn: &T) -> EditResult<Vec<String>> {
3013    Ok(live_slide_order(txn)?
3014        .into_iter()
3015        .map(|(_, id)| id)
3016        .collect())
3017}
3018
3019fn live_slide_index<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<u32> {
3020    live_slide_order(txn)?
3021        .iter()
3022        .position(|(_, id)| id == slide_id)
3023        .map(|index| index as u32)
3024        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
3025}
3026
3027/// The raw order index that places a new entry at visible `index`, and the visible length.
3028fn slide_insert_position<T: ReadTxn>(
3029    order: &ArrayRef,
3030    txn: &T,
3031    index: u32,
3032) -> EditResult<(u32, u32)> {
3033    let live = live_slide_order(txn)?;
3034    let length = live.len() as u32;
3035    match live.get(index as usize) {
3036        Some((position, _)) => Ok((*position, length)),
3037        None if index == length => Ok((order.len(txn), length)),
3038        None => Err(EditError::OutOfBounds { index, length }),
3039    }
3040}
3041
3042fn remove_slide_entries(order: &ArrayRef, txn: &mut TransactionMut<'_>, slide_id: &str) {
3043    let indices: Vec<u32> = order
3044        .iter(&*txn)
3045        .enumerate()
3046        .filter(|(_, value)| out_string(value).as_deref() == Some(slide_id))
3047        .map(|(index, _)| index as u32)
3048        .collect();
3049    for index in indices.into_iter().rev() {
3050        order.remove(txn, index);
3051    }
3052}
3053
3054pub(crate) fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
3055    txn.get_map(name)
3056        .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
3057}
3058
3059pub(crate) fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
3060    live_entry(&required_map(txn, SLIDES)?, txn, slide_id)
3061        .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
3062}
3063
3064pub(crate) fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
3065    live_entry(&required_map(txn, SHAPES)?, txn, shape_id)
3066        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
3067}
3068
3069/// The identity is an undoable membership marker; deleting it keeps the map's content merging.
3070fn live_entry<T: ReadTxn>(entries: &MapRef, txn: &T, id: &str) -> Option<MapRef> {
3071    entries
3072        .get(txn, id)
3073        .and_then(|value| value.cast::<MapRef>().ok())
3074        .filter(|entry| entry.contains_key(txn, "id"))
3075}
3076
3077fn entry_is_deleted<T: ReadTxn>(entries: &MapRef, txn: &T, id: &str) -> bool {
3078    entries
3079        .get(txn, id)
3080        .and_then(|value| value.cast::<MapRef>().ok())
3081        .is_some_and(|entry| !entry.contains_key(txn, "id"))
3082}
3083
3084fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
3085    if required_string(shape, txn, "kind")? == "shape" {
3086        Ok(())
3087    } else {
3088        Err(EditError::InvalidGeometry(
3089            "only preset shapes support shape styling".to_owned(),
3090        ))
3091    }
3092}
3093
3094pub(crate) fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
3095    slide
3096        .get(txn, "shapes")
3097        .and_then(|value| value.cast::<ArrayRef>().ok())
3098        .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
3099}
3100
3101fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
3102    let slide = slide_ref(txn, slide_id)?;
3103    let order = slide_shape_order(&slide, txn)?;
3104    if array_index(&order, txn, shape_id).is_some() {
3105        Ok(())
3106    } else {
3107        Err(EditError::ShapeNotFound(shape_id.to_owned()))
3108    }
3109}
3110
3111fn tombstone_shapes(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
3112    let shapes = required_map(txn, SHAPES)?;
3113    let mut entries = ShapeEntries::default();
3114    for shape_id in root_shape_ids {
3115        collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
3116    }
3117    for shape_id in entries.shape_ids {
3118        shape_ref(txn, &shape_id)?.remove(txn, "id");
3119    }
3120    Ok(())
3121}
3122
3123#[derive(Default)]
3124struct ShapeEntries {
3125    shape_ids: Vec<String>,
3126    seen_shape_ids: HashSet<String>,
3127}
3128
3129fn collect_shape_entries<T: ReadTxn>(
3130    shapes: &MapRef,
3131    txn: &T,
3132    shape_id: &str,
3133    visiting: &mut HashSet<String>,
3134    entries: &mut ShapeEntries,
3135) -> EditResult<()> {
3136    if visiting.len() >= MAX_SHAPE_DEPTH {
3137        return Err(EditError::InvalidState(format!(
3138            "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
3139        )));
3140    }
3141    if !visiting.insert(shape_id.to_owned()) {
3142        return Err(EditError::InvalidState(format!(
3143            "shape cycle at {shape_id}"
3144        )));
3145    }
3146    if entries.seen_shape_ids.contains(shape_id) {
3147        visiting.remove(shape_id);
3148        return Ok(());
3149    }
3150    let shape = shapes
3151        .get(txn, shape_id)
3152        .and_then(|value| value.cast::<MapRef>().ok())
3153        .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
3154    for child_id in map_string_array(&shape, txn, "children")? {
3155        collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
3156    }
3157    visiting.remove(shape_id);
3158    entries.seen_shape_ids.insert(shape_id.to_owned());
3159    entries.shape_ids.push(shape_id.to_owned());
3160    Ok(())
3161}
3162
3163fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
3164    array
3165        .iter(txn)
3166        .enumerate()
3167        .find(|(_, item)| out_string(item).as_deref() == Some(value))
3168        .map(|(index, _)| index as u32)
3169}
3170
3171pub(crate) fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
3172    array
3173        .iter(txn)
3174        .filter_map(|value| out_string(&value))
3175        .collect()
3176}
3177
3178pub(crate) fn map_string_array<T: ReadTxn>(
3179    map: &MapRef,
3180    txn: &T,
3181    key: &str,
3182) -> EditResult<Vec<String>> {
3183    match map.get(txn, key) {
3184        Some(Out::Any(Any::Array(values))) => Ok(values
3185            .iter()
3186            .filter_map(|value| match value {
3187                Any::String(value) => Some(value.to_string()),
3188                _ => None,
3189            })
3190            .collect()),
3191        None => Ok(Vec::new()),
3192        _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
3193    }
3194}
3195
3196fn string_array(values: &[String]) -> Any {
3197    Any::Array(Arc::from(
3198        values
3199            .iter()
3200            .map(|value| Any::from(value.as_str()))
3201            .collect::<Vec<_>>(),
3202    ))
3203}
3204
3205fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
3206    Ok(ShapeRect {
3207        x: required_i64(shape, txn, "x")?,
3208        y: required_i64(shape, txn, "y")?,
3209        width: required_i64(shape, txn, "width")?,
3210        height: required_i64(shape, txn, "height")?,
3211    })
3212}
3213
3214fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
3215    let value = required_i64(map, txn, key)?;
3216    u32::try_from(value).map_err(|_| {
3217        EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
3218    })
3219}
3220
3221pub(crate) fn validate_rect(rect: ShapeRect) -> EditResult<()> {
3222    validate_coordinate(rect.x)?;
3223    validate_coordinate(rect.y)?;
3224    if rect.width <= 0 || rect.height <= 0 {
3225        return Err(EditError::InvalidGeometry(
3226            "shape width and height must be positive".to_owned(),
3227        ));
3228    }
3229    validate_coordinate(rect.width)?;
3230    validate_coordinate(rect.height)
3231}
3232
3233fn validate_coordinate(value: i64) -> EditResult<()> {
3234    if value.unsigned_abs() > MAX_GEOMETRY as u64 {
3235        return Err(EditError::InvalidGeometry(format!(
3236            "coordinate {value} exceeds the safe range"
3237        )));
3238    }
3239    Ok(())
3240}
3241
3242pub(crate) fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
3243    if adjustments.len() > MAX_ADJUSTMENTS {
3244        return Err(EditError::InvalidAdjustment(format!(
3245            "at most {MAX_ADJUSTMENTS} values are allowed"
3246        )));
3247    }
3248    for (name, value) in adjustments {
3249        if !valid_adjustment_name(name) {
3250            return Err(EditError::InvalidAdjustment(format!(
3251                "unrecognized guide name {name:?}"
3252            )));
3253        }
3254        if !value.is_finite() {
3255            return Err(EditError::InvalidAdjustment(format!(
3256                "guide {name:?} must be finite"
3257            )));
3258        }
3259    }
3260    Ok(())
3261}
3262
3263fn valid_adjustment_name(name: &str) -> bool {
3264    if name == "adj" {
3265        return true;
3266    }
3267    let Some(index) = name.strip_prefix("adj") else {
3268        return false;
3269    };
3270    index
3271        .parse::<usize>()
3272        .ok()
3273        .filter(|value| value.to_string() == index)
3274        .is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
3275}
3276
3277pub(crate) fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
3278    Ok(match color {
3279        Some(color) => ShapeFill {
3280            fill_type: "solid".to_owned(),
3281            color: Some(color_value(color)?),
3282            gradient: None,
3283        },
3284        None => ShapeFill::named("none"),
3285    })
3286}
3287
3288/// The outline a stroke leaves on a shape outlined by `existing`, after checking the stroke.
3289pub(crate) fn stroked_outline(
3290    existing: Option<ShapeOutline>,
3291    stroke: &ShapeStroke,
3292) -> EditResult<ShapeOutline> {
3293    let color = stroke.color.as_deref().map(color_value).transpose()?;
3294    if let Some(width) = stroke.width_pt
3295        && (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
3296    {
3297        return Err(EditError::InvalidGeometry(format!(
3298            "stroke width {width}pt is outside the safe range"
3299        )));
3300    }
3301    if stroke.color.is_none() && stroke.width_pt.is_none() {
3302        return Ok(ShapeOutline::default());
3303    }
3304    let mut outline = existing.unwrap_or_default();
3305    if let Some(color) = color {
3306        if outline.width.is_none() {
3307            outline.width = Some(EMU_PER_POINT);
3308        }
3309        outline.color = Some(color);
3310        outline.gradient = None;
3311    } else if outline.color.is_none() && outline.gradient.is_none() {
3312        outline.color = Some(color_value("#000000")?);
3313    }
3314    if let Some(width) = stroke.width_pt {
3315        outline.width = Some(width * EMU_PER_POINT);
3316    }
3317    Ok(outline)
3318}
3319
3320fn fill_color(fill: &ShapeFill) -> Option<String> {
3321    (fill.fill_type != "none")
3322        .then(|| resolve_color_value_to_hex(fill.color.as_ref()))
3323        .flatten()
3324}
3325
3326fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
3327    Some(ShapeStroke {
3328        color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
3329        width_pt: outline.width.map(|width| width / EMU_PER_POINT),
3330    })
3331}
3332
3333fn color_value(color: &str) -> EditResult<ColorValue> {
3334    let rgb = color.strip_prefix('#').unwrap_or(color);
3335    if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
3336        return Err(EditError::InvalidGeometry(format!(
3337            "color {color:?} must be a six-digit hex value"
3338        )));
3339    }
3340    Ok(ColorValue {
3341        rgb: Some(rgb.to_ascii_uppercase()),
3342        ..ColorValue::default()
3343    })
3344}
3345
3346fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
3347    map_string(map, txn, key)
3348        .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
3349}
3350
3351/// A shape's stored graphic payload as JSON text, shared rather than copied.
3352pub(crate) fn graphic_json<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<Option<Arc<str>>> {
3353    Ok(match shape_ref(txn, shape_id)?.get(txn, "graphicJson") {
3354        Some(Out::Any(Any::String(json))) => Some(json),
3355        _ => None,
3356    })
3357}
3358
3359pub(crate) fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
3360    map.get(txn, key).and_then(|value| out_string(&value))
3361}
3362
3363fn out_string(value: &Out) -> Option<String> {
3364    match value {
3365        Out::Any(Any::String(value)) => Some(value.to_string()),
3366        _ => None,
3367    }
3368}
3369
3370fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
3371    let number = map_number(map, txn, key)
3372        .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
3373    if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
3374        return Err(EditError::InvalidState(format!("invalid integer {key}")));
3375    }
3376    Ok(number as i64)
3377}
3378
3379pub(crate) fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
3380    match map.get(txn, key) {
3381        Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
3382        Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
3383        _ => None,
3384    }
3385}
3386
3387pub(crate) fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
3388    match map.get(txn, key) {
3389        Some(Out::Any(Any::Bool(value))) => Some(value),
3390        _ => None,
3391    }
3392}
3393
3394fn optional_json<T: DeserializeOwned, R: ReadTxn>(
3395    map: &MapRef,
3396    txn: &R,
3397    key: &str,
3398) -> EditResult<Option<T>> {
3399    map_string(map, txn, key)
3400        .map(|json| {
3401            serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
3402        })
3403        .transpose()
3404}
3405
3406#[cfg(test)]
3407mod tests {
3408    use super::*;
3409    use crate::{
3410        CaretAnchor, CommentFlavor, ProposalEdit, ProposalRequest, TextStyle, TextStylePatch,
3411        UndoCaptureMode,
3412    };
3413    use std::sync::atomic::Ordering;
3414    use yrs::Text;
3415    use yrs::types::text::YChange;
3416
3417    fn deterministic_deck_bytes() -> Vec<u8> {
3418        let shapes = (2..=3)
3419            .map(|id| {
3420                format!(
3421                    r#"<p:sp><p:nvSpPr><p:cNvPr id="{id}" name="Text {id}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="1270000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:buAutoNum type="arabicPeriod" startAt="1"/></a:pPr><a:r><a:rPr/><a:t>Alpha</a:t></a:r></a:p><a:p><a:pPr><a:buAutoNum type="arabicPeriod" startAt="7"/></a:pPr><a:r><a:rPr/><a:t>Beta</a:t></a:r></a:p></p:txBody></p:sp>"#
3422                )
3423            })
3424            .collect::<String>();
3425        let slide = format!(
3426            r#"<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{shapes}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>"#
3427        );
3428        let parts = [
3429            (
3430                "[Content_Types].xml",
3431                r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>"#,
3432            ),
3433            (
3434                "_rels/.rels",
3435                r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>"#,
3436            ),
3437            (
3438                "ppt/presentation.xml",
3439                r#"<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>"#,
3440            ),
3441            (
3442                "ppt/_rels/presentation.xml.rels",
3443                r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>"#,
3444            ),
3445            ("ppt/slides/slide1.xml", slide.as_str()),
3446        ]
3447        .into_iter()
3448        .map(|(path, xml)| (path.to_owned(), xml.as_bytes().to_vec()))
3449        .collect::<Vec<_>>();
3450        ooxml_opc::rezip_parts(&parts).unwrap()
3451    }
3452
3453    fn add_test_comment(
3454        session: &DeckSession,
3455        ctx: &EditCtx,
3456        slide_id: &str,
3457        text: &str,
3458    ) -> EditResult<crate::CommentReceipt> {
3459        session.add_comment(
3460            ctx,
3461            slide_id,
3462            "Human",
3463            "H",
3464            text,
3465            "2026-10-05T10:00:00Z",
3466            0,
3467            0,
3468        )
3469    }
3470
3471    fn add_test_reply(
3472        session: &DeckSession,
3473        ctx: &EditCtx,
3474        parent: &str,
3475        text: &str,
3476    ) -> EditResult<crate::CommentReceipt> {
3477        session.reply_to_comment(ctx, parent, "Human", "H", text, "2026-10-05T10:00:00Z")
3478    }
3479
3480    fn assert_equal_peers(left: &DeckSession, right: &DeckSession, label: &str) {
3481        let snapshot = left.snapshot().unwrap();
3482        assert_eq!(snapshot, right.snapshot().unwrap(), "{label}: snapshot");
3483        assert_eq!(left.save().unwrap(), right.save().unwrap(), "{label}: save");
3484        assert_eq!(
3485            left.proposals().unwrap(),
3486            right.proposals().unwrap(),
3487            "{label}: proposals"
3488        );
3489        assert_eq!(left.can_undo(), right.can_undo(), "{label}: can undo");
3490        assert_eq!(left.can_redo(), right.can_redo(), "{label}: can redo");
3491        assert_eq!(
3492            left.undo.borrow().stack_clock_counts(),
3493            right.undo.borrow().stack_clock_counts(),
3494            "{label}: history entries"
3495        );
3496        assert_eq!(
3497            left.id_counter.load(Ordering::Relaxed),
3498            right.id_counter.load(Ordering::Relaxed),
3499            "{label}: allocator"
3500        );
3501        for slide in &snapshot.slides {
3502            for shape in &slide.shapes {
3503                for story in &shape.text_stories {
3504                    assert_eq!(
3505                        left.story(&story.id).unwrap(),
3506                        right.story(&story.id).unwrap(),
3507                        "{label}: story"
3508                    );
3509                    for index in 0..story.length {
3510                        let left_anchor = left.anchor_caret(&story.id, index).unwrap();
3511                        let right_anchor = right.anchor_caret(&story.id, index).unwrap();
3512                        assert_eq!(
3513                            left.resolve_caret_anchor(&left_anchor),
3514                            Some(index),
3515                            "{label}: left anchor"
3516                        );
3517                        assert_eq!(
3518                            right.resolve_caret_anchor(&right_anchor),
3519                            Some(index),
3520                            "{label}: right anchor"
3521                        );
3522                    }
3523                }
3524            }
3525        }
3526    }
3527
3528    fn assert_refused_without_changes<T: std::fmt::Debug>(
3529        session: &DeckSession,
3530        label: &str,
3531        reject: impl FnOnce() -> EditResult<T>,
3532    ) {
3533        let update = session.encode_state_as_update_v1();
3534        let version = session.version();
3535        let counter = session.id_counter.load(Ordering::Relaxed);
3536        let proposals = session.proposals().map_err(|error| error.to_string());
3537        let history = (session.can_undo(), session.can_redo());
3538        let history_entries = session.undo.borrow().stack_diagnostics();
3539        let outcome = reject();
3540        assert!(outcome.is_err(), "{label}: {outcome:?}");
3541        assert_eq!(
3542            session.encode_state_as_update_v1(),
3543            update,
3544            "{label}: update"
3545        );
3546        assert_eq!(session.version(), version, "{label}: version");
3547        assert_eq!(
3548            session.id_counter.load(Ordering::Relaxed),
3549            counter,
3550            "{label}: allocator"
3551        );
3552        assert_eq!(
3553            session.proposals().map_err(|error| error.to_string()),
3554            proposals,
3555            "{label}: proposals"
3556        );
3557        assert_eq!(
3558            (session.can_undo(), session.can_redo()),
3559            history,
3560            "{label}: history"
3561        );
3562        assert_eq!(
3563            session.undo.borrow().stack_diagnostics(),
3564            history_entries,
3565            "{label}: history entries"
3566        );
3567    }
3568
3569    fn assert_history_paths_match(
3570        left: &DeckSession,
3571        right: &DeckSession,
3572        label: &str,
3573        anchors: &[CaretAnchor],
3574    ) -> usize {
3575        assert_eq!(
3576            left.undo.borrow().stack_diagnostics(),
3577            right.undo.borrow().stack_diagnostics(),
3578            "{label}: history ids before restoration"
3579        );
3580        let final_snapshot = left.snapshot().unwrap();
3581        let final_save = left.save().unwrap();
3582        let mut undos = 0;
3583        loop {
3584            let applied = left.undo();
3585            assert_eq!(applied, right.undo(), "{label}: undo {undos}");
3586            assert_equal_peers(left, right, &format!("{label}: undo {undos}"));
3587            for anchor in anchors {
3588                assert_eq!(
3589                    left.resolve_caret_anchor(anchor),
3590                    right.resolve_caret_anchor(anchor),
3591                    "{label}: retained undo anchor"
3592                );
3593            }
3594            if !applied {
3595                break;
3596            }
3597            undos += 1;
3598            assert!(undos < 64, "{label}: undo failed to terminate");
3599        }
3600        let mut redos = 0;
3601        loop {
3602            let applied = left.redo();
3603            assert_eq!(applied, right.redo(), "{label}: redo {redos}");
3604            assert_equal_peers(left, right, &format!("{label}: redo {redos}"));
3605            for anchor in anchors {
3606                assert_eq!(
3607                    left.resolve_caret_anchor(anchor),
3608                    right.resolve_caret_anchor(anchor),
3609                    "{label}: retained redo anchor"
3610                );
3611            }
3612            if !applied {
3613                break;
3614            }
3615            redos += 1;
3616            assert!(redos < 64, "{label}: redo failed to terminate");
3617        }
3618        assert_eq!(undos, redos, "{label}: history depth");
3619        assert_eq!(
3620            left.snapshot().unwrap(),
3621            final_snapshot,
3622            "{label}: redo snapshot"
3623        );
3624        assert_eq!(left.save().unwrap(), final_save, "{label}: redo save");
3625        undos
3626    }
3627
3628    #[test]
3629    fn rejected_creators_preserve_allocator_and_history() {
3630        let bytes = deterministic_deck_bytes();
3631        let ctx = EditCtx::local("human");
3632        let rect = ShapeRect {
3633            x: 0,
3634            y: 0,
3635            width: 1270000,
3636            height: 1270000,
3637        };
3638        let text_box = ShapeDraft {
3639            name: "Text".into(),
3640            rect,
3641            text: "Created".into(),
3642            style: TextStyle::default(),
3643        };
3644        let shape = PresetShapeDraft {
3645            name: "Shape".into(),
3646            geometry: "rect".into(),
3647            rect,
3648            fill: None,
3649        };
3650        let picture = PictureDraft {
3651            name: "Picture".into(),
3652            rect,
3653            content_type: "image/png".into(),
3654            media_bytes: base64::engine::general_purpose::STANDARD
3655                .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=")
3656                .unwrap(),
3657        };
3658        for case in [
3659            "slide index",
3660            "slide layout",
3661            "text box target",
3662            "text box style",
3663            "shape target",
3664            "shape geometry",
3665            "picture target",
3666            "picture data",
3667            "picture type",
3668            "comment target",
3669            "comment text",
3670            "reply target",
3671            "reply nested",
3672            "reply empty",
3673            "paragraph target",
3674            "paragraph index",
3675        ] {
3676            let refused = DeckSession::open(&bytes, 710).unwrap();
3677            let fresh = DeckSession::open(&bytes, 710).unwrap();
3678            let slide_id = fresh.slide_ids().unwrap()[0].clone();
3679            let story_id = fresh.snapshot().unwrap().slides[0].shapes[0].text_stories[0]
3680                .id
3681                .clone();
3682            let mut root = String::new();
3683            let mut reply = String::new();
3684            for session in [&refused, &fresh] {
3685                session.set_undo_capture_mode(UndoCaptureMode::Manual);
3686                if case != "reply empty" {
3687                    session
3688                        .set_comment_flavor(&ctx, CommentFlavor::Modern)
3689                        .unwrap();
3690                }
3691                root = add_test_comment(session, &ctx, &slide_id, "Root")
3692                    .unwrap()
3693                    .comment_id;
3694                if case != "reply empty" {
3695                    reply = add_test_reply(session, &ctx, &root, "Reply")
3696                        .unwrap()
3697                        .comment_id;
3698                }
3699                session
3700                    .propose(ProposalRequest {
3701                        agent_id: "agent".into(),
3702                        note: None,
3703                        edits: vec![ProposalEdit::SetSlideNotes {
3704                            slide_id: slide_id.clone(),
3705                            text: "Pending".into(),
3706                        }],
3707                    })
3708                    .unwrap();
3709                session.add_undo_barrier();
3710                session
3711                    .insert_text(&ctx, &story_id, 0, "redo", &TextStyle::default())
3712                    .unwrap();
3713                assert!(session.undo());
3714                assert!(session.can_redo());
3715            }
3716            assert_equal_peers(&refused, &fresh, case);
3717            assert_refused_without_changes(&refused, case, || match case {
3718                "slide index" => refused.insert_slide(&ctx, 2, None).map(|_| ()),
3719                "slide layout" => refused
3720                    .insert_slide(&ctx, 1, Some("missing-layout"))
3721                    .map(|_| ()),
3722                "text box target" => refused
3723                    .add_text_box(&ctx, "missing-slide", &text_box)
3724                    .map(|_| ()),
3725                "text box style" => refused
3726                    .add_text_box(
3727                        &ctx,
3728                        &slide_id,
3729                        &ShapeDraft {
3730                            style: TextStyle {
3731                                font_size_pt: Some(f64::NAN),
3732                                ..Default::default()
3733                            },
3734                            ..text_box.clone()
3735                        },
3736                    )
3737                    .map(|_| ()),
3738                "shape target" => refused.add_shape(&ctx, "missing-slide", &shape).map(|_| ()),
3739                "shape geometry" => refused
3740                    .add_shape(
3741                        &ctx,
3742                        &slide_id,
3743                        &PresetShapeDraft {
3744                            geometry: "missing-geometry".into(),
3745                            ..shape.clone()
3746                        },
3747                    )
3748                    .map(|_| ()),
3749                "picture target" => refused
3750                    .add_picture(&ctx, "missing-slide", &picture)
3751                    .map(|_| ()),
3752                "picture data" => refused
3753                    .add_picture(
3754                        &ctx,
3755                        &slide_id,
3756                        &PictureDraft {
3757                            media_bytes: vec![],
3758                            ..picture.clone()
3759                        },
3760                    )
3761                    .map(|_| ()),
3762                "picture type" => refused
3763                    .add_picture(
3764                        &ctx,
3765                        &slide_id,
3766                        &PictureDraft {
3767                            content_type: "image/unknown".into(),
3768                            ..picture.clone()
3769                        },
3770                    )
3771                    .map(|_| ()),
3772                "comment target" => {
3773                    add_test_comment(&refused, &ctx, "missing-slide", "Comment").map(|_| ())
3774                }
3775                "comment text" => add_test_comment(&refused, &ctx, &slide_id, "").map(|_| ()),
3776                "reply target" => {
3777                    add_test_reply(&refused, &ctx, "missing-comment", "Reply").map(|_| ())
3778                }
3779                "reply nested" => add_test_reply(&refused, &ctx, &reply, "Reply").map(|_| ()),
3780                "reply empty" => add_test_reply(&refused, &ctx, &root, "").map(|_| ()),
3781                "paragraph target" => refused
3782                    .insert_paragraph_break(&ctx, "missing-story", 0)
3783                    .map(|_| ()),
3784                "paragraph index" => refused
3785                    .insert_paragraph_break(&ctx, &story_id, u32::MAX)
3786                    .map(|_| ()),
3787                _ => unreachable!(),
3788            });
3789            assert_equal_peers(&refused, &fresh, case);
3790            for session in [&refused, &fresh] {
3791                match case {
3792                    "slide index" | "slide layout" => {
3793                        session.insert_slide(&ctx, 1, None).unwrap();
3794                    }
3795                    "text box target" | "text box style" => {
3796                        session.add_text_box(&ctx, &slide_id, &text_box).unwrap();
3797                    }
3798                    "shape target" | "shape geometry" => {
3799                        session.add_shape(&ctx, &slide_id, &shape).unwrap();
3800                    }
3801                    "picture target" | "picture data" | "picture type" => {
3802                        session.add_picture(&ctx, &slide_id, &picture).unwrap();
3803                    }
3804                    "comment target" | "comment text" => {
3805                        add_test_comment(session, &ctx, &slide_id, "Comment").unwrap();
3806                    }
3807                    "reply target" | "reply nested" => {
3808                        add_test_reply(session, &ctx, &root, "Reply").unwrap();
3809                    }
3810                    "reply empty" => {
3811                        session.remove_comment(&ctx, &root).unwrap();
3812                        session
3813                            .set_comment_flavor(&ctx, CommentFlavor::Modern)
3814                            .unwrap();
3815                        let parent = add_test_comment(session, &ctx, &slide_id, "Root").unwrap();
3816                        add_test_reply(session, &ctx, &parent.comment_id, "Reply").unwrap();
3817                    }
3818                    "paragraph target" | "paragraph index" => {
3819                        session.insert_paragraph_break(&ctx, &story_id, 2).unwrap();
3820                    }
3821                    _ => unreachable!(),
3822                }
3823                session
3824                    .propose(ProposalRequest {
3825                        agent_id: "agent".into(),
3826                        note: None,
3827                        edits: vec![ProposalEdit::SetSlideNotes {
3828                            slide_id: slide_id.clone(),
3829                            text: "Next".into(),
3830                        }],
3831                    })
3832                    .unwrap();
3833            }
3834            assert_equal_peers(&refused, &fresh, case);
3835            assert!(assert_history_paths_match(&refused, &fresh, case, &[]) > 0);
3836        }
3837    }
3838
3839    #[test]
3840    fn formatting_and_map_writes_preserve_history_and_anchors() {
3841        let bytes = deterministic_deck_bytes();
3842        let left = DeckSession::open(&bytes, 711).unwrap();
3843        let right = DeckSession::open(&bytes, 711).unwrap();
3844        let pristine = left.snapshot().unwrap();
3845        let ctx = EditCtx::local("human");
3846        let slide_id = pristine.slides[0].id.clone();
3847        let story_ids = pristine.slides[0]
3848            .shapes
3849            .iter()
3850            .map(|shape| shape.text_stories[0].id.clone())
3851            .collect::<Vec<_>>();
3852        let mut anchors: Vec<CaretAnchor> = Vec::new();
3853        let mut root = String::new();
3854        for session in [&left, &right] {
3855            session.set_undo_capture_mode(UndoCaptureMode::Manual);
3856            let mut txn = session.doc.transact_mut_with("pptx:system");
3857            let mut migrated = 0;
3858            for story_id in &story_ids {
3859                let story = crate::story::story_ref(&txn, story_id).unwrap();
3860                for diff in story.diff(&txn, YChange::identity) {
3861                    let Out::YMap(map) = diff.insert else {
3862                        continue;
3863                    };
3864                    let json = map_string(&map, &txn, "bulletJson").unwrap();
3865                    let mut bullet: pptx_parse::Bullet = serde_json::from_str(&json).unwrap();
3866                    let pptx_parse::Bullet::AutoNumber { restart, .. } = &mut bullet else {
3867                        panic!("synthetic paragraphs must have automatic numbering");
3868                    };
3869                    assert!(*restart);
3870                    *restart = false;
3871                    map.insert(
3872                        &mut txn,
3873                        "bulletJson",
3874                        serde_json::to_string(&bullet).unwrap(),
3875                    );
3876                    migrated += 1;
3877                }
3878            }
3879            assert_eq!(migrated, 4);
3880            drop(txn);
3881            assert!(!session.can_undo());
3882            let epoch = session.epoch();
3883            crate::story::import_source_numbering_restarts(&session.doc, session.package())
3884                .unwrap();
3885            assert_eq!(session.epoch(), epoch + 1);
3886            for story_id in &story_ids {
3887                for paragraph in session.story(story_id).unwrap().paragraphs {
3888                    let bullet: pptx_parse::Bullet =
3889                        serde_json::from_str(&paragraph.bullet_json.unwrap()).unwrap();
3890                    assert!(matches!(
3891                        bullet,
3892                        pptx_parse::Bullet::AutoNumber { restart: true, .. }
3893                    ));
3894                }
3895            }
3896            let epoch = session.epoch();
3897            crate::story::import_source_numbering_restarts(&session.doc, session.package())
3898                .unwrap();
3899            assert_eq!(session.epoch(), epoch);
3900            assert_eq!(session.snapshot().unwrap(), pristine);
3901            session
3902                .set_comment_flavor(&ctx, CommentFlavor::Modern)
3903                .unwrap();
3904            root = add_test_comment(session, &ctx, &slide_id, "Root")
3905                .unwrap()
3906                .comment_id;
3907            for text in ["One", "Two", "Three"] {
3908                add_test_reply(session, &ctx, &root, text).unwrap();
3909            }
3910            add_test_comment(session, &ctx, &slide_id, "Other").unwrap();
3911            session.add_undo_barrier();
3912        }
3913        assert_equal_peers(&left, &right, "numbering migration and comment setup");
3914        for story_id in &story_ids {
3915            let anchor = left.anchor_caret(story_id, 3).unwrap();
3916            assert_eq!(anchor, right.anchor_caret(story_id, 3).unwrap());
3917            anchors.push(anchor);
3918        }
3919        let patch = TextStylePatch {
3920            bold: Some(true),
3921            italic: Some(true),
3922            font_size_pt: Some(28.0),
3923            color: Some("123ABC".into()),
3924            font_family: Some("Arial".into()),
3925            underline: Some("dbl".into()),
3926            spacing_pt: Some(1.5),
3927            baseline_pct: Some(12.0),
3928        };
3929        for story_id in &story_ids {
3930            let end = left.story(story_id).unwrap().length - 1;
3931            let epoch = left.epoch();
3932            assert_eq!(
3933                left.format_text(&ctx, story_id, 1, end, &patch).unwrap(),
3934                right.format_text(&ctx, story_id, 1, end, &patch).unwrap()
3935            );
3936            assert_eq!(left.epoch(), epoch + 1);
3937            let formatted = left.story(story_id).unwrap();
3938            let style = &formatted.paragraphs[0].runs.last().unwrap().style;
3939            assert_eq!(style.bold, patch.bold);
3940            assert_eq!(style.italic, patch.italic);
3941            assert_eq!(style.font_size_pt, patch.font_size_pt);
3942            assert_eq!(style.color, patch.color);
3943            assert_eq!(style.font_family, patch.font_family);
3944            assert_eq!(style.underline, patch.underline);
3945            assert_eq!(style.spacing_pt, patch.spacing_pt);
3946            assert_eq!(style.baseline_pct, patch.baseline_pct);
3947            assert_eq!(formatted.paragraphs[0].runs[0].text, "A");
3948            assert_eq!(formatted.paragraphs[0].runs[0].style, TextStyle::default());
3949            let original = &pristine.slides[0]
3950                .shapes
3951                .iter()
3952                .find(|shape| shape.text_stories[0].id == *story_id)
3953                .unwrap()
3954                .text_stories[0];
3955            for (before, after) in original.paragraphs.iter().zip(&formatted.paragraphs) {
3956                assert_eq!(before.id, after.id);
3957                assert_eq!(before.bullet_json, after.bullet_json);
3958            }
3959            assert_equal_peers(&left, &right, "multi-attribute formatting");
3960        }
3961        left.add_undo_barrier();
3962        right.add_undo_barrier();
3963        for story_id in &story_ids {
3964            assert_eq!(
3965                left.insert_paragraph_break(&ctx, story_id, 2).unwrap(),
3966                right.insert_paragraph_break(&ctx, story_id, 2).unwrap()
3967            );
3968        }
3969        assert_equal_peers(
3970            &left,
3971            &right,
3972            "paragraph breaks after formatting and migration",
3973        );
3974        for anchor in &anchors {
3975            assert_eq!(left.resolve_caret_anchor(anchor), Some(4));
3976            assert_eq!(right.resolve_caret_anchor(anchor), Some(4));
3977        }
3978        left.add_undo_barrier();
3979        right.add_undo_barrier();
3980        assert_eq!(
3981            left.remove_comment(&ctx, &root).unwrap(),
3982            right.remove_comment(&ctx, &root).unwrap()
3983        );
3984        assert_eq!(left.comments().unwrap().len(), 1);
3985        assert_equal_peers(&left, &right, "root and reply deletion");
3986        left.add_undo_barrier();
3987        right.add_undo_barrier();
3988        assert_eq!(
3989            left.delete_slide(&ctx, &slide_id).unwrap(),
3990            right.delete_slide(&ctx, &slide_id).unwrap()
3991        );
3992        assert!(left.snapshot().unwrap().slides.is_empty());
3993        assert!(left.comments().unwrap().is_empty());
3994        assert_equal_peers(&left, &right, "slide comment deletion");
3995        assert_eq!(
3996            assert_history_paths_match(&left, &right, "formatting and map writes", &anchors),
3997            5
3998        );
3999        for anchor in anchors {
4000            assert_eq!(
4001                left.resolve_caret_anchor(&anchor),
4002                right.resolve_caret_anchor(&anchor)
4003            );
4004        }
4005    }
4006
4007    #[test]
4008    fn rejected_comment_mutations_preserve_allocator_and_history() {
4009        let bytes = deterministic_deck_bytes();
4010        let ctx = EditCtx::local("human");
4011        for flavor in [CommentFlavor::Legacy, CommentFlavor::Modern] {
4012            let refused = DeckSession::open(&bytes, 712).unwrap();
4013            let fresh = DeckSession::open(&bytes, 712).unwrap();
4014            let slide_id = fresh.slide_ids().unwrap()[0].clone();
4015            let story_id = fresh.snapshot().unwrap().slides[0].shapes[0].text_stories[0]
4016                .id
4017                .clone();
4018            let mut root = String::new();
4019            let mut reply = String::new();
4020            for session in [&refused, &fresh] {
4021                session.set_undo_capture_mode(UndoCaptureMode::Manual);
4022                session.set_comment_flavor(&ctx, flavor).unwrap();
4023                root = add_test_comment(session, &ctx, &slide_id, "Root")
4024                    .unwrap()
4025                    .comment_id;
4026                if flavor == CommentFlavor::Modern {
4027                    reply = add_test_reply(session, &ctx, &root, "Reply")
4028                        .unwrap()
4029                        .comment_id;
4030                }
4031                session.add_undo_barrier();
4032                session
4033                    .insert_text(&ctx, &story_id, 0, "redo", &TextStyle::default())
4034                    .unwrap();
4035                assert!(session.undo());
4036                assert!(session.can_redo());
4037            }
4038            assert_refused_without_changes(&refused, "missing status target", || {
4039                refused.set_comment_status(&ctx, "missing-comment", true)
4040            });
4041            assert_refused_without_changes(&refused, "missing position target", || {
4042                refused.set_comment_position(&ctx, "missing-comment", 0, 0)
4043            });
4044            assert_refused_without_changes(&refused, "unsafe position", || {
4045                refused.set_comment_position(&ctx, &root, i64::MAX, 0)
4046            });
4047            assert_refused_without_changes(&refused, "missing removal target", || {
4048                refused.remove_comment(&ctx, "missing-comment")
4049            });
4050            assert_refused_without_changes(&refused, "fixed flavor", || {
4051                refused.set_comment_flavor(&ctx, CommentFlavor::Modern)
4052            });
4053            assert_refused_without_changes(&refused, "missing notes target", || {
4054                refused.set_slide_notes(&ctx, "missing-slide", "Notes")
4055            });
4056            if flavor == CommentFlavor::Legacy {
4057                assert_refused_without_changes(&refused, "legacy status", || {
4058                    refused.set_comment_status(&ctx, &root, true)
4059                });
4060            } else {
4061                assert_refused_without_changes(&refused, "reply position", || {
4062                    refused.set_comment_position(&ctx, &reply, 0, 0)
4063                });
4064            }
4065            assert_equal_peers(&refused, &fresh, "comment refusals");
4066            for session in [&refused, &fresh] {
4067                session
4068                    .set_comment_position(&ctx, &root, 12700, 25400)
4069                    .unwrap();
4070                if flavor == CommentFlavor::Modern {
4071                    session.set_comment_status(&ctx, &root, true).unwrap();
4072                }
4073                session.remove_comment(&ctx, &root).unwrap();
4074                session
4075                    .set_comment_flavor(&ctx, CommentFlavor::Modern)
4076                    .unwrap();
4077                session.set_slide_notes(&ctx, &slide_id, "Notes").unwrap();
4078            }
4079            assert_equal_peers(&refused, &fresh, "comment retries");
4080            assert!(assert_history_paths_match(&refused, &fresh, "comment refusals", &[]) > 0);
4081        }
4082    }
4083
4084    #[test]
4085    fn rejected_shape_creators_preflight_shape_order() {
4086        let bytes = deterministic_deck_bytes();
4087        let session = DeckSession::open(&bytes, 713).unwrap();
4088        let ctx = EditCtx::local("human");
4089        let slide_id = session.slide_ids().unwrap()[0].clone();
4090        let rect = ShapeRect {
4091            x: 0,
4092            y: 0,
4093            width: 1270000,
4094            height: 1270000,
4095        };
4096        let mut txn = session.doc.transact_mut_with("pptx:system");
4097        slide_ref(&txn, &slide_id)
4098            .unwrap()
4099            .insert(&mut txn, "shapes", "invalid");
4100        drop(txn);
4101        assert_refused_without_changes(&session, "text box shape order", || {
4102            session.add_text_box(
4103                &ctx,
4104                &slide_id,
4105                &ShapeDraft {
4106                    name: "Text".into(),
4107                    rect,
4108                    text: "Text".into(),
4109                    style: TextStyle::default(),
4110                },
4111            )
4112        });
4113        assert_refused_without_changes(&session, "preset shape order", || {
4114            session.add_shape(
4115                &ctx,
4116                &slide_id,
4117                &PresetShapeDraft {
4118                    name: "Shape".into(),
4119                    geometry: "rect".into(),
4120                    rect,
4121                    fill: None,
4122                },
4123            )
4124        });
4125        assert_refused_without_changes(&session, "picture shape order", || {
4126            session.add_picture(
4127                &ctx,
4128                &slide_id,
4129                &PictureDraft {
4130                    name: "Picture".into(),
4131                    rect,
4132                    content_type: "image/png".into(),
4133                    media_bytes: vec![1],
4134                },
4135            )
4136        });
4137    }
4138
4139    const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
4140    const HIDDEN_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/hidden-shapes.pptx");
4141
4142    fn assert_metadata_matches_snapshot(session: &DeckSession) {
4143        let snapshot = session.snapshot().unwrap();
4144        let slides: Vec<_> = snapshot
4145            .slides
4146            .iter()
4147            .enumerate()
4148            .map(|(index, slide)| {
4149                serde_json::json!({
4150                    "id": slide.id,
4151                    "index": index,
4152                    "name": slide.name,
4153                    "layoutPartPath": slide.layout_part_path,
4154                })
4155            })
4156            .collect();
4157        assert_eq!(
4158            serde_json::to_value(session.session_metadata().unwrap()).unwrap(),
4159            serde_json::json!({
4160                "slides": slides,
4161                "size": { "width": snapshot.width_emu, "height": snapshot.height_emu },
4162            })
4163        );
4164    }
4165
4166    #[test]
4167    fn session_metadata_matches_snapshots_for_fixtures_and_updates() {
4168        let files: &[&[u8]] = &[
4169            FIXTURE,
4170            HIDDEN_FIXTURE,
4171            include_bytes!("../tests/fixtures/blip-shadow.pptx"),
4172            include_bytes!("../tests/fixtures/chart-text-overflow.pptx"),
4173            include_bytes!("../tests/fixtures/deck-schema-v2-connectors.pptx"),
4174            include_bytes!("../tests/fixtures/deck-schema-v2-nested-connectors.pptx"),
4175            include_bytes!("../tests/fixtures/deck-schema-v2.1-defaults.pptx"),
4176            include_bytes!("../tests/fixtures/deck-schema-v2.1-edits.pptx"),
4177            include_bytes!("../tests/fixtures/metafile-tracking.pptx"),
4178            include_bytes!("../tests/fixtures/modern-comments.pptx"),
4179            include_bytes!("../tests/fixtures/run-spacing-shadow.pptx"),
4180            include_bytes!("../../../packages/pptx/src/render/fixtures/tiff-image.pptx"),
4181        ];
4182        let context = EditCtx::local("test");
4183        for bytes in files {
4184            let session = DeckSession::open(bytes, 101).unwrap();
4185            assert_metadata_matches_snapshot(&session);
4186            let restored =
4187                DeckSession::open_from_update(&session.encode_state_as_update_v1(), 102).unwrap();
4188            assert_metadata_matches_snapshot(&restored);
4189            assert_eq!(
4190                session.session_metadata().unwrap(),
4191                restored.session_metadata().unwrap()
4192            );
4193
4194            let layout = session
4195                .package()
4196                .layouts
4197                .first()
4198                .map(|layout| layout.part_path.as_str());
4199            let inserted = session.insert_slide(&context, 0, layout).unwrap();
4200            assert_metadata_matches_snapshot(&session);
4201            let last = session.slide_ids().unwrap().len() as u32 - 1;
4202            session
4203                .move_slide(&context, &inserted.slide_id, last)
4204                .unwrap();
4205            assert_metadata_matches_snapshot(&session);
4206            session.delete_slide(&context, &inserted.slide_id).unwrap();
4207            assert_metadata_matches_snapshot(&session);
4208
4209            let restored =
4210                DeckSession::open_from_update(&session.encode_state_as_update_v1(), 102).unwrap();
4211            assert_metadata_matches_snapshot(&restored);
4212            assert_eq!(
4213                session.session_metadata().unwrap(),
4214                restored.session_metadata().unwrap()
4215            );
4216        }
4217    }
4218
4219    #[test]
4220    fn session_metadata_matches_duplicate_order_and_optional_fields() {
4221        let session = DeckSession::open(FIXTURE, 103).unwrap();
4222        let id = session.slide_ids().unwrap()[0].clone();
4223        {
4224            let mut txn = session.doc.transact_mut();
4225            let order = required_order(&txn).unwrap();
4226            order.push_back(&mut txn, id.as_str());
4227            let slide = slide_ref(&txn, &id).unwrap();
4228            slide.remove(&mut txn, "name");
4229            slide.remove(&mut txn, "layoutPartPath");
4230        }
4231        assert_metadata_matches_snapshot(&session);
4232        {
4233            let mut txn = session.doc.transact_mut();
4234            let slide = slide_ref(&txn, &id).unwrap();
4235            slide.insert(&mut txn, "name", "Slide \"α\"\n");
4236            slide.insert(&mut txn, "layoutPartPath", "");
4237        }
4238        assert_metadata_matches_snapshot(&session);
4239    }
4240
4241    #[test]
4242    fn session_metadata_tracks_empty_decks() {
4243        let session = DeckSession::open(FIXTURE, 104).unwrap();
4244        let context = EditCtx::local("test");
4245        for id in session.slide_ids().unwrap() {
4246            session.delete_slide(&context, &id).unwrap();
4247            assert_metadata_matches_snapshot(&session);
4248        }
4249        assert!(session.session_metadata().unwrap().slides.is_empty());
4250        session.insert_slide(&context, 0, None).unwrap();
4251        assert_metadata_matches_snapshot(&session);
4252    }
4253
4254    #[test]
4255    fn a_reattached_source_restores_the_series_lines_a_stored_package_lacks() {
4256        let source = ChartSpace {
4257            plot_groups: vec![pptx_parse::ChartPlotGroup {
4258                series: vec![pptx_parse::ChartSeries {
4259                    line: Some(ooxml_drawingml::chart::ChartLine {
4260                        none: false,
4261                        color: Some("#FFFFFF".to_owned()),
4262                        width_emu: Some(41275.0),
4263                    }),
4264                    ..pptx_parse::ChartSeries::default()
4265                }],
4266                ..pptx_parse::ChartPlotGroup::default()
4267            }],
4268            ..ChartSpace::default()
4269        };
4270        let mut target = source.clone();
4271        target.plot_groups[0].series[0].line = None;
4272
4273        assert!(merge_source_chart_properties(&mut target, &source));
4274        assert_eq!(target, source);
4275        assert!(!merge_source_chart_properties(&mut target, &source));
4276    }
4277
4278    #[test]
4279    fn text_columns_are_restored_when_a_source_is_reattached() {
4280        let mut json = serde_json::to_value(pptx_parse::TextBody::default()).unwrap();
4281        json["numCol"] = 2.into();
4282        json["spcCol"] = 457_200.into();
4283        json["rtlCol"] = true.into();
4284        let source = serde_json::from_value(json).unwrap();
4285        let mut target = pptx_parse::TextBody::default();
4286        assert!(merge_source_text_body(&mut target, &source));
4287        assert_eq!(target, source);
4288        assert!(!merge_source_text_body(&mut target, &source));
4289    }
4290
4291    #[test]
4292    fn a_reattached_source_restores_picture_paths_a_stored_package_lacks() {
4293        let source = vec![ShapeNode::Picture(pptx_parse::Picture {
4294            base: ShapeBase {
4295                id: 42,
4296                name: "Custom picture".to_owned(),
4297                description: None,
4298                hidden: false,
4299                placeholder: None,
4300                transform: pptx_parse::ShapeTransform::default(),
4301            },
4302            aspect_ratio_locked: false,
4303            relationship_id: None,
4304            media_part_path: None,
4305            crop: pptx_parse::PictureCrop::default(),
4306            effects: Vec::new(),
4307            geometry: "custom".to_owned(),
4308            paths: vec![pptx_parse::CustomGeometryPath {
4309                commands: vec![
4310                    ooxml_drawingml::GeometryPathCommand::Move { x: 0.0, y: 0.0 },
4311                    ooxml_drawingml::GeometryPathCommand::Line { x: 1.0, y: 1.0 },
4312                ],
4313                no_fill: true,
4314                no_stroke: false,
4315            }],
4316            adjust_values: BTreeMap::new(),
4317            fill: None,
4318            outline: None,
4319            shape_effects: None,
4320            style: None,
4321        })];
4322        let mut target = source.clone();
4323        let ShapeNode::Picture(picture) = &mut target[0] else {
4324            panic!("expected picture");
4325        };
4326        picture.paths.clear();
4327
4328        assert!(merge_source_render_shapes(&mut target, &source));
4329        assert_eq!(target, source);
4330        assert!(!merge_source_render_shapes(&mut target, &source));
4331        let ShapeNode::Picture(picture) = &mut target[0] else {
4332            panic!("expected picture");
4333        };
4334        picture.paths[0].no_stroke = true;
4335        let before = target.clone();
4336        assert!(!merge_source_render_shapes(&mut target, &source));
4337        assert_eq!(target, before);
4338    }
4339
4340    #[test]
4341    fn a_reattached_source_restores_the_right_margin_a_stored_package_lacks() {
4342        let source = pptx_parse::ParagraphProperties {
4343            margin_right: Some(914_400),
4344            ..pptx_parse::ParagraphProperties::default()
4345        };
4346        let mut target = pptx_parse::ParagraphProperties::default();
4347
4348        assert!(merge_source_paragraph_properties(&mut target, &source));
4349        assert_eq!(target.margin_right, Some(914_400));
4350        assert!(!merge_source_paragraph_properties(&mut target, &source));
4351    }
4352
4353    #[test]
4354    fn a_reattached_source_restores_the_paragraph_spacing_a_stored_package_lacks() {
4355        let source = pptx_parse::ParagraphProperties {
4356            space_before: Some(pptx_parse::LineSpacing::Points { value: 10.0 }),
4357            space_after: Some(pptx_parse::LineSpacing::Percent { value: 0.2 }),
4358            ..pptx_parse::ParagraphProperties::default()
4359        };
4360        let mut target = pptx_parse::ParagraphProperties::default();
4361
4362        assert!(merge_source_paragraph_properties(&mut target, &source));
4363        assert_eq!(target.space_before, source.space_before);
4364        assert_eq!(target.space_after, source.space_after);
4365        assert!(!merge_source_paragraph_properties(&mut target, &source));
4366    }
4367
4368    #[test]
4369    fn attaching_a_source_refreshes_chart_text_a_stored_package_never_carried() {
4370        use ooxml_drawingml::chart::{ChartLegend, ChartTextProperties};
4371
4372        let styled = ChartTextProperties {
4373            spacing_pt: Some(3.0),
4374            ..ChartTextProperties::default()
4375        };
4376        let source = ChartSpace {
4377            text: Some(styled.clone()),
4378            title_text: Some(styled.clone()),
4379            legend: Some(ChartLegend {
4380                visible: true,
4381                text: Some(styled.clone()),
4382                ..ChartLegend::default()
4383            }),
4384            axis_list: Some(vec![ChartAxis {
4385                text: Some(styled.clone()),
4386                ..ChartAxis::default()
4387            }]),
4388            ..ChartSpace::default()
4389        };
4390        let mut target = ChartSpace {
4391            legend: Some(ChartLegend {
4392                visible: true,
4393                ..ChartLegend::default()
4394            }),
4395            axis_list: Some(vec![ChartAxis::default()]),
4396            ..ChartSpace::default()
4397        };
4398        assert!(merge_source_chart_properties(&mut target, &source));
4399        assert_eq!(target.text.as_ref(), Some(&styled));
4400        assert_eq!(target.title_text.as_ref(), Some(&styled));
4401        assert_eq!(target.legend.unwrap().text.as_ref(), Some(&styled));
4402        assert_eq!(target.axis_list.unwrap()[0].text.as_ref(), Some(&styled));
4403        assert!(!merge_source_chart_properties(&mut source.clone(), &source));
4404    }
4405
4406    #[test]
4407    fn an_unmigratable_schema_version_is_reported_before_package_json() {
4408        let session = DeckSession::open(FIXTURE, 100).unwrap();
4409        {
4410            let mut txn = session.doc.transact_mut();
4411            let meta = required_map(&txn, META).unwrap();
4412            meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
4413            meta.insert(
4414                &mut txn,
4415                "packageJson",
4416                Any::Buffer(Arc::from(b"{}".to_vec())),
4417            );
4418        }
4419
4420        assert!(matches!(
4421            validate_doc(&session.doc),
4422            Err(EditError::InvalidState(message))
4423                if message == "unsupported deck schema version"
4424        ));
4425        assert!(matches!(
4426            DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
4427            Err(EditError::InvalidState(message))
4428                if message == "unsupported deck schema version"
4429        ));
4430    }
4431
4432    #[test]
4433    fn migration_leaves_a_current_document_untouched() {
4434        let session = DeckSession::open(FIXTURE, 102).unwrap();
4435        let before = session.encode_state_as_update_v1();
4436        migrate_doc(&session.doc).unwrap();
4437        assert_eq!(session.encode_state_as_update_v1(), before);
4438    }
4439
4440    #[test]
4441    fn legacy_migrations_commit_once_and_normalise_the_package() {
4442        use std::sync::Mutex;
4443
4444        const V1: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v1.update.bin");
4445        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-connectors.update.bin");
4446        for update in [V1, V2] {
4447            let doc = crate::doc_with_client_id(920);
4448            crate::hydrate_doc(&doc, update).unwrap();
4449            let before = snapshot_doc(&doc, &package_from_doc(&doc).unwrap()).unwrap();
4450            let observed = Arc::new(Mutex::new(Vec::new()));
4451            let events = observed.clone();
4452            let _subscription = doc
4453                .observe_update_v1(move |txn, _| {
4454                    let meta = required_map(txn, META).unwrap();
4455                    let Some(Out::Any(Any::Buffer(package))) = meta.get(txn, "packageJson") else {
4456                        panic!("missing package data");
4457                    };
4458                    events
4459                        .lock()
4460                        .unwrap()
4461                        .push((map_number(&meta, txn, "schemaVersion").unwrap(), package));
4462                })
4463                .unwrap();
4464
4465            migrate_doc(&doc).unwrap();
4466
4467            let events = observed.lock().unwrap();
4468            assert_eq!(
4469                events
4470                    .iter()
4471                    .map(|(version, _)| *version)
4472                    .collect::<Vec<_>>(),
4473                [SCHEMA_VERSION]
4474            );
4475            for (_, package) in events.iter() {
4476                let json: serde_json::Value = serde_json::from_slice(package).unwrap();
4477                assert!(json.get("charts").unwrap().is_array());
4478                assert!(json.get("shapeElements").is_none());
4479                assert!(json["presentation"].get("firstSlideNum").is_none());
4480            }
4481            let package = package_from_doc(&doc).unwrap();
4482            assert_eq!(snapshot_doc(&doc, &package).unwrap(), before);
4483            assert!(!package.models_connectors());
4484            assert_eq!(package.presentation.first_slide_num, 1);
4485        }
4486    }
4487
4488    #[test]
4489    fn a_v2_hidden_snapshot_backfills_flags_without_rewriting_the_package() {
4490        use std::sync::Mutex;
4491        use yrs::Update;
4492        use yrs::updates::decoder::Decode;
4493
4494        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-hidden.update.bin");
4495        let doc = crate::doc_with_client_id(9430);
4496        doc.transact_mut()
4497            .apply_update(Update::decode_v1(V2).unwrap())
4498            .unwrap();
4499        let before = package_from_doc(&doc).unwrap();
4500        let observed = Arc::new(Mutex::new(Vec::new()));
4501        let events = observed.clone();
4502        let _subscription = doc
4503            .observe_update_v1(move |txn, _| {
4504                let meta = required_map(txn, META).unwrap();
4505                let shapes = required_map(txn, SHAPES).unwrap();
4506                let mut hidden: Vec<String> = shapes
4507                    .iter(txn)
4508                    .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
4509                    .filter(|(_, shape)| map_bool(shape, txn, "hidden").unwrap_or(false))
4510                    .map(|(id, _)| id.to_owned())
4511                    .collect();
4512                hidden.sort();
4513                events.lock().unwrap().push((
4514                    map_number(&meta, txn, "schemaVersion").unwrap(),
4515                    package_from_meta(&meta, txn).unwrap(),
4516                    hidden,
4517                ));
4518            })
4519            .unwrap();
4520        migrate_doc(&doc).unwrap();
4521        let events = observed.lock().unwrap();
4522        assert_eq!(
4523            events
4524                .iter()
4525                .map(|(version, _, _)| *version)
4526                .collect::<Vec<_>>(),
4527            [SCHEMA_VERSION]
4528        );
4529        for (_, package, hidden) in events.iter() {
4530            assert_eq!(
4531                serde_json::to_vec(package).unwrap(),
4532                serde_json::to_vec(&before).unwrap()
4533            );
4534            assert_eq!(
4535                hidden,
4536                &[
4537                    "slide:0:256:shape:0",
4538                    "slide:0:256:shape:8",
4539                    "slide:0:256:shape:8.13",
4540                    "slide:1:257:shape:16",
4541                ]
4542            );
4543        }
4544    }
4545
4546    /// `baseline_snapshot` must reproduce exactly what a freshly seeded
4547    /// scratch document snapshots to, including run merging under yrs'
4548    /// format-gap cleanup.
4549    #[test]
4550    fn baseline_matches_seeded_doc_snapshot() {
4551        let mut files: Vec<Vec<u8>> = vec![
4552            FIXTURE.to_vec(),
4553            HIDDEN_FIXTURE.to_vec(),
4554            include_bytes!("../tests/fixtures/blip-shadow.pptx").to_vec(),
4555            include_bytes!("../tests/fixtures/chart-text-overflow.pptx").to_vec(),
4556            include_bytes!("../tests/fixtures/deck-schema-v2-connectors.pptx").to_vec(),
4557            include_bytes!("../tests/fixtures/deck-schema-v2-nested-connectors.pptx").to_vec(),
4558            include_bytes!("../tests/fixtures/metafile-tracking.pptx").to_vec(),
4559            include_bytes!("../tests/fixtures/modern-comments.pptx").to_vec(),
4560            include_bytes!("../tests/fixtures/run-spacing-shadow.pptx").to_vec(),
4561        ];
4562        if let Ok(dir) = std::env::var("PPTX_DIR") {
4563            let mut extra: Vec<_> = std::fs::read_dir(dir)
4564                .unwrap()
4565                .filter_map(|e| e.ok().map(|e| e.path()))
4566                .filter(|p| p.extension().is_some_and(|e| e == "pptx"))
4567                .collect();
4568            extra.sort();
4569            files.extend(extra.iter().map(|path| std::fs::read(path).unwrap()));
4570        }
4571        for (index, bytes) in files.iter().enumerate() {
4572            let package = pptx_parse::parse_pptx(bytes).unwrap();
4573            let doc = crate::doc_with_client_id(crate::BOOTSTRAP_CLIENT_ID);
4574            seed_doc(&doc, &package, "").unwrap();
4575            let seeded = snapshot_doc(&doc, &package).unwrap();
4576            let direct = baseline_snapshot(&package).unwrap();
4577            assert_eq!(seeded, direct, "baseline snapshot differs for file {index}");
4578        }
4579    }
4580
4581    #[test]
4582    fn seeding_stores_hidden_only_for_hidden_shapes() {
4583        assert!(hidden_keys(&DeckSession::open(FIXTURE, 103).unwrap()).is_empty());
4584
4585        let session = DeckSession::open(HIDDEN_FIXTURE, 104).unwrap();
4586        assert_eq!(
4587            hidden_keys(&session),
4588            [
4589                "slide:0:256:shape:0",
4590                "slide:0:256:shape:8",
4591                "slide:0:256:shape:8.13",
4592                "slide:1:257:shape:16",
4593                "slide:1:257:shape:4",
4594            ]
4595        );
4596        let snapshot = session.snapshot().unwrap();
4597        let group = &snapshot.slides[0].shapes[8];
4598        assert!(snapshot.slides[0].shapes[0].hidden);
4599        assert!(group.hidden);
4600        assert_eq!(group.children.len(), 14);
4601        assert!(group.children[..13].iter().all(|child| !child.hidden));
4602        assert!(group.children[13].hidden);
4603    }
4604
4605    #[test]
4606    fn hidden_flags_serialise_sparsely_and_round_trip() {
4607        let snapshot = DeckSession::open(HIDDEN_FIXTURE, 106)
4608            .unwrap()
4609            .snapshot()
4610            .unwrap();
4611        let json = serde_json::to_string(&snapshot).unwrap();
4612        assert_eq!(json.matches("\"hidden\":true").count(), 5);
4613        assert!(!json.contains("\"hidden\":false"));
4614        assert_eq!(
4615            serde_json::from_str::<DeckSnapshot>(&json).unwrap(),
4616            snapshot
4617        );
4618    }
4619
4620    fn hidden_keys(session: &DeckSession) -> Vec<String> {
4621        let txn = session.doc.transact();
4622        let shapes = required_map(&txn, SHAPES).unwrap();
4623        let mut ids: Vec<String> = shapes
4624            .iter(&txn)
4625            .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
4626            .filter(|(_, shape)| shape.get(&txn, "hidden").is_some())
4627            .map(|(id, _)| id.to_owned())
4628            .collect();
4629        ids.sort();
4630        ids
4631    }
4632
4633    #[test]
4634    fn delete_operations_retain_owned_map_entries_for_undo() {
4635        let session = DeckSession::open(FIXTURE, 101).unwrap();
4636        let context = EditCtx::local("test");
4637
4638        for cycle in 0..3 {
4639            let baseline = map_lengths(&session);
4640            let index = session.snapshot().unwrap().slides.len() as u32;
4641            let slide = session.insert_slide(&context, index, None).unwrap();
4642            let first = session
4643                .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
4644                .unwrap();
4645            session
4646                .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
4647                .unwrap();
4648            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
4649
4650            session.add_undo_barrier();
4651            session
4652                .remove_shape(&context, &slide.slide_id, &first.shape_id)
4653                .unwrap();
4654            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
4655
4656            session.delete_slide(&context, &slide.slide_id).unwrap();
4657            assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
4658        }
4659    }
4660
4661    #[test]
4662    fn deleting_seeded_slide_retains_descendant_entries_for_undo() {
4663        let session = DeckSession::open(FIXTURE, 202).unwrap();
4664        let initial = session.snapshot().unwrap();
4665        let slide = &initial.slides[0];
4666        let before = map_lengths(&session);
4667
4668        session
4669            .delete_slide(&EditCtx::local("test"), &slide.id)
4670            .unwrap();
4671
4672        assert_eq!(map_lengths(&session), before);
4673        assert!(session.undo());
4674        assert_eq!(session.snapshot().unwrap(), initial);
4675    }
4676
4677    fn draft(cycle: u32, index: u32) -> ShapeDraft {
4678        ShapeDraft {
4679            name: format!("Shape {cycle}:{index}"),
4680            rect: ShapeRect {
4681                x: 100_000,
4682                y: 100_000,
4683                width: 1_000_000,
4684                height: 500_000,
4685            },
4686            text: "Text".to_owned(),
4687            style: TextStyle::default(),
4688        }
4689    }
4690
4691    fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
4692        let txn = session.doc.transact();
4693        (
4694            required_map(&txn, SLIDES).unwrap().len(&txn),
4695            required_map(&txn, SHAPES).unwrap().len(&txn),
4696            required_map(&txn, STORIES).unwrap().len(&txn),
4697        )
4698    }
4699
4700    fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
4701        (left.0 + right.0, left.1 + right.1, left.2 + right.2)
4702    }
4703
4704    #[test]
4705    fn a_v2_comment_snapshot_records_its_flavour_while_deferring_the_import() {
4706        use std::sync::Mutex;
4707        use yrs::Update;
4708        use yrs::updates::decoder::Decode;
4709
4710        const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-comments.update.bin");
4711        let doc = crate::doc_with_client_id(9620);
4712        doc.transact_mut()
4713            .apply_update(Update::decode_v1(V2).unwrap())
4714            .unwrap();
4715        let before = {
4716            let txn = doc.transact();
4717            let meta = required_map(&txn, META).unwrap();
4718            assert_eq!(map_number(&meta, &txn, "schemaVersion"), Some(2.0));
4719            assert!(meta.get(&txn, "commentFlavor").is_none());
4720            package_from_meta(&meta, &txn).unwrap()
4721        };
4722        let observed = Arc::new(Mutex::new(Vec::new()));
4723        let events = observed.clone();
4724        let _subscription = doc
4725            .observe_update_v1(move |txn, _| {
4726                let meta = required_map(txn, META).unwrap();
4727                events.lock().unwrap().push((
4728                    map_number(&meta, txn, "schemaVersion").unwrap(),
4729                    package_from_meta(&meta, txn).unwrap(),
4730                    map_string(&meta, txn, "commentFlavor"),
4731                    map_bool(&meta, txn, "commentsPendingSource"),
4732                ));
4733            })
4734            .unwrap();
4735        migrate_doc(&doc).unwrap();
4736        let events = observed.lock().unwrap();
4737        assert_eq!(
4738            *events,
4739            [(
4740                SCHEMA_VERSION,
4741                before,
4742                Some("legacy".to_owned()),
4743                Some(true)
4744            )]
4745        );
4746    }
4747}