Skip to main content

pptx_edit/
deck.rs

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