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