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