1use std::collections::{BTreeMap, HashMap, 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::{ChartAxis, ChartSpace, PptxPackage, ShapeBase, 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::comments::{flavor_key, seed_comments, snapshot_comments, snapshot_flavor};
16use crate::story::{seed_plain_story, seed_story, snapshot_story, validate_story};
17use crate::{
18 DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN,
19 PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt, ShapeDraft,
20 ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
21 ShapeStrokeReceipt, SlideReceipt, SlideSnapshot, TransformReceipt,
22};
23
24const SCHEMA_VERSION: f64 = 2.1;
25const MIGRATABLE_SCHEMA_VERSIONS: [f64; 3] = [1.0, 2.0, SCHEMA_VERSION];
27const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
28const MAX_SHAPE_DEPTH: usize = 128;
29const EMU_PER_POINT: f64 = 12_700.0;
30const MAX_ADJUSTMENTS: usize = 32;
31const MAX_ADJUSTMENT_INDEX: usize = 32;
32
33pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
34 let package_json =
35 serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
36 let mut txn = doc.transact_mut_with("pptx:bootstrap");
37 let meta = txn.get_or_insert_map(META);
38 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
39 meta.insert(&mut txn, "fingerprint", fingerprint);
40 meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
41 meta.insert(
42 &mut txn,
43 "heightEmu",
44 package.presentation.height_emu as f64,
45 );
46 meta.insert(
47 &mut txn,
48 "packageJson",
49 Any::Buffer(Arc::from(package_json)),
50 );
51 meta.insert(
52 &mut txn,
53 "commentFlavor",
54 flavor_key(package.comment_flavor.unwrap_or_default()),
55 );
56 let order = txn.get_or_insert_array(SLIDE_ORDER);
57 let slides = txn.get_or_insert_map(SLIDES);
58 let shapes = txn.get_or_insert_map(SHAPES);
59 let stories = txn.get_or_insert_map(STORIES);
60 let mut slide_id_by_part: HashMap<String, String> = HashMap::new();
61
62 for (slide_index, slide) in package.slides.iter().enumerate() {
63 let theme = slide_theme(package, slide);
64 let slide_id = seeded_slide_id(slide_index, package.presentation.slides[slide_index].id);
65 order.push_back(&mut txn, slide_id.as_str());
66 let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
67 slide_map.insert(&mut txn, "id", slide_id.as_str());
68 slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
69 if let Some(layout) = &slide.layout_part_path {
70 slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
71 }
72 if let Some(name) = &slide.name {
73 slide_map.insert(&mut txn, "name", name.as_str());
74 }
75 if !slide.notes.is_empty() {
76 slide_map.insert(&mut txn, "notes", slide.notes.as_str());
77 }
78 let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
79 for (shape_index, shape) in slide.shapes.iter().enumerate() {
80 let shape_id = seed_shape(
81 &shapes,
82 &stories,
83 &mut txn,
84 &slide_id,
85 &shape_index.to_string(),
86 shape,
87 theme,
88 )?;
89 shape_order.push_back(&mut txn, shape_id.as_str());
90 }
91 slide_id_by_part.insert(slide.part_path.clone(), slide_id);
92 }
93 seed_comments(&mut txn, package, &|part| {
94 slide_id_by_part.get(part).cloned()
95 })?;
96 Ok(())
97}
98
99fn seed_shape(
100 shapes: &MapRef,
101 stories: &MapRef,
102 txn: &mut TransactionMut<'_>,
103 slide_id: &str,
104 path: &str,
105 shape: &ShapeNode,
106 theme: Option<&Theme>,
107) -> EditResult<String> {
108 let shape_id = seeded_shape_id(slide_id, path);
109 let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
110 let base = shape_base(shape);
111 shape_map.insert(txn, "id", shape_id.as_str());
112 shape_map.insert(txn, "sourceId", base.id as f64);
113 shape_map.insert(txn, "name", base.name.as_str());
114 shape_map.insert(txn, "x", base.transform.x as f64);
115 shape_map.insert(txn, "y", base.transform.y as f64);
116 shape_map.insert(txn, "width", base.transform.width as f64);
117 shape_map.insert(txn, "height", base.transform.height as f64);
118 shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
119 shape_map.insert(txn, "flipH", base.transform.flip_h);
120 shape_map.insert(txn, "flipV", base.transform.flip_v);
121 if base.hidden {
122 shape_map.insert(txn, "hidden", true);
123 }
124 insert_json(
125 &shape_map,
126 txn,
127 "placeholderJson",
128 base.placeholder.as_ref(),
129 )?;
130
131 let mut text_story_ids = Vec::new();
132 let mut child_ids = Vec::new();
133 match shape {
134 ShapeNode::Shape(shape) => {
135 shape_map.insert(txn, "kind", "shape");
136 shape_map.insert(txn, "geometry", shape.geometry.as_str());
137 if !shape.has_preset_geometry {
138 shape_map.insert(txn, "hasPresetGeometry", false);
139 }
140 let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
141 .into_iter()
142 .collect::<BTreeMap<_, _>>();
143 adjust_values.extend(shape.adjust_values.clone());
144 insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
145 insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
146 insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
147 if let Some(body) = &shape.text {
148 let story_id = format!("story:{shape_id}:0");
149 seed_story(stories, txn, &story_id, body, theme)?;
150 text_story_ids.push(story_id);
151 }
152 }
153 ShapeNode::Picture(picture) => {
154 shape_map.insert(txn, "kind", "picture");
155 shape_map.insert(txn, "geometry", "rect");
156 insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
157 insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
158 if !picture.effects.is_empty() {
159 insert_json(&shape_map, txn, "blipEffectsJson", Some(&picture.effects))?;
160 }
161 if let Some(media) = &picture.media_part_path {
162 shape_map.insert(txn, "mediaPartPath", media.as_str());
163 }
164 }
165 ShapeNode::GraphicFrame(frame) => {
166 shape_map.insert(txn, "kind", "graphicFrame");
167 shape_map.insert(txn, "geometry", "rect");
168 insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
169 if let pptx_parse::GraphicFrameData::Table(table) = &frame.data {
170 for (row_index, row) in table.rows.iter().enumerate() {
171 for (cell_index, cell) in row.cells.iter().enumerate() {
172 let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
173 seed_story(stories, txn, &story_id, &cell.text, theme)?;
174 text_story_ids.push(story_id);
175 }
176 }
177 }
178 }
179 ShapeNode::Group(group) => {
180 shape_map.insert(txn, "kind", "group");
181 shape_map.insert(txn, "geometry", "group");
182 for (child_index, child) in group.children.iter().enumerate() {
183 child_ids.push(seed_shape(
184 shapes,
185 stories,
186 txn,
187 slide_id,
188 &seeded_child_path(path, child_index),
189 child,
190 theme,
191 )?);
192 }
193 }
194 }
195 shape_map.insert(txn, "textStories", string_array(&text_story_ids));
196 shape_map.insert(txn, "children", string_array(&child_ids));
197 Ok(shape_id)
198}
199
200fn seeded_slide_id(slide_index: usize, reference_id: u32) -> String {
201 format!("slide:{slide_index}:{reference_id}")
202}
203
204fn seeded_shape_id(slide_id: &str, path: &str) -> String {
205 format!("{slide_id}:shape:{path}")
206}
207
208fn seeded_child_path(path: &str, child_index: usize) -> String {
209 format!("{path}.{child_index}")
210}
211
212pub(crate) fn shape_base(shape: &ShapeNode) -> &ShapeBase {
213 match shape {
214 ShapeNode::Shape(shape) => &shape.base,
215 ShapeNode::Picture(shape) => &shape.base,
216 ShapeNode::GraphicFrame(shape) => &shape.base,
217 ShapeNode::Group(shape) => &shape.base,
218 }
219}
220
221fn slide_theme<'a>(package: &'a PptxPackage, slide: &Slide) -> Option<&'a Theme> {
222 theme_for_layout(package, slide.layout_part_path.as_deref())
223}
224
225fn theme_for_layout<'a>(
226 package: &'a PptxPackage,
227 layout_part_path: Option<&str>,
228) -> Option<&'a Theme> {
229 let layout = layout_part_path
230 .and_then(|path| {
231 package
232 .layouts
233 .iter()
234 .find(|layout| layout.part_path == path)
235 })
236 .or_else(|| package.layouts.first());
237 let master = layout
238 .and_then(|layout| layout.master_part_path.as_deref())
239 .and_then(|path| {
240 package
241 .masters
242 .iter()
243 .find(|master| master.part_path == path)
244 })
245 .or_else(|| {
246 layout.and_then(|layout| {
247 package.masters.iter().find(|master| {
248 master
249 .layout_part_paths
250 .iter()
251 .any(|path| path == &layout.part_path)
252 })
253 })
254 })
255 .or_else(|| package.masters.first());
256 master
257 .and_then(|master| master.theme_part_path.as_deref())
258 .and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
259 .map(|part| &part.theme)
260 .or_else(|| package.themes.first().map(|part| &part.theme))
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 pub fn insert_slide(
283 &self,
284 context: &EditCtx,
285 index: u32,
286 layout_part_path: Option<&str>,
287 ) -> EditResult<SlideReceipt> {
288 if let Some(path) = layout_part_path
289 && !self
290 .package
291 .layouts
292 .iter()
293 .any(|layout| layout.part_path == path)
294 {
295 return Err(EditError::InvalidState(format!(
296 "unknown slide layout {path:?}"
297 )));
298 }
299 let slide_id = self.next_id("slide");
300 let mut txn = self.transact_for(context);
301 let order = required_order(&txn)?;
302 let length = order.len(&txn);
303 if index > length {
304 return Err(EditError::OutOfBounds { index, length });
305 }
306 let slides = required_map(&txn, SLIDES)?;
307 let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
308 slide.insert(&mut txn, "id", slide_id.as_str());
309 slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
310 if let Some(layout_part_path) = layout_part_path {
311 slide.insert(&mut txn, "layoutPartPath", layout_part_path);
312 }
313 slide.insert(&mut txn, "shapes", ArrayPrelim::default());
314 order.insert(&mut txn, index, slide_id.as_str());
315 Ok(SlideReceipt {
316 slide_id,
317 from_index: None,
318 to_index: Some(index),
319 })
320 }
321
322 pub fn set_slide_notes(&self, context: &EditCtx, slide_id: &str, text: &str) -> EditResult<()> {
323 crate::model::validate_xml_text(text)?;
324 self.add_undo_barrier();
325 let mut txn = self.transact_for(context);
326 let slide = slide_ref(&txn, slide_id)?;
327 slide.insert(&mut txn, "notes", text);
328 drop(txn);
329 self.add_undo_barrier();
330 Ok(())
331 }
332
333 pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
334 let mut txn = self.transact_for(context);
335 let order = required_order(&txn)?;
336 let index = array_index(&order, &txn, slide_id)
337 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
338 let slides = required_map(&txn, SLIDES)?;
339 let slide = slide_ref(&txn, slide_id)?;
340 let shape_order = slide_shape_order(&slide, &txn)?;
341 let shape_ids = string_array_ref(&shape_order, &txn);
342 remove_shape_entries(&mut txn, &shape_ids)?;
343 let comments = required_map(&txn, crate::COMMENTS)?;
344 let comment_ids: Vec<String> = comments
345 .iter(&txn)
346 .filter_map(|(id, value)| {
347 let entry = value.cast::<MapRef>().ok()?;
348 (map_string(&entry, &txn, "slideId").as_deref() == Some(slide_id))
349 .then(|| id.to_owned())
350 })
351 .collect();
352 for id in comment_ids {
353 comments.remove(&mut txn, &id);
354 }
355 order.remove(&mut txn, index);
356 slides.remove(&mut txn, slide_id);
357 Ok(SlideReceipt {
358 slide_id: slide_id.to_owned(),
359 from_index: Some(index),
360 to_index: None,
361 })
362 }
363
364 pub fn move_slide(
365 &self,
366 context: &EditCtx,
367 slide_id: &str,
368 to_index: u32,
369 ) -> EditResult<SlideReceipt> {
370 let mut txn = self.transact_for(context);
371 let order = required_order(&txn)?;
372 let length = order.len(&txn);
373 if to_index >= length {
374 return Err(EditError::OutOfBounds {
375 index: to_index,
376 length,
377 });
378 }
379 let from_index = array_index(&order, &txn, slide_id)
380 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
381 if from_index != to_index {
382 order.remove(&mut txn, from_index);
383 order.insert(&mut txn, to_index, slide_id);
384 }
385 Ok(SlideReceipt {
386 slide_id: slide_id.to_owned(),
387 from_index: Some(from_index),
388 to_index: Some(to_index),
389 })
390 }
391
392 pub fn add_text_box(
393 &self,
394 context: &EditCtx,
395 slide_id: &str,
396 draft: &ShapeDraft,
397 ) -> EditResult<ShapeReceipt> {
398 validate_rect(draft.rect)?;
399 crate::model::validate_xml_text(&draft.name)?;
400 crate::model::validate_xml_text(&draft.text)?;
401 crate::story::validate_style_values(
402 draft.style.font_family.as_deref(),
403 draft.style.underline.as_deref(),
404 draft.style.color.as_deref(),
405 draft.style.font_size_pt,
406 draft.style.spacing_pt,
407 draft.style.baseline_pct,
408 )?;
409 let shape_id = self.next_id("shape");
410 let story_id = format!("story:{shape_id}:0");
411 let paragraph_id = self.next_id("para");
412 let mut txn = self.transact_for(context);
413 let slide = slide_ref(&txn, slide_id)?;
414 let order = slide_shape_order(&slide, &txn)?;
415 let index = order.len(&txn);
416 let shapes = required_map(&txn, SHAPES)?;
417 let stories = required_map(&txn, STORIES)?;
418 seed_plain_story(
419 &stories,
420 &mut txn,
421 &story_id,
422 ¶graph_id,
423 &draft.text,
424 &draft.style,
425 );
426 let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
427 shape.insert(&mut txn, "id", shape_id.as_str());
428 shape.insert(&mut txn, "sourceId", 0_f64);
429 shape.insert(&mut txn, "kind", "shape");
430 shape.insert(&mut txn, "name", draft.name.as_str());
431 shape.insert(&mut txn, "x", draft.rect.x as f64);
432 shape.insert(&mut txn, "y", draft.rect.y as f64);
433 shape.insert(&mut txn, "width", draft.rect.width as f64);
434 shape.insert(&mut txn, "height", draft.rect.height as f64);
435 shape.insert(&mut txn, "rotationDeg", 0_f64);
436 shape.insert(&mut txn, "flipH", false);
437 shape.insert(&mut txn, "flipV", false);
438 shape.insert(&mut txn, "geometry", "rect");
439 insert_json(
440 &shape,
441 &mut txn,
442 "fillJson",
443 Some(&ShapeFill::named("none")),
444 )?;
445 shape.insert(
446 &mut txn,
447 "textStories",
448 string_array(std::slice::from_ref(&story_id)),
449 );
450 shape.insert(&mut txn, "children", string_array(&[]));
451 order.push_back(&mut txn, shape_id.as_str());
452 Ok(ShapeReceipt {
453 slide_id: slide_id.to_owned(),
454 shape_id,
455 index,
456 })
457 }
458
459 pub fn add_shape(
460 &self,
461 context: &EditCtx,
462 slide_id: &str,
463 draft: &PresetShapeDraft,
464 ) -> EditResult<ShapeReceipt> {
465 validate_rect(draft.rect)?;
466 crate::model::validate_xml_text(&draft.name)?;
467 let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
468 if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none() {
469 return Err(EditError::InvalidGeometry(format!(
470 "unsupported preset geometry {}",
471 draft.geometry
472 )));
473 }
474 let fill = shape_fill(draft.fill.as_deref())?;
475 let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
476 .into_iter()
477 .collect::<BTreeMap<_, _>>();
478 let shape_id = self.next_id("shape");
479 let mut txn = self.transact_for(context);
480 let slide = slide_ref(&txn, slide_id)?;
481 let order = slide_shape_order(&slide, &txn)?;
482 let index = order.len(&txn);
483 let shapes = required_map(&txn, SHAPES)?;
484 let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
485 shape.insert(&mut txn, "id", shape_id.as_str());
486 shape.insert(&mut txn, "sourceId", 0_f64);
487 shape.insert(&mut txn, "kind", "shape");
488 shape.insert(&mut txn, "name", draft.name.as_str());
489 shape.insert(&mut txn, "x", draft.rect.x as f64);
490 shape.insert(&mut txn, "y", draft.rect.y as f64);
491 shape.insert(&mut txn, "width", draft.rect.width as f64);
492 shape.insert(&mut txn, "height", draft.rect.height as f64);
493 shape.insert(&mut txn, "rotationDeg", 0_f64);
494 shape.insert(&mut txn, "flipH", false);
495 shape.insert(&mut txn, "flipV", false);
496 shape.insert(&mut txn, "geometry", draft.geometry.as_str());
497 insert_json(&shape, &mut txn, "adjustValuesJson", Some(&adjust_values))?;
498 insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
499 shape.insert(&mut txn, "textStories", string_array(&[]));
500 shape.insert(&mut txn, "children", string_array(&[]));
501 order.push_back(&mut txn, shape_id.as_str());
502 Ok(ShapeReceipt {
503 slide_id: slide_id.to_owned(),
504 shape_id,
505 index,
506 })
507 }
508
509 pub fn set_shape_fill(
510 &self,
511 context: &EditCtx,
512 slide_id: &str,
513 shape_id: &str,
514 color: Option<&str>,
515 ) -> EditResult<ShapeFillReceipt> {
516 let fill = shape_fill(color)?;
517 let mut txn = self.transact_for(context);
518 require_shape_membership(&txn, slide_id, shape_id)?;
519 let shape = shape_ref(&txn, shape_id)?;
520 require_shape_kind(&shape, &txn)?;
521 let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
522 .as_ref()
523 .and_then(fill_color);
524 insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
525 Ok(ShapeFillReceipt {
526 slide_id: slide_id.to_owned(),
527 shape_id: shape_id.to_owned(),
528 before,
529 after: fill_color(&fill),
530 })
531 }
532
533 pub fn set_shape_stroke(
534 &self,
535 context: &EditCtx,
536 slide_id: &str,
537 shape_id: &str,
538 stroke: &ShapeStroke,
539 ) -> EditResult<ShapeStrokeReceipt> {
540 let color = stroke.color.as_deref().map(color_value).transpose()?;
541 if let Some(width) = stroke.width_pt
542 && (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
543 {
544 return Err(EditError::InvalidGeometry(format!(
545 "stroke width {width}pt is outside the safe range"
546 )));
547 }
548 let mut txn = self.transact_for(context);
549 require_shape_membership(&txn, slide_id, shape_id)?;
550 let shape = shape_ref(&txn, shape_id)?;
551 require_shape_kind(&shape, &txn)?;
552 let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
553 let before = existing.as_ref().and_then(outline_stroke);
554 let outline = if stroke.color.is_none() && stroke.width_pt.is_none() {
555 ShapeOutline::default()
556 } else {
557 let mut outline = existing.unwrap_or_default();
558 if let Some(color) = color {
559 if outline.width.is_none() {
560 outline.width = Some(EMU_PER_POINT);
561 }
562 outline.color = Some(color);
563 outline.gradient = None;
564 } else if outline.color.is_none() && outline.gradient.is_none() {
565 outline.color = Some(color_value("#000000")?);
566 }
567 if let Some(width) = stroke.width_pt {
568 outline.width = Some(width * EMU_PER_POINT);
569 }
570 outline
571 };
572 insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
573 Ok(ShapeStrokeReceipt {
574 slide_id: slide_id.to_owned(),
575 shape_id: shape_id.to_owned(),
576 before,
577 after: outline_stroke(&outline),
578 })
579 }
580
581 pub fn set_shape_adjust(
582 &self,
583 context: &EditCtx,
584 slide_id: &str,
585 shape_id: &str,
586 adjustments: &BTreeMap<String, f64>,
587 ) -> EditResult<ShapeAdjustReceipt> {
588 validate_adjustments(adjustments)?;
589 let mut txn = self.transact_for(context);
590 require_shape_membership(&txn, slide_id, shape_id)?;
591 let shape = shape_ref(&txn, shape_id)?;
592 require_shape_kind(&shape, &txn)?;
593 let geometry = required_string(&shape, &txn, "geometry")?;
594 if geometry == "custom" {
595 return Err(EditError::InvalidGeometry(
596 "custom geometry does not support shape adjustments".to_owned(),
597 ));
598 }
599 if !map_bool(&shape, &txn, "hasPresetGeometry").unwrap_or(true) {
600 return Err(EditError::InvalidGeometry(
601 "shape without preset geometry does not support shape adjustments".to_owned(),
602 ));
603 }
604 let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
605 let mut after = BTreeMap::new();
606 for (name, value) in adjustments {
607 let maximum = if geometry == "roundRect" && name == "adj" {
608 0.5
609 } else {
610 1.0
611 };
612 after.insert(name.clone(), value.clamp(0.0, maximum));
613 }
614 insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
615 Ok(ShapeAdjustReceipt {
616 slide_id: slide_id.to_owned(),
617 shape_id: shape_id.to_owned(),
618 before,
619 after,
620 })
621 }
622
623 pub fn remove_shape(
624 &self,
625 context: &EditCtx,
626 slide_id: &str,
627 shape_id: &str,
628 ) -> EditResult<ShapeReceipt> {
629 let mut txn = self.transact_for(context);
630 let slide = slide_ref(&txn, slide_id)?;
631 let order = slide_shape_order(&slide, &txn)?;
632 let index = array_index(&order, &txn, shape_id)
633 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
634 remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
635 order.remove(&mut txn, index);
636 Ok(ShapeReceipt {
637 slide_id: slide_id.to_owned(),
638 shape_id: shape_id.to_owned(),
639 index,
640 })
641 }
642
643 pub fn move_shape(
644 &self,
645 context: &EditCtx,
646 slide_id: &str,
647 shape_id: &str,
648 x: i64,
649 y: i64,
650 ) -> EditResult<TransformReceipt> {
651 validate_coordinate(x)?;
652 validate_coordinate(y)?;
653 let mut txn = self.transact_for(context);
654 require_shape_membership(&txn, slide_id, shape_id)?;
655 let shape = shape_ref(&txn, shape_id)?;
656 let before = shape_rect(&shape, &txn)?;
657 shape.insert(&mut txn, "x", x as f64);
658 shape.insert(&mut txn, "y", y as f64);
659 Ok(TransformReceipt {
660 slide_id: slide_id.to_owned(),
661 shape_id: shape_id.to_owned(),
662 before,
663 after: ShapeRect { x, y, ..before },
664 })
665 }
666
667 pub fn resize_shape(
668 &self,
669 context: &EditCtx,
670 slide_id: &str,
671 shape_id: &str,
672 width: i64,
673 height: i64,
674 ) -> EditResult<TransformReceipt> {
675 let rect = ShapeRect {
676 width,
677 height,
678 ..ShapeRect::default()
679 };
680 validate_rect(rect)?;
681 let mut txn = self.transact_for(context);
682 require_shape_membership(&txn, slide_id, shape_id)?;
683 let shape = shape_ref(&txn, shape_id)?;
684 let before = shape_rect(&shape, &txn)?;
685 shape.insert(&mut txn, "width", width as f64);
686 shape.insert(&mut txn, "height", height as f64);
687 Ok(TransformReceipt {
688 slide_id: slide_id.to_owned(),
689 shape_id: shape_id.to_owned(),
690 before,
691 after: ShapeRect {
692 width,
693 height,
694 ..before
695 },
696 })
697 }
698
699 pub fn set_shape_rect(
700 &self,
701 context: &EditCtx,
702 slide_id: &str,
703 shape_id: &str,
704 rect: ShapeRect,
705 ) -> EditResult<TransformReceipt> {
706 validate_rect(rect)?;
707 let mut txn = self.transact_for(context);
708 require_shape_membership(&txn, slide_id, shape_id)?;
709 let shape = shape_ref(&txn, shape_id)?;
710 let before = shape_rect(&shape, &txn)?;
711 shape.insert(&mut txn, "x", rect.x as f64);
712 shape.insert(&mut txn, "y", rect.y as f64);
713 shape.insert(&mut txn, "width", rect.width as f64);
714 shape.insert(&mut txn, "height", rect.height as f64);
715 Ok(TransformReceipt {
716 slide_id: slide_id.to_owned(),
717 shape_id: shape_id.to_owned(),
718 before,
719 after: rect,
720 })
721 }
722}
723
724pub(crate) fn validate_doc(doc: &Doc) -> EditResult<()> {
725 let package = {
726 let txn = doc.transact();
727 let meta = required_map(&txn, META)?;
728 validate_schema_version(&meta, &txn)?;
729 if map_string(&meta, &txn, "fingerprint").is_none() {
730 return Err(EditError::InvalidState("missing fingerprint".to_owned()));
731 }
732 package_from_meta(&meta, &txn)?
733 };
734 let snapshot = snapshot_doc(doc, &package)?;
735 if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
736 return Err(EditError::InvalidState(
737 "slide dimensions must be positive".to_owned(),
738 ));
739 }
740 let txn = doc.transact();
741 let stories = required_map(&txn, STORIES)?;
742 for (story_id, value) in stories.iter(&txn) {
743 let story = value
744 .cast::<TextRef>()
745 .map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
746 validate_story(&story, &txn, story_id)?;
747 }
748 Ok(())
749}
750
751pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
752 let txn = doc.transact();
753 let meta = required_map(&txn, META)?;
754 validate_schema_version(&meta, &txn)?;
755 package_from_meta(&meta, &txn)
756}
757
758pub(crate) fn import_source_render_data(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
759 let mut package = package_from_doc(doc)?;
760 let sources = source
761 .slides
762 .iter()
763 .map(|part| (&part.part_path, &part.shapes))
764 .chain(
765 source
766 .layouts
767 .iter()
768 .map(|part| (&part.part_path, &part.shapes)),
769 )
770 .chain(
771 source
772 .masters
773 .iter()
774 .map(|part| (&part.part_path, &part.shapes)),
775 )
776 .collect::<HashMap<_, _>>();
777 let mut changed = false;
778 for (path, shapes) in package
779 .slides
780 .iter_mut()
781 .map(|part| (&part.part_path, &mut part.shapes))
782 .chain(
783 package
784 .layouts
785 .iter_mut()
786 .map(|part| (&part.part_path, &mut part.shapes)),
787 )
788 .chain(
789 package
790 .masters
791 .iter_mut()
792 .map(|part| (&part.part_path, &mut part.shapes)),
793 )
794 {
795 if let Some(source) = sources.get(path) {
796 changed |= merge_source_render_shapes(shapes, source);
797 }
798 }
799 for master in &mut package.masters {
800 if let Some(source) = source
801 .masters
802 .iter()
803 .find(|source| source.part_path == master.part_path)
804 {
805 let target = &mut master.text_styles;
806 let source = &source.text_styles;
807 for (target, source) in target
808 .title
809 .iter_mut()
810 .zip(&source.title)
811 .chain(target.body.iter_mut().zip(&source.body))
812 .chain(target.other.iter_mut().zip(&source.other))
813 {
814 changed |= merge_source_paragraph_properties(target, source);
815 }
816 }
817 }
818 for chart in &mut package.charts {
819 if let Some(source) = source
820 .charts
821 .iter()
822 .find(|source| source.part_path == chart.part_path)
823 {
824 changed |= merge_source_chart_properties(&mut chart.chart, &source.chart);
825 }
826 }
827 for slide in &mut package.slides {
828 if let Some(source) = source
829 .slides
830 .iter()
831 .find(|source| source.part_path == slide.part_path)
832 && slide.notes != source.notes
833 {
834 slide.notes.clone_from(&source.notes);
835 changed = true;
836 }
837 }
838 if package.table_styles != source.table_styles {
839 package.table_styles.clone_from(&source.table_styles);
840 changed = true;
841 }
842 if !changed {
843 return Ok(());
844 }
845 let bytes = serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
846 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
847 let meta = required_map(&txn, META)?;
848 meta.insert(&mut txn, "packageJson", Any::Buffer(Arc::from(bytes)));
849 backfill_blip_effects(&mut txn, &package)?;
850 backfill_tables(&mut txn, &package)?;
851 Ok(())
852}
853
854fn merge_source_render_shapes(target: &mut [ShapeNode], source: &[ShapeNode]) -> bool {
855 let mut changed = false;
856 for source in source {
857 let Some(target) = target
858 .iter_mut()
859 .find(|target| shape_base(target).id == shape_base(source).id)
860 else {
861 continue;
862 };
863 match (target, source) {
864 (ShapeNode::Shape(target), ShapeNode::Shape(source)) => {
865 changed |= target.picture_fill != source.picture_fill;
866 target.picture_fill.clone_from(&source.picture_fill);
867 if let (Some(target), Some(source)) = (&mut target.text, &source.text) {
868 changed |= merge_source_text_body(target, source);
869 }
870 }
871 (ShapeNode::Picture(target), ShapeNode::Picture(source)) => {
872 if target.effects.is_empty() && !source.effects.is_empty() {
873 target.effects.clone_from(&source.effects);
874 changed = true;
875 }
876 }
877 (ShapeNode::Group(target), ShapeNode::Group(source)) => {
878 changed |= merge_source_render_shapes(&mut target.children, &source.children);
879 }
880 (ShapeNode::GraphicFrame(target), ShapeNode::GraphicFrame(source)) => {
881 if matches!(
882 target.data,
883 pptx_parse::GraphicFrameData::Unknown { picture: None, .. }
884 ) && matches!(
885 source.data,
886 pptx_parse::GraphicFrameData::Unknown {
887 picture: Some(_),
888 ..
889 }
890 ) {
891 target.data.clone_from(&source.data);
892 changed = true;
893 }
894 if let (
895 pptx_parse::GraphicFrameData::Table(target),
896 pptx_parse::GraphicFrameData::Table(source),
897 ) = (&mut target.data, &source.data)
898 {
899 changed |= merge_source_table(target, source);
900 }
901 }
902 _ => {}
903 }
904 }
905 changed
906}
907
908fn merge_source_chart_properties(target: &mut ChartSpace, source: &ChartSpace) -> bool {
911 let mut changed = target.fill != source.fill;
912 target.fill.clone_from(&source.fill);
913 changed |= target.text != source.text || target.title_text != source.title_text;
914 target.text.clone_from(&source.text);
915 target.title_text.clone_from(&source.title_text);
916 if let (Some(target), Some(source)) = (&mut target.legend, &source.legend) {
917 changed |= target.text != source.text;
918 target.text.clone_from(&source.text);
919 }
920 for (target, source) in target.plot_groups.iter_mut().zip(&source.plot_groups) {
921 for (target, source) in target.series.iter_mut().zip(&source.series) {
922 changed |= target.line != source.line;
923 target.line.clone_from(&source.line);
924 }
925 }
926 let mut merge_axis = |target: Option<&mut ChartAxis>, source: Option<&ChartAxis>| {
927 if let (Some(target), Some(source)) = (target, source) {
928 changed |= target.line != source.line || target.text != source.text;
929 target.line.clone_from(&source.line);
930 target.text.clone_from(&source.text);
931 }
932 };
933 if let (Some(target), Some(source)) = (&mut target.axes, &source.axes) {
934 merge_axis(target.category.as_mut(), source.category.as_ref());
935 merge_axis(target.value.as_mut(), source.value.as_ref());
936 }
937 if let (Some(target), Some(source)) = (&mut target.axis_list, &source.axis_list) {
938 for (target, source) in target.iter_mut().zip(source) {
939 merge_axis(Some(target), Some(source));
940 }
941 }
942 changed
943}
944
945fn backfill_tables(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
946 let shapes = required_map(txn, SHAPES)?;
947 for (index, (slide, reference)) in package
948 .slides
949 .iter()
950 .zip(&package.presentation.slides)
951 .enumerate()
952 {
953 let slide_id = seeded_slide_id(index, reference.id);
954 for (index, node) in slide.shapes.iter().enumerate() {
955 backfill_table(txn, &shapes, &slide_id, &index.to_string(), node)?;
956 }
957 }
958 Ok(())
959}
960
961fn backfill_table(
962 txn: &mut TransactionMut<'_>,
963 shapes: &MapRef,
964 slide_id: &str,
965 path: &str,
966 node: &ShapeNode,
967) -> EditResult<()> {
968 if let ShapeNode::GraphicFrame(frame) = node
969 && let pptx_parse::GraphicFrameData::Table(table) = &frame.data
970 && !table.grid.is_empty()
971 && let Some(shape) = shapes
972 .get(txn, &seeded_shape_id(slide_id, path))
973 .and_then(|value| value.cast::<MapRef>().ok())
974 && matches!(
975 optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
976 Some(pptx_parse::GraphicFrameData::Table(stored)) if stored.grid.is_empty()
977 )
978 {
979 insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
980 }
981 if let ShapeNode::Group(group) = node {
982 for (index, child) in group.children.iter().enumerate() {
983 backfill_table(
984 txn,
985 shapes,
986 slide_id,
987 &seeded_child_path(path, index),
988 child,
989 )?;
990 }
991 }
992 Ok(())
993}
994
995fn merge_source_table(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
997 let mut changed = target.grid != source.grid || target.properties != source.properties;
998 target.grid.clone_from(&source.grid);
999 target.properties.clone_from(&source.properties);
1000 for (target, source) in target.rows.iter_mut().zip(&source.rows) {
1001 changed |= target.height != source.height;
1002 target.height = source.height;
1003 for (target, source) in target.cells.iter_mut().zip(&source.cells) {
1004 changed |= target.grid_span != source.grid_span
1005 || target.row_span != source.row_span
1006 || target.merged != source.merged
1007 || target.fill != source.fill
1008 || target.borders != source.borders;
1009 target.grid_span = source.grid_span;
1010 target.row_span = source.row_span;
1011 target.merged = source.merged;
1012 target.fill.clone_from(&source.fill);
1013 target.borders.clone_from(&source.borders);
1014 changed |= merge_source_text_body(&mut target.text, &source.text);
1015 }
1016 }
1017 changed
1018}
1019
1020fn merge_source_text_body(
1021 target: &mut pptx_parse::TextBody,
1022 source: &pptx_parse::TextBody,
1023) -> bool {
1024 let mut changed = target.list_style != source.list_style
1025 || target.default_list_style != source.default_list_style
1026 || target.compat_line_spacing != source.compat_line_spacing
1027 || target.vertical_overflow != source.vertical_overflow
1028 || target.horizontal_overflow != source.horizontal_overflow;
1029 target.vertical_overflow = source.vertical_overflow;
1030 target.horizontal_overflow = source.horizontal_overflow;
1031 target.compat_line_spacing = source.compat_line_spacing;
1032 target.list_style.clone_from(&source.list_style);
1033 target
1034 .default_list_style
1035 .clone_from(&source.default_list_style);
1036 for (target, source) in target.paragraphs.iter_mut().zip(&source.paragraphs) {
1037 changed |= merge_source_paragraph_properties(&mut target.properties, &source.properties);
1038 }
1039 changed
1040}
1041
1042fn merge_source_paragraph_properties(
1043 target: &mut pptx_parse::ParagraphProperties,
1044 source: &pptx_parse::ParagraphProperties,
1045) -> bool {
1046 let mut changed = target.bullet_font != source.bullet_font
1047 || target.bullet_color != source.bullet_color
1048 || target.bullet_size != source.bullet_size
1049 || target.line_spacing != source.line_spacing
1050 || target.margin_right != source.margin_right
1051 || target.space_before != source.space_before
1052 || target.space_after != source.space_after;
1053 target.line_spacing = source.line_spacing;
1054 target.margin_right = source.margin_right;
1055 target.space_before = source.space_before;
1056 target.space_after = source.space_after;
1057 if let (
1058 Some(pptx_parse::Bullet::AutoNumber {
1059 restart: target, ..
1060 }),
1061 Some(pptx_parse::Bullet::AutoNumber {
1062 restart: source, ..
1063 }),
1064 ) = (&mut target.bullet, &source.bullet)
1065 {
1066 changed |= *target != *source;
1067 *target = *source;
1068 }
1069 target.bullet_font.clone_from(&source.bullet_font);
1070 target.bullet_color.clone_from(&source.bullet_color);
1071 target.bullet_size.clone_from(&source.bullet_size);
1072 changed
1073}
1074
1075pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
1076 let txn = doc.transact();
1077 let meta = required_map(&txn, META)?;
1078 map_string(&meta, &txn, "fingerprint")
1079 .ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
1080}
1081
1082pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
1084 let version = {
1085 let txn = doc.transact();
1086 let meta = required_map(&txn, META)?;
1087 schema_version(&meta, &txn)?
1088 };
1089 if version < SCHEMA_VERSION {
1090 migrate_doc_to_v2_1(doc)?;
1091 }
1092 Ok(())
1093}
1094
1095fn migrate_doc_to_v2_1(doc: &Doc) -> EditResult<()> {
1102 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1103 let meta = required_map(&txn, META)?;
1104 let package = package_from_meta(&meta, &txn)?;
1105 let package_json =
1106 serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1107 meta.insert(
1108 &mut txn,
1109 "packageJson",
1110 Any::Buffer(Arc::from(package_json)),
1111 );
1112 backfill_hidden(&mut txn, &package)?;
1113 record_comment_flavor(&mut txn, &meta, &package);
1114 meta.insert(&mut txn, "baselinesPendingSource", true);
1115 meta.insert(&mut txn, "outlineGradientsPendingSource", true);
1116 backfill_blip_effects(&mut txn, &package)?;
1117 backfill_tables(&mut txn, &package)?;
1118 meta.insert(&mut txn, "spacingPendingSource", true);
1119 if package_needs_ole_source(&package) {
1120 meta.insert(&mut txn, "olePicturesPendingSource", true);
1121 }
1122 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1123 Ok(())
1124}
1125
1126fn record_comment_flavor(txn: &mut TransactionMut<'_>, meta: &MapRef, package: &PptxPackage) {
1127 if meta.contains_key(txn, "commentFlavor") {
1128 return;
1129 }
1130 if !package.comments.is_empty()
1131 || package
1132 .relationships
1133 .values()
1134 .flatten()
1135 .any(|relationship| {
1136 relationship.is_type(pptx_parse::relationship_types::COMMENTS)
1137 || relationship.is_type(pptx_parse::relationship_types::MODERN_COMMENTS)
1138 })
1139 {
1140 meta.insert(txn, "commentsPendingSource", true);
1141 }
1142 meta.insert(
1143 txn,
1144 "commentFlavor",
1145 flavor_key(package.comment_flavor.unwrap_or_default()),
1146 );
1147}
1148
1149fn backfill_blip_effects(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1150 let shapes = required_map(txn, SHAPES)?;
1151 for (index, (slide, reference)) in package
1152 .slides
1153 .iter()
1154 .zip(&package.presentation.slides)
1155 .enumerate()
1156 {
1157 let slide_id = seeded_slide_id(index, reference.id);
1158 for (index, node) in slide.shapes.iter().enumerate() {
1159 backfill_picture_effects(txn, &shapes, &slide_id, &index.to_string(), node)?;
1160 }
1161 }
1162 Ok(())
1163}
1164
1165fn backfill_picture_effects(
1166 txn: &mut TransactionMut<'_>,
1167 shapes: &MapRef,
1168 slide_id: &str,
1169 path: &str,
1170 node: &ShapeNode,
1171) -> EditResult<()> {
1172 match node {
1173 ShapeNode::Picture(picture) if !picture.effects.is_empty() => {
1174 let id = seeded_shape_id(slide_id, path);
1175 if let Some(shape) = shapes
1176 .get(txn, &id)
1177 .and_then(|shape| shape.cast::<MapRef>().ok())
1178 && !shape.contains_key(txn, "blipEffectsJson")
1179 {
1180 insert_json(&shape, txn, "blipEffectsJson", Some(&picture.effects))?;
1181 }
1182 }
1183 ShapeNode::Group(group) => {
1184 for (index, child) in group.children.iter().enumerate() {
1185 backfill_picture_effects(
1186 txn,
1187 shapes,
1188 slide_id,
1189 &seeded_child_path(path, index),
1190 child,
1191 )?;
1192 }
1193 }
1194 _ => {}
1195 }
1196 Ok(())
1197}
1198
1199fn package_needs_ole_source(package: &PptxPackage) -> bool {
1200 package
1201 .slides
1202 .iter()
1203 .any(|part| needs_ole_source(&part.shapes))
1204 || package
1205 .layouts
1206 .iter()
1207 .any(|part| needs_ole_source(&part.shapes))
1208 || package
1209 .masters
1210 .iter()
1211 .any(|part| needs_ole_source(&part.shapes))
1212}
1213
1214fn needs_ole_source(nodes: &[ShapeNode]) -> bool {
1215 nodes.iter().any(|node| match node {
1216 ShapeNode::GraphicFrame(frame) => matches!(&frame.data,
1217 pptx_parse::GraphicFrameData::Unknown { uri: Some(uri), picture: None }
1218 if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole"),
1219 ShapeNode::Group(group) => needs_ole_source(&group.children),
1220 _ => false,
1221 })
1222}
1223
1224pub(crate) fn import_source_ole_pictures(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
1225 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1226 let meta = required_map(&txn, META)?;
1227 if map_bool(&meta, &txn, "olePicturesPendingSource") != Some(true) {
1228 return Ok(());
1229 }
1230 let shapes = required_map(&txn, SHAPES)?;
1231 for (index, (slide, reference)) in source
1232 .slides
1233 .iter()
1234 .zip(&source.presentation.slides)
1235 .enumerate()
1236 {
1237 let slide_id = seeded_slide_id(index, reference.id);
1238 for (index, node) in slide.shapes.iter().enumerate() {
1239 backfill_ole_picture(&mut txn, &shapes, &slide_id, &index.to_string(), node)?;
1240 }
1241 }
1242 meta.remove(&mut txn, "olePicturesPendingSource");
1243 Ok(())
1244}
1245
1246fn backfill_ole_picture(
1247 txn: &mut TransactionMut<'_>,
1248 shapes: &MapRef,
1249 slide_id: &str,
1250 path: &str,
1251 node: &ShapeNode,
1252) -> EditResult<()> {
1253 if let ShapeNode::GraphicFrame(frame) = node
1254 && matches!(
1255 frame.data,
1256 pptx_parse::GraphicFrameData::Unknown {
1257 picture: Some(_),
1258 ..
1259 }
1260 )
1261 && let Some(shape) = shapes
1262 .get(txn, &seeded_shape_id(slide_id, path))
1263 .and_then(|value| value.cast::<MapRef>().ok())
1264 && matches!(
1265 optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
1266 Some(pptx_parse::GraphicFrameData::Unknown { picture: None, .. })
1267 )
1268 {
1269 insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
1270 }
1271 if let ShapeNode::Group(group) = node {
1272 for (index, child) in group.children.iter().enumerate() {
1273 backfill_ole_picture(
1274 txn,
1275 shapes,
1276 slide_id,
1277 &seeded_child_path(path, index),
1278 child,
1279 )?;
1280 }
1281 }
1282 Ok(())
1283}
1284
1285fn backfill_hidden(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1286 let shapes = required_map(txn, SHAPES)?;
1287 for shape_id in seeded_hidden_shape_ids(package) {
1288 if let Some(shape) = shapes
1289 .get(txn, &shape_id)
1290 .and_then(|value| value.cast::<MapRef>().ok())
1291 {
1292 shape.insert(txn, "hidden", true);
1293 }
1294 }
1295 Ok(())
1296}
1297
1298fn seeded_hidden_shape_ids(package: &PptxPackage) -> Vec<String> {
1299 let mut ids = Vec::new();
1300 for (slide_index, (slide, reference)) in package
1301 .slides
1302 .iter()
1303 .zip(&package.presentation.slides)
1304 .enumerate()
1305 {
1306 let slide_id = seeded_slide_id(slide_index, reference.id);
1307 for (shape_index, shape) in slide.shapes.iter().enumerate() {
1308 collect_hidden_shape_ids(&slide_id, &shape_index.to_string(), shape, &mut ids);
1309 }
1310 }
1311 ids
1312}
1313
1314fn collect_hidden_shape_ids(slide_id: &str, path: &str, shape: &ShapeNode, ids: &mut Vec<String>) {
1315 if shape_base(shape).hidden {
1316 ids.push(seeded_shape_id(slide_id, path));
1317 }
1318 if let ShapeNode::Group(group) = shape {
1319 for (child_index, child) in group.children.iter().enumerate() {
1320 collect_hidden_shape_ids(slide_id, &seeded_child_path(path, child_index), child, ids);
1321 }
1322 }
1323}
1324
1325fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
1326 map_number(meta, txn, "schemaVersion")
1327 .filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
1328 .ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
1329}
1330
1331fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
1332 schema_version(meta, txn).map(|_| ())
1333}
1334
1335fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
1336 let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
1337 return Err(EditError::InvalidState("missing package data".to_owned()));
1338 };
1339 serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
1340}
1341
1342pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
1343 let txn = doc.transact();
1344 let meta = required_map(&txn, META)?;
1345 let order = required_order(&txn)?;
1346 let slides = required_map(&txn, SLIDES)?;
1347 let shapes = required_map(&txn, SHAPES)?;
1348 let stories = required_map(&txn, STORIES)?;
1349 let mut seen_slides = HashSet::new();
1350 let mut slide_snapshots = Vec::new();
1351 for slide_id in string_array_ref(&order, &txn) {
1352 if !seen_slides.insert(slide_id.clone()) {
1353 continue;
1354 }
1355 let slide = slides
1356 .get(&txn, &slide_id)
1357 .and_then(|value| value.cast::<MapRef>().ok())
1358 .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
1359 let source_part_path = map_string(&slide, &txn, "sourcePartPath");
1360 let layout_part_path = map_string(&slide, &txn, "layoutPartPath");
1361 let theme = theme_for_layout(package, layout_part_path.as_deref());
1362 let shape_order = slide_shape_order(&slide, &txn)?;
1363 let mut seen_shapes = HashSet::new();
1364 let mut shape_snapshots = Vec::new();
1365 for shape_id in string_array_ref(&shape_order, &txn) {
1366 if seen_shapes.insert(shape_id.clone()) {
1367 shape_snapshots.push(snapshot_shape(
1368 &shapes,
1369 &stories,
1370 &txn,
1371 &shape_id,
1372 &mut HashSet::new(),
1373 theme,
1374 )?);
1375 }
1376 }
1377 let notes = map_string(&slide, &txn, "notes").unwrap_or_else(|| {
1378 package
1379 .slides
1380 .iter()
1381 .find(|source| Some(&source.part_path) == source_part_path.as_ref())
1382 .map(|source| source.notes.clone())
1383 .unwrap_or_default()
1384 });
1385 slide_snapshots.push(SlideSnapshot {
1386 id: slide_id,
1387 source_part_path,
1388 layout_part_path,
1389 name: map_string(&slide, &txn, "name"),
1390 notes,
1391 shapes: shape_snapshots,
1392 });
1393 }
1394 Ok(DeckSnapshot {
1395 width_emu: required_i64(&meta, &txn, "widthEmu")?,
1396 height_emu: required_i64(&meta, &txn, "heightEmu")?,
1397 slides: slide_snapshots,
1398 comment_flavor: snapshot_flavor(&txn)?,
1399 comments: snapshot_comments(&txn)?,
1400 })
1401}
1402
1403fn snapshot_shape<T: ReadTxn>(
1404 shapes: &MapRef,
1405 stories: &MapRef,
1406 txn: &T,
1407 shape_id: &str,
1408 visiting: &mut HashSet<String>,
1409 theme: Option<&Theme>,
1410) -> EditResult<ShapeSnapshot> {
1411 if visiting.len() >= MAX_SHAPE_DEPTH {
1412 return Err(EditError::InvalidState(format!(
1413 "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
1414 )));
1415 }
1416 if !visiting.insert(shape_id.to_owned()) {
1417 return Err(EditError::InvalidState(format!(
1418 "shape cycle at {shape_id}"
1419 )));
1420 }
1421 let shape = shapes
1422 .get(txn, shape_id)
1423 .and_then(|value| value.cast::<MapRef>().ok())
1424 .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
1425 let mut text_snapshots = Vec::new();
1426 for story_id in map_string_array(&shape, txn, "textStories")? {
1427 let story = stories
1428 .get(txn, &story_id)
1429 .and_then(|value| value.cast::<TextRef>().ok())
1430 .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
1431 text_snapshots.push(snapshot_story(&story, txn, &story_id)?);
1432 }
1433 let mut children = Vec::new();
1434 for child_id in map_string_array(&shape, txn, "children")? {
1435 children.push(snapshot_shape(
1436 shapes, stories, txn, &child_id, visiting, theme,
1437 )?);
1438 }
1439 visiting.remove(shape_id);
1440 let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
1441 let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
1442 let resolved_fill_color = fill
1443 .as_ref()
1444 .filter(|fill| fill.fill_type != "none")
1445 .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
1446 let resolved_outline_color = outline
1447 .as_ref()
1448 .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
1449 Ok(ShapeSnapshot {
1450 id: shape_id.to_owned(),
1451 source_id: required_u32(&shape, txn, "sourceId")?,
1452 kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
1453 name: required_string(&shape, txn, "name")?,
1454 x: required_i64(&shape, txn, "x")?,
1455 y: required_i64(&shape, txn, "y")?,
1456 width: required_i64(&shape, txn, "width")?,
1457 height: required_i64(&shape, txn, "height")?,
1458 rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
1459 flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
1460 flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
1461 hidden: map_bool(&shape, txn, "hidden").unwrap_or_default(),
1462 geometry: required_string(&shape, txn, "geometry")?,
1463 adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
1464 placeholder: optional_json(&shape, txn, "placeholderJson")?,
1465 fill,
1466 resolved_fill_color,
1467 outline,
1468 resolved_outline_color,
1469 media_part_path: map_string(&shape, txn, "mediaPartPath"),
1470 blip_effects: optional_json(&shape, txn, "blipEffectsJson")?.unwrap_or_default(),
1471 graphic: optional_json(&shape, txn, "graphicJson")?,
1472 text_stories: text_snapshots,
1473 children,
1474 })
1475}
1476
1477fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
1478 match value {
1479 "shape" => Ok(ShapeKind::Shape),
1480 "picture" => Ok(ShapeKind::Picture),
1481 "graphicFrame" => Ok(ShapeKind::GraphicFrame),
1482 "group" => Ok(ShapeKind::Group),
1483 _ => Err(EditError::InvalidState(format!(
1484 "unknown shape kind {value}"
1485 ))),
1486 }
1487}
1488
1489fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
1490 txn.get_array(SLIDE_ORDER)
1491 .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
1492}
1493
1494pub(crate) fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
1495 txn.get_map(name)
1496 .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
1497}
1498
1499pub(crate) fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
1500 required_map(txn, SLIDES)?
1501 .get(txn, slide_id)
1502 .and_then(|value| value.cast::<MapRef>().ok())
1503 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
1504}
1505
1506fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
1507 required_map(txn, SHAPES)?
1508 .get(txn, shape_id)
1509 .and_then(|value| value.cast::<MapRef>().ok())
1510 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
1511}
1512
1513fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
1514 if required_string(shape, txn, "kind")? == "shape" {
1515 Ok(())
1516 } else {
1517 Err(EditError::InvalidGeometry(
1518 "only preset shapes support shape styling".to_owned(),
1519 ))
1520 }
1521}
1522
1523fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
1524 slide
1525 .get(txn, "shapes")
1526 .and_then(|value| value.cast::<ArrayRef>().ok())
1527 .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
1528}
1529
1530fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
1531 let slide = slide_ref(txn, slide_id)?;
1532 let order = slide_shape_order(&slide, txn)?;
1533 if array_index(&order, txn, shape_id).is_some() {
1534 Ok(())
1535 } else {
1536 Err(EditError::ShapeNotFound(shape_id.to_owned()))
1537 }
1538}
1539
1540fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
1541 let shapes = required_map(txn, SHAPES)?;
1542 let stories = required_map(txn, STORIES)?;
1543 let mut entries = ShapeEntries::default();
1544 for shape_id in root_shape_ids {
1545 collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
1546 }
1547 for story_id in entries.story_ids {
1548 stories.remove(txn, &story_id);
1549 }
1550 for shape_id in entries.shape_ids {
1551 shapes.remove(txn, &shape_id);
1552 }
1553 Ok(())
1554}
1555
1556#[derive(Default)]
1557struct ShapeEntries {
1558 shape_ids: Vec<String>,
1559 seen_shape_ids: HashSet<String>,
1560 story_ids: Vec<String>,
1561 seen_story_ids: HashSet<String>,
1562}
1563
1564fn collect_shape_entries<T: ReadTxn>(
1565 shapes: &MapRef,
1566 txn: &T,
1567 shape_id: &str,
1568 visiting: &mut HashSet<String>,
1569 entries: &mut ShapeEntries,
1570) -> EditResult<()> {
1571 if visiting.len() >= MAX_SHAPE_DEPTH {
1572 return Err(EditError::InvalidState(format!(
1573 "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
1574 )));
1575 }
1576 if !visiting.insert(shape_id.to_owned()) {
1577 return Err(EditError::InvalidState(format!(
1578 "shape cycle at {shape_id}"
1579 )));
1580 }
1581 if entries.seen_shape_ids.contains(shape_id) {
1582 visiting.remove(shape_id);
1583 return Ok(());
1584 }
1585 let shape = shapes
1586 .get(txn, shape_id)
1587 .and_then(|value| value.cast::<MapRef>().ok())
1588 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
1589 for story_id in map_string_array(&shape, txn, "textStories")? {
1590 if entries.seen_story_ids.insert(story_id.clone()) {
1591 entries.story_ids.push(story_id);
1592 }
1593 }
1594 for child_id in map_string_array(&shape, txn, "children")? {
1595 collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
1596 }
1597 visiting.remove(shape_id);
1598 entries.seen_shape_ids.insert(shape_id.to_owned());
1599 entries.shape_ids.push(shape_id.to_owned());
1600 Ok(())
1601}
1602
1603fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
1604 array
1605 .iter(txn)
1606 .enumerate()
1607 .find(|(_, item)| out_string(item).as_deref() == Some(value))
1608 .map(|(index, _)| index as u32)
1609}
1610
1611fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
1612 array
1613 .iter(txn)
1614 .filter_map(|value| out_string(&value))
1615 .collect()
1616}
1617
1618fn map_string_array<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<Vec<String>> {
1619 match map.get(txn, key) {
1620 Some(Out::Any(Any::Array(values))) => Ok(values
1621 .iter()
1622 .filter_map(|value| match value {
1623 Any::String(value) => Some(value.to_string()),
1624 _ => None,
1625 })
1626 .collect()),
1627 None => Ok(Vec::new()),
1628 _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
1629 }
1630}
1631
1632fn string_array(values: &[String]) -> Any {
1633 Any::Array(Arc::from(
1634 values
1635 .iter()
1636 .map(|value| Any::from(value.as_str()))
1637 .collect::<Vec<_>>(),
1638 ))
1639}
1640
1641fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
1642 Ok(ShapeRect {
1643 x: required_i64(shape, txn, "x")?,
1644 y: required_i64(shape, txn, "y")?,
1645 width: required_i64(shape, txn, "width")?,
1646 height: required_i64(shape, txn, "height")?,
1647 })
1648}
1649
1650fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
1651 let value = required_i64(map, txn, key)?;
1652 u32::try_from(value).map_err(|_| {
1653 EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
1654 })
1655}
1656
1657fn validate_rect(rect: ShapeRect) -> EditResult<()> {
1658 validate_coordinate(rect.x)?;
1659 validate_coordinate(rect.y)?;
1660 if rect.width <= 0 || rect.height <= 0 {
1661 return Err(EditError::InvalidGeometry(
1662 "shape width and height must be positive".to_owned(),
1663 ));
1664 }
1665 validate_coordinate(rect.width)?;
1666 validate_coordinate(rect.height)
1667}
1668
1669fn validate_coordinate(value: i64) -> EditResult<()> {
1670 if value.unsigned_abs() > MAX_GEOMETRY as u64 {
1671 return Err(EditError::InvalidGeometry(format!(
1672 "coordinate {value} exceeds the safe range"
1673 )));
1674 }
1675 Ok(())
1676}
1677
1678fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
1679 if adjustments.len() > MAX_ADJUSTMENTS {
1680 return Err(EditError::InvalidAdjustment(format!(
1681 "at most {MAX_ADJUSTMENTS} values are allowed"
1682 )));
1683 }
1684 for (name, value) in adjustments {
1685 if !valid_adjustment_name(name) {
1686 return Err(EditError::InvalidAdjustment(format!(
1687 "unrecognized guide name {name:?}"
1688 )));
1689 }
1690 if !value.is_finite() {
1691 return Err(EditError::InvalidAdjustment(format!(
1692 "guide {name:?} must be finite"
1693 )));
1694 }
1695 }
1696 Ok(())
1697}
1698
1699fn valid_adjustment_name(name: &str) -> bool {
1700 if name == "adj" {
1701 return true;
1702 }
1703 let Some(index) = name.strip_prefix("adj") else {
1704 return false;
1705 };
1706 index
1707 .parse::<usize>()
1708 .ok()
1709 .filter(|value| value.to_string() == index)
1710 .is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
1711}
1712
1713fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
1714 Ok(match color {
1715 Some(color) => ShapeFill {
1716 fill_type: "solid".to_owned(),
1717 color: Some(color_value(color)?),
1718 gradient: None,
1719 },
1720 None => ShapeFill::named("none"),
1721 })
1722}
1723
1724fn fill_color(fill: &ShapeFill) -> Option<String> {
1725 (fill.fill_type != "none")
1726 .then(|| resolve_color_value_to_hex(fill.color.as_ref()))
1727 .flatten()
1728}
1729
1730fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
1731 Some(ShapeStroke {
1732 color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
1733 width_pt: outline.width.map(|width| width / EMU_PER_POINT),
1734 })
1735}
1736
1737fn color_value(color: &str) -> EditResult<ColorValue> {
1738 let rgb = color.strip_prefix('#').unwrap_or(color);
1739 if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1740 return Err(EditError::InvalidGeometry(format!(
1741 "color {color:?} must be a six-digit hex value"
1742 )));
1743 }
1744 Ok(ColorValue {
1745 rgb: Some(rgb.to_ascii_uppercase()),
1746 ..ColorValue::default()
1747 })
1748}
1749
1750fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
1751 map_string(map, txn, key)
1752 .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
1753}
1754
1755pub(crate) fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
1756 map.get(txn, key).and_then(|value| out_string(&value))
1757}
1758
1759fn out_string(value: &Out) -> Option<String> {
1760 match value {
1761 Out::Any(Any::String(value)) => Some(value.to_string()),
1762 _ => None,
1763 }
1764}
1765
1766fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
1767 let number = map_number(map, txn, key)
1768 .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
1769 if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
1770 return Err(EditError::InvalidState(format!("invalid integer {key}")));
1771 }
1772 Ok(number as i64)
1773}
1774
1775pub(crate) fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
1776 match map.get(txn, key) {
1777 Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
1778 Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
1779 _ => None,
1780 }
1781}
1782
1783pub(crate) fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
1784 match map.get(txn, key) {
1785 Some(Out::Any(Any::Bool(value))) => Some(value),
1786 _ => None,
1787 }
1788}
1789
1790fn optional_json<T: DeserializeOwned, R: ReadTxn>(
1791 map: &MapRef,
1792 txn: &R,
1793 key: &str,
1794) -> EditResult<Option<T>> {
1795 map_string(map, txn, key)
1796 .map(|json| {
1797 serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
1798 })
1799 .transpose()
1800}
1801
1802#[cfg(test)]
1803mod tests {
1804 use super::*;
1805 use crate::TextStyle;
1806
1807 const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
1808 const HIDDEN_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/hidden-shapes.pptx");
1809
1810 #[test]
1811 fn a_reattached_source_restores_the_series_lines_a_stored_package_lacks() {
1812 let source = ChartSpace {
1813 plot_groups: vec![pptx_parse::ChartPlotGroup {
1814 series: vec![pptx_parse::ChartSeries {
1815 line: Some(ooxml_drawingml::chart::ChartLine {
1816 none: false,
1817 color: Some("#FFFFFF".to_owned()),
1818 width_emu: Some(41275.0),
1819 }),
1820 ..pptx_parse::ChartSeries::default()
1821 }],
1822 ..pptx_parse::ChartPlotGroup::default()
1823 }],
1824 ..ChartSpace::default()
1825 };
1826 let mut target = source.clone();
1827 target.plot_groups[0].series[0].line = None;
1828
1829 assert!(merge_source_chart_properties(&mut target, &source));
1830 assert_eq!(target, source);
1831 assert!(!merge_source_chart_properties(&mut target, &source));
1832 }
1833
1834 #[test]
1835 fn a_reattached_source_restores_the_right_margin_a_stored_package_lacks() {
1836 let source = pptx_parse::ParagraphProperties {
1837 margin_right: Some(914_400),
1838 ..pptx_parse::ParagraphProperties::default()
1839 };
1840 let mut target = pptx_parse::ParagraphProperties::default();
1841
1842 assert!(merge_source_paragraph_properties(&mut target, &source));
1843 assert_eq!(target.margin_right, Some(914_400));
1844 assert!(!merge_source_paragraph_properties(&mut target, &source));
1845 }
1846
1847 #[test]
1848 fn a_reattached_source_restores_the_paragraph_spacing_a_stored_package_lacks() {
1849 let source = pptx_parse::ParagraphProperties {
1850 space_before: Some(pptx_parse::LineSpacing::Points { value: 10.0 }),
1851 space_after: Some(pptx_parse::LineSpacing::Percent { value: 0.2 }),
1852 ..pptx_parse::ParagraphProperties::default()
1853 };
1854 let mut target = pptx_parse::ParagraphProperties::default();
1855
1856 assert!(merge_source_paragraph_properties(&mut target, &source));
1857 assert_eq!(target.space_before, source.space_before);
1858 assert_eq!(target.space_after, source.space_after);
1859 assert!(!merge_source_paragraph_properties(&mut target, &source));
1860 }
1861
1862 #[test]
1863 fn attaching_a_source_refreshes_chart_text_a_stored_package_never_carried() {
1864 use ooxml_drawingml::chart::{ChartLegend, ChartTextProperties};
1865
1866 let styled = ChartTextProperties {
1867 spacing_pt: Some(3.0),
1868 ..ChartTextProperties::default()
1869 };
1870 let source = ChartSpace {
1871 text: Some(styled.clone()),
1872 title_text: Some(styled.clone()),
1873 legend: Some(ChartLegend {
1874 visible: true,
1875 text: Some(styled.clone()),
1876 ..ChartLegend::default()
1877 }),
1878 axis_list: Some(vec![ChartAxis {
1879 text: Some(styled.clone()),
1880 ..ChartAxis::default()
1881 }]),
1882 ..ChartSpace::default()
1883 };
1884 let mut target = ChartSpace {
1885 legend: Some(ChartLegend {
1886 visible: true,
1887 ..ChartLegend::default()
1888 }),
1889 axis_list: Some(vec![ChartAxis::default()]),
1890 ..ChartSpace::default()
1891 };
1892 assert!(merge_source_chart_properties(&mut target, &source));
1893 assert_eq!(target.text.as_ref(), Some(&styled));
1894 assert_eq!(target.title_text.as_ref(), Some(&styled));
1895 assert_eq!(target.legend.unwrap().text.as_ref(), Some(&styled));
1896 assert_eq!(target.axis_list.unwrap()[0].text.as_ref(), Some(&styled));
1897 assert!(!merge_source_chart_properties(&mut source.clone(), &source));
1898 }
1899
1900 #[test]
1901 fn an_unmigratable_schema_version_is_reported_before_package_json() {
1902 let session = DeckSession::open(FIXTURE, 100).unwrap();
1903 {
1904 let mut txn = session.doc.transact_mut();
1905 let meta = required_map(&txn, META).unwrap();
1906 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
1907 meta.insert(
1908 &mut txn,
1909 "packageJson",
1910 Any::Buffer(Arc::from(b"{}".to_vec())),
1911 );
1912 }
1913
1914 assert!(matches!(
1915 validate_doc(&session.doc),
1916 Err(EditError::InvalidState(message))
1917 if message == "unsupported deck schema version"
1918 ));
1919 assert!(matches!(
1920 DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
1921 Err(EditError::InvalidState(message))
1922 if message == "unsupported deck schema version"
1923 ));
1924 }
1925
1926 #[test]
1927 fn migration_leaves_a_current_document_untouched() {
1928 let session = DeckSession::open(FIXTURE, 102).unwrap();
1929 let before = session.encode_state_as_update_v1();
1930 migrate_doc(&session.doc).unwrap();
1931 assert_eq!(session.encode_state_as_update_v1(), before);
1932 }
1933
1934 #[test]
1935 fn legacy_migrations_commit_once_and_normalise_the_package() {
1936 use std::sync::Mutex;
1937
1938 const V1: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v1.update.bin");
1939 const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-connectors.update.bin");
1940 for update in [V1, V2] {
1941 let doc = crate::doc_with_client_id(920);
1942 crate::hydrate_doc(&doc, update).unwrap();
1943 let before = snapshot_doc(&doc, &package_from_doc(&doc).unwrap()).unwrap();
1944 let observed = Arc::new(Mutex::new(Vec::new()));
1945 let events = observed.clone();
1946 let _subscription = doc
1947 .observe_update_v1(move |txn, _| {
1948 let meta = required_map(txn, META).unwrap();
1949 let Some(Out::Any(Any::Buffer(package))) = meta.get(txn, "packageJson") else {
1950 panic!("missing package data");
1951 };
1952 events
1953 .lock()
1954 .unwrap()
1955 .push((map_number(&meta, txn, "schemaVersion").unwrap(), package));
1956 })
1957 .unwrap();
1958
1959 migrate_doc(&doc).unwrap();
1960
1961 let events = observed.lock().unwrap();
1962 assert_eq!(
1963 events
1964 .iter()
1965 .map(|(version, _)| *version)
1966 .collect::<Vec<_>>(),
1967 [SCHEMA_VERSION]
1968 );
1969 for (_, package) in events.iter() {
1970 let json: serde_json::Value = serde_json::from_slice(package).unwrap();
1971 assert!(json.get("charts").unwrap().is_array());
1972 assert!(json.get("shapeElements").is_none());
1973 assert!(json["presentation"].get("firstSlideNum").is_none());
1974 }
1975 let package = package_from_doc(&doc).unwrap();
1976 assert_eq!(snapshot_doc(&doc, &package).unwrap(), before);
1977 assert!(!package.models_connectors());
1978 assert_eq!(package.presentation.first_slide_num, 1);
1979 }
1980 }
1981
1982 #[test]
1983 fn a_v2_hidden_snapshot_backfills_flags_without_rewriting_the_package() {
1984 use std::sync::Mutex;
1985 use yrs::Update;
1986 use yrs::updates::decoder::Decode;
1987
1988 const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-hidden.update.bin");
1989 let doc = crate::doc_with_client_id(9430);
1990 doc.transact_mut()
1991 .apply_update(Update::decode_v1(V2).unwrap())
1992 .unwrap();
1993 let before = package_from_doc(&doc).unwrap();
1994 let observed = Arc::new(Mutex::new(Vec::new()));
1995 let events = observed.clone();
1996 let _subscription = doc
1997 .observe_update_v1(move |txn, _| {
1998 let meta = required_map(txn, META).unwrap();
1999 let shapes = required_map(txn, SHAPES).unwrap();
2000 let mut hidden: Vec<String> = shapes
2001 .iter(txn)
2002 .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2003 .filter(|(_, shape)| map_bool(shape, txn, "hidden").unwrap_or(false))
2004 .map(|(id, _)| id.to_owned())
2005 .collect();
2006 hidden.sort();
2007 events.lock().unwrap().push((
2008 map_number(&meta, txn, "schemaVersion").unwrap(),
2009 package_from_meta(&meta, txn).unwrap(),
2010 hidden,
2011 ));
2012 })
2013 .unwrap();
2014 migrate_doc(&doc).unwrap();
2015 let events = observed.lock().unwrap();
2016 assert_eq!(
2017 events
2018 .iter()
2019 .map(|(version, _, _)| *version)
2020 .collect::<Vec<_>>(),
2021 [SCHEMA_VERSION]
2022 );
2023 for (_, package, hidden) in events.iter() {
2024 assert_eq!(
2025 serde_json::to_vec(package).unwrap(),
2026 serde_json::to_vec(&before).unwrap()
2027 );
2028 assert_eq!(
2029 hidden,
2030 &[
2031 "slide:0:256:shape:0",
2032 "slide:0:256:shape:8",
2033 "slide:0:256:shape:8.13",
2034 "slide:1:257:shape:16",
2035 ]
2036 );
2037 }
2038 }
2039
2040 #[test]
2041 fn seeding_stores_hidden_only_for_hidden_shapes() {
2042 assert!(hidden_keys(&DeckSession::open(FIXTURE, 103).unwrap()).is_empty());
2043
2044 let session = DeckSession::open(HIDDEN_FIXTURE, 104).unwrap();
2045 assert_eq!(
2046 hidden_keys(&session),
2047 [
2048 "slide:0:256:shape:0",
2049 "slide:0:256:shape:8",
2050 "slide:0:256:shape:8.13",
2051 "slide:1:257:shape:16",
2052 "slide:1:257:shape:4",
2053 ]
2054 );
2055 let snapshot = session.snapshot().unwrap();
2056 let group = &snapshot.slides[0].shapes[8];
2057 assert!(snapshot.slides[0].shapes[0].hidden);
2058 assert!(group.hidden);
2059 assert_eq!(group.children.len(), 14);
2060 assert!(group.children[..13].iter().all(|child| !child.hidden));
2061 assert!(group.children[13].hidden);
2062 }
2063
2064 #[test]
2065 fn hidden_flags_serialise_sparsely_and_round_trip() {
2066 let snapshot = DeckSession::open(HIDDEN_FIXTURE, 106)
2067 .unwrap()
2068 .snapshot()
2069 .unwrap();
2070 let json = serde_json::to_string(&snapshot).unwrap();
2071 assert_eq!(json.matches("\"hidden\":true").count(), 5);
2072 assert!(!json.contains("\"hidden\":false"));
2073 assert_eq!(
2074 serde_json::from_str::<DeckSnapshot>(&json).unwrap(),
2075 snapshot
2076 );
2077 }
2078
2079 fn hidden_keys(session: &DeckSession) -> Vec<String> {
2080 let txn = session.doc.transact();
2081 let shapes = required_map(&txn, SHAPES).unwrap();
2082 let mut ids: Vec<String> = shapes
2083 .iter(&txn)
2084 .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2085 .filter(|(_, shape)| shape.get(&txn, "hidden").is_some())
2086 .map(|(id, _)| id.to_owned())
2087 .collect();
2088 ids.sort();
2089 ids
2090 }
2091
2092 #[test]
2093 fn delete_operations_remove_owned_map_entries() {
2094 let session = DeckSession::open(FIXTURE, 101).unwrap();
2095 let context = EditCtx::local("test");
2096 let baseline = map_lengths(&session);
2097
2098 for cycle in 0..3 {
2099 let index = session.snapshot().unwrap().slides.len() as u32;
2100 let slide = session.insert_slide(&context, index, None).unwrap();
2101 let first = session
2102 .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
2103 .unwrap();
2104 session
2105 .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
2106 .unwrap();
2107 assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
2108
2109 session
2110 .remove_shape(&context, &slide.slide_id, &first.shape_id)
2111 .unwrap();
2112 assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
2113
2114 session.delete_slide(&context, &slide.slide_id).unwrap();
2115 assert_eq!(map_lengths(&session), baseline);
2116 }
2117 }
2118
2119 #[test]
2120 fn deleting_seeded_slide_cascades_descendant_entries() {
2121 let session = DeckSession::open(FIXTURE, 202).unwrap();
2122 let slide = session.snapshot().unwrap().slides[0].clone();
2123 let before = map_lengths(&session);
2124 let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
2125 let next = shape_entry_lengths(shape);
2126 (total.0 + next.0, total.1 + next.1)
2127 });
2128
2129 session
2130 .delete_slide(&EditCtx::local("test"), &slide.id)
2131 .unwrap();
2132
2133 assert!(owned.0 > slide.shapes.len() as u32);
2134 assert_eq!(
2135 map_lengths(&session),
2136 (before.0 - 1, before.1 - owned.0, before.2 - owned.1)
2137 );
2138 }
2139
2140 fn draft(cycle: u32, index: u32) -> ShapeDraft {
2141 ShapeDraft {
2142 name: format!("Shape {cycle}:{index}"),
2143 rect: ShapeRect {
2144 x: 100_000,
2145 y: 100_000,
2146 width: 1_000_000,
2147 height: 500_000,
2148 },
2149 text: "Text".to_owned(),
2150 style: TextStyle::default(),
2151 }
2152 }
2153
2154 fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
2155 let txn = session.doc.transact();
2156 (
2157 required_map(&txn, SLIDES).unwrap().len(&txn),
2158 required_map(&txn, SHAPES).unwrap().len(&txn),
2159 required_map(&txn, STORIES).unwrap().len(&txn),
2160 )
2161 }
2162
2163 fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
2164 shape.children.iter().fold(
2165 (1, shape.text_stories.len() as u32),
2166 |(shape_count, story_count), child| {
2167 let child_counts = shape_entry_lengths(child);
2168 (shape_count + child_counts.0, story_count + child_counts.1)
2169 },
2170 )
2171 }
2172
2173 fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
2174 (left.0 + right.0, left.1 + right.1, left.2 + right.2)
2175 }
2176
2177 #[test]
2178 fn a_v2_comment_snapshot_records_its_flavour_while_deferring_the_import() {
2179 use std::sync::Mutex;
2180 use yrs::Update;
2181 use yrs::updates::decoder::Decode;
2182
2183 const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-comments.update.bin");
2184 let doc = crate::doc_with_client_id(9620);
2185 doc.transact_mut()
2186 .apply_update(Update::decode_v1(V2).unwrap())
2187 .unwrap();
2188 let before = {
2189 let txn = doc.transact();
2190 let meta = required_map(&txn, META).unwrap();
2191 assert_eq!(map_number(&meta, &txn, "schemaVersion"), Some(2.0));
2192 assert!(meta.get(&txn, "commentFlavor").is_none());
2193 package_from_meta(&meta, &txn).unwrap()
2194 };
2195 let observed = Arc::new(Mutex::new(Vec::new()));
2196 let events = observed.clone();
2197 let _subscription = doc
2198 .observe_update_v1(move |txn, _| {
2199 let meta = required_map(txn, META).unwrap();
2200 events.lock().unwrap().push((
2201 map_number(&meta, txn, "schemaVersion").unwrap(),
2202 package_from_meta(&meta, txn).unwrap(),
2203 map_string(&meta, txn, "commentFlavor"),
2204 map_bool(&meta, txn, "commentsPendingSource"),
2205 ));
2206 })
2207 .unwrap();
2208 migrate_doc(&doc).unwrap();
2209 let events = observed.lock().unwrap();
2210 assert_eq!(
2211 *events,
2212 [(
2213 SCHEMA_VERSION,
2214 before,
2215 Some("legacy".to_owned()),
2216 Some(true)
2217 )]
2218 );
2219 }
2220}