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