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