1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::Arc;
3
4use base64::Engine as _;
5use ooxml_drawingml::{
6 ColorMap, ColorValue, ShapeFill, ShapeOutline, Theme, preset_geometry_default_adjustments,
7 preset_geometry_to_path, resolve_color_value_to_hex, resolve_color_value_to_hex_with_theme,
8};
9use pptx_parse::{
10 ChartAxis, ChartSpace, GraphicFrameData, Placeholder, PptxPackage, ShapeBase, ShapeNode, Slide,
11};
12use serde::de::DeserializeOwned;
13use yrs::updates::decoder::Decode;
14use yrs::{
15 Any, Array, ArrayPrelim, ArrayRef, ClientID, Doc, Map, MapPrelim, MapRef, Out, ReadTxn,
16 StateVector, TextRef, Transact, TransactionMut, Update, WriteTxn,
17};
18
19use crate::comments::{
20 baseline_comments, flavor_key, seed_comments, snapshot_comments, snapshot_flavor,
21};
22use crate::inherit::{SlideContext, inherited_transform, layout_transform, record_inherited};
23use crate::story::{baseline_story, seed_plain_story, seed_story, snapshot_story, validate_story};
24use crate::{
25 DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN, PendingMedia,
26 PictureDraft, PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt,
27 ShapeDraft, ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
28 ShapeStrokeReceipt, ShapeZOrderReceipt, SlideReceipt, SlideScope, SlideSnapshot,
29 TransformReceipt,
30};
31
32const SCHEMA_VERSION: f64 = 2.2;
33const MIGRATABLE_SCHEMA_VERSIONS: [f64; 4] = [1.0, 2.0, 2.1, SCHEMA_VERSION];
35const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
36const MAX_SHAPE_DEPTH: usize = 128;
37const EMU_PER_POINT: f64 = 12_700.0;
38const MAX_ADJUSTMENTS: usize = 32;
39const MAX_ADJUSTMENT_INDEX: usize = 32;
40const MAX_PENDING_PICTURE_BYTES: usize = 8 * 1024 * 1024;
42
43pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
44 let package_json =
45 serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
46 let mut txn = doc.transact_mut_with("pptx:bootstrap");
47 let meta = txn.get_or_insert_map(META);
48 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
49 meta.insert(&mut txn, "fingerprint", fingerprint);
50 meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
51 meta.insert(
52 &mut txn,
53 "heightEmu",
54 package.presentation.height_emu as f64,
55 );
56 meta.insert(
57 &mut txn,
58 "packageJson",
59 Any::Buffer(Arc::from(package_json)),
60 );
61 meta.insert(
62 &mut txn,
63 "commentFlavor",
64 flavor_key(package.comment_flavor.unwrap_or_default()),
65 );
66 let order = txn.get_or_insert_array(SLIDE_ORDER);
67 let slides = txn.get_or_insert_map(SLIDES);
68 let shapes = txn.get_or_insert_map(SHAPES);
69 let stories = txn.get_or_insert_map(STORIES);
70 let mut slide_id_by_part: HashMap<String, String> = HashMap::new();
71
72 for (slide_index, slide) in package.slides.iter().enumerate() {
73 let theme = pptx_parse::slide_theme(package, Some(&slide.part_path), None);
74 let slide_id = seeded_slide_id(slide_index, package.presentation.slides[slide_index].id);
75 order.push_back(&mut txn, slide_id.as_str());
76 let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
77 slide_map.insert(&mut txn, "id", slide_id.as_str());
78 slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
79 if let Some(layout) = &slide.layout_part_path {
80 slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
81 }
82 if let Some(name) = &slide.name {
83 slide_map.insert(&mut txn, "name", name.as_str());
84 }
85 if !slide.notes.is_empty() {
86 slide_map.insert(&mut txn, "notes", slide.notes.as_str());
87 }
88 let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
89 for (shape_index, shape) in slide.shapes.iter().enumerate() {
90 let shape_id = seed_shape(
91 &shapes,
92 &stories,
93 &mut txn,
94 &slide_id,
95 &shape_index.to_string(),
96 shape,
97 Some(&theme),
98 )?;
99 shape_order.push_back(&mut txn, shape_id.as_str());
100 }
101 slide_id_by_part.insert(slide.part_path.clone(), slide_id);
102 }
103 seed_comments(&mut txn, package, &|part| {
104 slide_id_by_part.get(part).cloned()
105 })?;
106 Ok(())
107}
108
109fn seed_shape(
110 shapes: &MapRef,
111 stories: &MapRef,
112 txn: &mut TransactionMut<'_>,
113 slide_id: &str,
114 path: &str,
115 shape: &ShapeNode,
116 theme: Option<&Theme>,
117) -> EditResult<String> {
118 let shape_id = seeded_shape_id(slide_id, path);
119 let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
120 let base = shape_base(shape);
121 shape_map.insert(txn, "id", shape_id.as_str());
122 shape_map.insert(txn, "sourceId", base.id as f64);
123 shape_map.insert(txn, "name", base.name.as_str());
124 shape_map.insert(txn, "x", base.transform.x as f64);
125 shape_map.insert(txn, "y", base.transform.y as f64);
126 shape_map.insert(txn, "width", base.transform.width as f64);
127 shape_map.insert(txn, "height", base.transform.height as f64);
128 shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
129 shape_map.insert(txn, "flipH", base.transform.flip_h);
130 shape_map.insert(txn, "flipV", base.transform.flip_v);
131 if base.hidden {
132 shape_map.insert(txn, "hidden", true);
133 }
134 insert_json(
135 &shape_map,
136 txn,
137 "placeholderJson",
138 base.placeholder.as_ref(),
139 )?;
140
141 let mut text_story_ids = Vec::new();
142 let mut child_ids = Vec::new();
143 match shape {
144 ShapeNode::Shape(shape) => {
145 shape_map.insert(txn, "kind", "shape");
146 shape_map.insert(txn, "geometry", shape.geometry.as_str());
147 if !shape.has_preset_geometry {
148 shape_map.insert(txn, "hasPresetGeometry", false);
149 }
150 let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
151 .into_iter()
152 .collect::<BTreeMap<_, _>>();
153 adjust_values.extend(shape.adjust_values.clone());
154 insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
155 insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
156 insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
157 if let Some(body) = &shape.text {
158 let story_id = format!("story:{shape_id}:0");
159 seed_story(stories, txn, &story_id, body, theme)?;
160 text_story_ids.push(story_id);
161 }
162 }
163 ShapeNode::Picture(picture) => {
164 shape_map.insert(txn, "kind", "picture");
165 shape_map.insert(txn, "geometry", "rect");
166 insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
167 insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
168 if !picture.effects.is_empty() {
169 insert_json(&shape_map, txn, "blipEffectsJson", Some(&picture.effects))?;
170 }
171 if let Some(media) = &picture.media_part_path {
172 shape_map.insert(txn, "mediaPartPath", media.as_str());
173 }
174 }
175 ShapeNode::GraphicFrame(frame) => {
176 shape_map.insert(txn, "kind", "graphicFrame");
177 shape_map.insert(txn, "geometry", "rect");
178 insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
179 if let pptx_parse::GraphicFrameData::Table(table) = &frame.data {
180 for (row_index, row) in table.rows.iter().enumerate() {
181 for (cell_index, cell) in row.cells.iter().enumerate() {
182 let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
183 seed_story(stories, txn, &story_id, &cell.text, theme)?;
184 text_story_ids.push(story_id);
185 }
186 }
187 }
188 }
189 ShapeNode::Group(group) => {
190 shape_map.insert(txn, "kind", "group");
191 shape_map.insert(txn, "geometry", "group");
192 for (child_index, child) in group.children.iter().enumerate() {
193 child_ids.push(seed_shape(
194 shapes,
195 stories,
196 txn,
197 slide_id,
198 &seeded_child_path(path, child_index),
199 child,
200 theme,
201 )?);
202 }
203 }
204 }
205 shape_map.insert(txn, "textStories", string_array(&text_story_ids));
206 shape_map.insert(txn, "children", string_array(&child_ids));
207 Ok(shape_id)
208}
209
210fn seeded_slide_id(slide_index: usize, reference_id: u32) -> String {
211 format!("slide:{slide_index}:{reference_id}")
212}
213
214fn seeded_shape_id(slide_id: &str, path: &str) -> String {
215 format!("{slide_id}:shape:{path}")
216}
217
218fn seeded_child_path(path: &str, child_index: usize) -> String {
219 format!("{path}.{child_index}")
220}
221
222pub(crate) fn shape_base(shape: &ShapeNode) -> &ShapeBase {
223 match shape {
224 ShapeNode::Shape(shape) => &shape.base,
225 ShapeNode::Picture(shape) => &shape.base,
226 ShapeNode::GraphicFrame(shape) => &shape.base,
227 ShapeNode::Group(shape) => &shape.base,
228 }
229}
230
231fn insert_json<T: serde::Serialize>(
232 map: &MapRef,
233 txn: &mut TransactionMut<'_>,
234 key: &str,
235 value: Option<&T>,
236) -> EditResult<()> {
237 if let Some(value) = value {
238 let json =
239 serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
240 map.insert(txn, key, json);
241 }
242 Ok(())
243}
244
245impl DeckSession {
246 pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
247 snapshot_doc(&self.doc, &self.package)
248 }
249
250 pub fn slide_ids(&self) -> EditResult<Vec<String>> {
252 let txn = self.doc.transact();
253 let order = required_order(&txn)?;
254 let slides = required_map(&txn, SLIDES)?;
255 let mut seen_slides = HashSet::new();
256 let mut ids = Vec::new();
257 for slide_id in string_array_ref(&order, &txn) {
258 if !seen_slides.insert(slide_id.clone()) {
259 continue;
260 }
261 if slides
262 .get(&txn, &slide_id)
263 .and_then(|value| value.cast::<MapRef>().ok())
264 .is_none()
265 {
266 return Err(EditError::InvalidState(format!("missing slide {slide_id}")));
267 }
268 ids.push(slide_id);
269 }
270 Ok(ids)
271 }
272
273 pub fn slide_scope(&self, slide_index: usize) -> EditResult<SlideScope> {
275 slide_scope(&self.doc, &self.package, slide_index)
276 }
277
278 pub fn insert_slide(
279 &self,
280 context: &EditCtx,
281 index: u32,
282 layout_part_path: Option<&str>,
283 ) -> EditResult<SlideReceipt> {
284 if let Some(path) = layout_part_path
285 && !self
286 .package
287 .layouts
288 .iter()
289 .any(|layout| layout.part_path == path)
290 {
291 return Err(EditError::InvalidState(format!(
292 "unknown slide layout {path:?}"
293 )));
294 }
295 let slide_id = self.next_id("slide");
296 let mut txn = self.transact_for(context);
297 let order = required_order(&txn)?;
298 let length = order.len(&txn);
299 if index > length {
300 return Err(EditError::OutOfBounds { index, length });
301 }
302 let slides = required_map(&txn, SLIDES)?;
303 let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
304 slide.insert(&mut txn, "id", slide_id.as_str());
305 slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
306 if let Some(layout_part_path) = layout_part_path {
307 slide.insert(&mut txn, "layoutPartPath", layout_part_path);
308 }
309 slide.insert(&mut txn, "shapes", ArrayPrelim::default());
310 order.insert(&mut txn, index, slide_id.as_str());
311 Ok(SlideReceipt {
312 slide_id,
313 from_index: None,
314 to_index: Some(index),
315 })
316 }
317
318 pub fn set_slide_notes(&self, context: &EditCtx, slide_id: &str, text: &str) -> EditResult<()> {
319 crate::model::validate_xml_text(text)?;
320 self.automatic_undo_barrier();
321 let mut txn = self.transact_for(context);
322 let slide = slide_ref(&txn, slide_id)?;
323 slide.insert(&mut txn, "notes", text);
324 drop(txn);
325 self.automatic_undo_barrier();
326 Ok(())
327 }
328
329 pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
330 let mut txn = self.transact_for(context);
331 let order = required_order(&txn)?;
332 let index = array_index(&order, &txn, slide_id)
333 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
334 let slides = required_map(&txn, SLIDES)?;
335 let slide = slide_ref(&txn, slide_id)?;
336 let shape_order = slide_shape_order(&slide, &txn)?;
337 let shape_ids = live_shape_order(&shape_order, &txn)?;
338 remove_shape_entries(&mut txn, &shape_ids)?;
339 let comments = required_map(&txn, crate::COMMENTS)?;
340 let comment_ids: Vec<String> = comments
341 .iter(&txn)
342 .filter_map(|(id, value)| {
343 let entry = value.cast::<MapRef>().ok()?;
344 (map_string(&entry, &txn, "slideId").as_deref() == Some(slide_id))
345 .then(|| id.to_owned())
346 })
347 .collect();
348 for id in comment_ids {
349 comments.remove(&mut txn, &id);
350 }
351 order.remove(&mut txn, index);
352 slides.remove(&mut txn, slide_id);
353 Ok(SlideReceipt {
354 slide_id: slide_id.to_owned(),
355 from_index: Some(index),
356 to_index: None,
357 })
358 }
359
360 pub fn move_slide(
361 &self,
362 context: &EditCtx,
363 slide_id: &str,
364 to_index: u32,
365 ) -> EditResult<SlideReceipt> {
366 let mut txn = self.transact_for(context);
367 let order = required_order(&txn)?;
368 let length = order.len(&txn);
369 if to_index >= length {
370 return Err(EditError::OutOfBounds {
371 index: to_index,
372 length,
373 });
374 }
375 let from_index = array_index(&order, &txn, slide_id)
376 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
377 if from_index != to_index {
378 order.remove(&mut txn, from_index);
379 order.insert(&mut txn, to_index, slide_id);
380 }
381 Ok(SlideReceipt {
382 slide_id: slide_id.to_owned(),
383 from_index: Some(from_index),
384 to_index: Some(to_index),
385 })
386 }
387
388 pub fn add_text_box(
389 &self,
390 context: &EditCtx,
391 slide_id: &str,
392 draft: &ShapeDraft,
393 ) -> EditResult<ShapeReceipt> {
394 validate_rect(draft.rect)?;
395 crate::model::validate_xml_text(&draft.name)?;
396 crate::model::validate_xml_text(&draft.text)?;
397 crate::story::validate_style_values(
398 draft.style.font_family.as_deref(),
399 draft.style.underline.as_deref(),
400 draft.style.color.as_deref(),
401 draft.style.font_size_pt,
402 draft.style.spacing_pt,
403 draft.style.baseline_pct,
404 )?;
405 let shape_id = self.next_id("shape");
406 let story_id = format!("story:{shape_id}:0");
407 let paragraph_id = self.next_id("para");
408 let mut txn = self.transact_for(context);
409 let slide = slide_ref(&txn, slide_id)?;
410 let order = slide_shape_order(&slide, &txn)?;
411 let index = order.len(&txn);
412 let shapes = required_map(&txn, SHAPES)?;
413 let stories = required_map(&txn, STORIES)?;
414 seed_plain_story(
415 &stories,
416 &mut txn,
417 &story_id,
418 ¶graph_id,
419 &draft.text,
420 &draft.style,
421 );
422 let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
423 shape.insert(&mut txn, "id", shape_id.as_str());
424 shape.insert(&mut txn, "sourceId", 0_f64);
425 shape.insert(&mut txn, "kind", "shape");
426 shape.insert(&mut txn, "name", draft.name.as_str());
427 shape.insert(&mut txn, "x", draft.rect.x as f64);
428 shape.insert(&mut txn, "y", draft.rect.y as f64);
429 shape.insert(&mut txn, "width", draft.rect.width as f64);
430 shape.insert(&mut txn, "height", draft.rect.height as f64);
431 shape.insert(&mut txn, "rotationDeg", 0_f64);
432 shape.insert(&mut txn, "flipH", false);
433 shape.insert(&mut txn, "flipV", false);
434 shape.insert(&mut txn, "geometry", "rect");
435 insert_json(
436 &shape,
437 &mut txn,
438 "fillJson",
439 Some(&ShapeFill::named("none")),
440 )?;
441 shape.insert(
442 &mut txn,
443 "textStories",
444 string_array(std::slice::from_ref(&story_id)),
445 );
446 shape.insert(&mut txn, "children", string_array(&[]));
447 order.push_back(&mut txn, shape_id.as_str());
448 Ok(ShapeReceipt {
449 slide_id: slide_id.to_owned(),
450 shape_id,
451 index,
452 })
453 }
454
455 pub fn add_shape(
456 &self,
457 context: &EditCtx,
458 slide_id: &str,
459 draft: &PresetShapeDraft,
460 ) -> EditResult<ShapeReceipt> {
461 validate_rect(draft.rect)?;
462 crate::model::validate_xml_text(&draft.name)?;
463 let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
464 if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none()
465 && ooxml_drawingml::preset_geometry_layers(
466 &draft.geometry,
467 &Default::default(),
468 aspect_ratio,
469 )
470 .is_none()
471 {
472 return Err(EditError::InvalidGeometry(format!(
473 "unsupported preset geometry {}",
474 draft.geometry
475 )));
476 }
477 let fill = shape_fill(draft.fill.as_deref())?;
478 let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
479 .into_iter()
480 .collect::<BTreeMap<_, _>>();
481 let shape_id = self.next_id("shape");
482 let mut txn = self.transact_for(context);
483 let slide = slide_ref(&txn, slide_id)?;
484 let order = slide_shape_order(&slide, &txn)?;
485 let index = order.len(&txn);
486 let shapes = required_map(&txn, SHAPES)?;
487 let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
488 shape.insert(&mut txn, "id", shape_id.as_str());
489 shape.insert(&mut txn, "sourceId", 0_f64);
490 shape.insert(&mut txn, "kind", "shape");
491 shape.insert(&mut txn, "name", draft.name.as_str());
492 shape.insert(&mut txn, "x", draft.rect.x as f64);
493 shape.insert(&mut txn, "y", draft.rect.y as f64);
494 shape.insert(&mut txn, "width", draft.rect.width as f64);
495 shape.insert(&mut txn, "height", draft.rect.height as f64);
496 shape.insert(&mut txn, "rotationDeg", 0_f64);
497 shape.insert(&mut txn, "flipH", false);
498 shape.insert(&mut txn, "flipV", false);
499 shape.insert(&mut txn, "geometry", draft.geometry.as_str());
500 insert_json(&shape, &mut txn, "adjustValuesJson", Some(&adjust_values))?;
501 insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
502 shape.insert(&mut txn, "textStories", string_array(&[]));
503 shape.insert(&mut txn, "children", string_array(&[]));
504 order.push_back(&mut txn, shape_id.as_str());
505 Ok(ShapeReceipt {
506 slide_id: slide_id.to_owned(),
507 shape_id,
508 index,
509 })
510 }
511
512 pub fn media_bytes(&self, asset_id: &str) -> EditResult<Vec<u8>> {
514 if let Some(shape_id) = asset_id.strip_prefix("pending-media:") {
515 let txn = self.doc.transact();
516 let shape = shape_ref(&txn, shape_id)?;
517 let encoded = map_string(&shape, &txn, "pendingMediaBase64")
518 .ok_or_else(|| EditError::InvalidState("pending media was not found".to_owned()))?;
519 return base64::engine::general_purpose::STANDARD
520 .decode(encoded)
521 .map_err(|error| {
522 EditError::InvalidState(format!("invalid pending image data: {error}"))
523 });
524 }
525 self.package()
526 .media
527 .iter()
528 .find(|media| media.part_path == asset_id)
529 .map(|media| media.bytes.clone())
530 .ok_or_else(|| EditError::InvalidState("media part was not found".to_owned()))
531 }
532
533 pub fn add_picture(
535 &self,
536 context: &EditCtx,
537 slide_id: &str,
538 draft: &PictureDraft,
539 ) -> EditResult<ShapeReceipt> {
540 validate_rect(draft.rect)?;
541 crate::model::validate_xml_text(&draft.name)?;
542 if draft.media_bytes.is_empty() {
543 return Err(EditError::InvalidState(
544 "a picture needs image data".to_owned(),
545 ));
546 }
547 if draft.media_bytes.len() > MAX_PENDING_PICTURE_BYTES {
548 return Err(EditError::InvalidState(format!(
549 "image is {} bytes, exceeds the {MAX_PENDING_PICTURE_BYTES}-byte limit",
550 draft.media_bytes.len()
551 )));
552 }
553 if !pptx_parse::is_supported_image_content_type(&draft.content_type) {
554 return Err(EditError::InvalidState(format!(
555 "unsupported image type {:?}",
556 draft.content_type
557 )));
558 }
559 let shape_id = self.next_id("shape");
560 let mut txn = self.transact_for(context);
561 let slide = slide_ref(&txn, slide_id)?;
562 let order = slide_shape_order(&slide, &txn)?;
563 let index = order.len(&txn);
564 let shapes = required_map(&txn, SHAPES)?;
565 let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
566 shape.insert(&mut txn, "id", shape_id.as_str());
567 shape.insert(&mut txn, "sourceId", 0_f64);
568 shape.insert(&mut txn, "kind", "picture");
569 shape.insert(&mut txn, "name", draft.name.as_str());
570 shape.insert(&mut txn, "x", draft.rect.x as f64);
571 shape.insert(&mut txn, "y", draft.rect.y as f64);
572 shape.insert(&mut txn, "width", draft.rect.width as f64);
573 shape.insert(&mut txn, "height", draft.rect.height as f64);
574 shape.insert(&mut txn, "rotationDeg", 0_f64);
575 shape.insert(&mut txn, "flipH", false);
576 shape.insert(&mut txn, "flipV", false);
577 shape.insert(&mut txn, "geometry", "rect");
578 shape.insert(
579 &mut txn,
580 "pendingMediaBase64",
581 base64::engine::general_purpose::STANDARD.encode(&draft.media_bytes),
582 );
583 shape.insert(
584 &mut txn,
585 "pendingMediaContentType",
586 draft.content_type.as_str(),
587 );
588 shape.insert(&mut txn, "textStories", string_array(&[]));
589 shape.insert(&mut txn, "children", string_array(&[]));
590 order.push_back(&mut txn, shape_id.as_str());
591 Ok(ShapeReceipt {
592 slide_id: slide_id.to_owned(),
593 shape_id,
594 index,
595 })
596 }
597
598 pub fn set_shape_fill(
599 &self,
600 context: &EditCtx,
601 slide_id: &str,
602 shape_id: &str,
603 color: Option<&str>,
604 ) -> EditResult<ShapeFillReceipt> {
605 let fill = shape_fill(color)?;
606 let mut txn = self.transact_for(context);
607 require_shape_membership(&txn, slide_id, shape_id)?;
608 let shape = shape_ref(&txn, shape_id)?;
609 require_shape_kind(&shape, &txn)?;
610 let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
611 .as_ref()
612 .and_then(fill_color);
613 insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
614 Ok(ShapeFillReceipt {
615 slide_id: slide_id.to_owned(),
616 shape_id: shape_id.to_owned(),
617 before,
618 after: fill_color(&fill),
619 })
620 }
621
622 pub fn set_shape_stroke(
623 &self,
624 context: &EditCtx,
625 slide_id: &str,
626 shape_id: &str,
627 stroke: &ShapeStroke,
628 ) -> EditResult<ShapeStrokeReceipt> {
629 stroked_outline(None, stroke)?;
630 let mut txn = self.transact_for(context);
631 require_shape_membership(&txn, slide_id, shape_id)?;
632 let shape = shape_ref(&txn, shape_id)?;
633 require_shape_kind(&shape, &txn)?;
634 let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
635 let before = existing.as_ref().and_then(outline_stroke);
636 let outline = stroked_outline(existing, stroke)?;
637 insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
638 Ok(ShapeStrokeReceipt {
639 slide_id: slide_id.to_owned(),
640 shape_id: shape_id.to_owned(),
641 before,
642 after: outline_stroke(&outline),
643 })
644 }
645
646 pub fn set_shape_adjust(
647 &self,
648 context: &EditCtx,
649 slide_id: &str,
650 shape_id: &str,
651 adjustments: &BTreeMap<String, f64>,
652 ) -> EditResult<ShapeAdjustReceipt> {
653 validate_adjustments(adjustments)?;
654 let mut txn = self.transact_for(context);
655 require_shape_membership(&txn, slide_id, shape_id)?;
656 let shape = shape_ref(&txn, shape_id)?;
657 require_shape_kind(&shape, &txn)?;
658 let geometry = required_string(&shape, &txn, "geometry")?;
659 if geometry == "custom" {
660 return Err(EditError::InvalidGeometry(
661 "custom geometry does not support shape adjustments".to_owned(),
662 ));
663 }
664 if !map_bool(&shape, &txn, "hasPresetGeometry").unwrap_or(true) {
665 return Err(EditError::InvalidGeometry(
666 "shape without preset geometry does not support shape adjustments".to_owned(),
667 ));
668 }
669 let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
670 let mut after = BTreeMap::new();
671 for (name, value) in adjustments {
672 let maximum = if geometry == "roundRect" && name == "adj" {
673 0.5
674 } else {
675 1.0
676 };
677 after.insert(name.clone(), value.clamp(0.0, maximum));
678 }
679 insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
680 Ok(ShapeAdjustReceipt {
681 slide_id: slide_id.to_owned(),
682 shape_id: shape_id.to_owned(),
683 before,
684 after,
685 })
686 }
687
688 pub fn remove_shape(
689 &self,
690 context: &EditCtx,
691 slide_id: &str,
692 shape_id: &str,
693 ) -> EditResult<ShapeReceipt> {
694 let mut txn = self.transact_for(context);
695 let slide = slide_ref(&txn, slide_id)?;
696 let order = slide_shape_order(&slide, &txn)?;
697 let ids = live_shape_order(&order, &txn)?;
698 let index =
699 ids.iter()
700 .position(|id| id == shape_id)
701 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))? as u32;
702 remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
703 for (index, id) in string_array_ref(&order, &txn).iter().enumerate().rev() {
704 if id == shape_id {
705 order.remove(&mut txn, index as u32);
706 }
707 }
708 Ok(ShapeReceipt {
709 slide_id: slide_id.to_owned(),
710 shape_id: shape_id.to_owned(),
711 index,
712 })
713 }
714
715 pub fn move_shape(
716 &self,
717 context: &EditCtx,
718 slide_id: &str,
719 shape_id: &str,
720 x: i64,
721 y: i64,
722 ) -> EditResult<TransformReceipt> {
723 validate_coordinate(x)?;
724 validate_coordinate(y)?;
725 let mut txn = self.transact_for(context);
726 require_shape_membership(&txn, slide_id, shape_id)?;
727 let shape = shape_ref(&txn, shape_id)?;
728 self.materialize_inherited(&mut txn, slide_id, &shape)?;
729 let before = shape_rect(&shape, &txn)?;
730 shape.insert(&mut txn, "x", x as f64);
731 shape.insert(&mut txn, "y", y as f64);
732 Ok(TransformReceipt {
733 slide_id: slide_id.to_owned(),
734 shape_id: shape_id.to_owned(),
735 before,
736 after: ShapeRect { x, y, ..before },
737 })
738 }
739
740 pub fn bring_to_front(
742 &self,
743 context: &EditCtx,
744 slide_id: &str,
745 shape_id: &str,
746 ) -> EditResult<ShapeZOrderReceipt> {
747 self.reorder_shape(context, slide_id, shape_id, |length, _from| length - 1)
748 }
749
750 pub fn send_to_back(
752 &self,
753 context: &EditCtx,
754 slide_id: &str,
755 shape_id: &str,
756 ) -> EditResult<ShapeZOrderReceipt> {
757 self.reorder_shape(context, slide_id, shape_id, |_length, _from| 0)
758 }
759
760 pub fn bring_forward(
762 &self,
763 context: &EditCtx,
764 slide_id: &str,
765 shape_id: &str,
766 ) -> EditResult<ShapeZOrderReceipt> {
767 self.reorder_shape(context, slide_id, shape_id, |length, from| {
768 (from + 1).min(length - 1)
769 })
770 }
771
772 pub fn send_backward(
774 &self,
775 context: &EditCtx,
776 slide_id: &str,
777 shape_id: &str,
778 ) -> EditResult<ShapeZOrderReceipt> {
779 self.reorder_shape(context, slide_id, shape_id, |_length, from| {
780 from.saturating_sub(1)
781 })
782 }
783
784 fn reorder_shape(
785 &self,
786 context: &EditCtx,
787 slide_id: &str,
788 shape_id: &str,
789 to_index: impl FnOnce(u32, u32) -> u32,
790 ) -> EditResult<ShapeZOrderReceipt> {
791 let mut txn = self.transact_for(context);
792 let slide = slide_ref(&txn, slide_id)?;
793 let order = slide_shape_order(&slide, &txn)?;
794 let ids = live_shape_order(&order, &txn)?;
795 let length = ids.len() as u32;
796 let from_index =
797 ids.iter()
798 .position(|id| id == shape_id)
799 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))? as u32;
800 let target = to_index(length, from_index).min(length - 1);
801 let mut seen = HashSet::new();
802 let live: HashSet<&str> = ids.iter().map(String::as_str).collect();
803 let stale: Vec<u32> = string_array_ref(&order, &txn)
804 .iter()
805 .enumerate()
806 .filter_map(|(index, id)| {
807 (!live.contains(id.as_str()) || !seen.insert(id.clone())).then_some(index as u32)
808 })
809 .collect();
810 for index in stale.into_iter().rev() {
811 order.remove(&mut txn, index);
812 }
813 if target != from_index {
814 order.remove(&mut txn, from_index);
815 order.insert(&mut txn, target, shape_id);
816 }
817 Ok(ShapeZOrderReceipt {
818 slide_id: slide_id.to_owned(),
819 shape_id: shape_id.to_owned(),
820 from_index,
821 to_index: target,
822 })
823 }
824
825 pub fn resize_shape(
826 &self,
827 context: &EditCtx,
828 slide_id: &str,
829 shape_id: &str,
830 width: i64,
831 height: i64,
832 ) -> EditResult<TransformReceipt> {
833 let rect = ShapeRect {
834 width,
835 height,
836 ..ShapeRect::default()
837 };
838 validate_rect(rect)?;
839 let mut txn = self.transact_for(context);
840 require_shape_membership(&txn, slide_id, shape_id)?;
841 let shape = shape_ref(&txn, shape_id)?;
842 self.materialize_inherited(&mut txn, slide_id, &shape)?;
843 let before = shape_rect(&shape, &txn)?;
844 shape.insert(&mut txn, "width", width as f64);
845 shape.insert(&mut txn, "height", height as f64);
846 Ok(TransformReceipt {
847 slide_id: slide_id.to_owned(),
848 shape_id: shape_id.to_owned(),
849 before,
850 after: ShapeRect {
851 width,
852 height,
853 ..before
854 },
855 })
856 }
857
858 pub fn set_shape_rect(
859 &self,
860 context: &EditCtx,
861 slide_id: &str,
862 shape_id: &str,
863 rect: ShapeRect,
864 ) -> EditResult<TransformReceipt> {
865 validate_rect(rect)?;
866 let mut txn = self.transact_for(context);
867 require_shape_membership(&txn, slide_id, shape_id)?;
868 let shape = shape_ref(&txn, shape_id)?;
869 self.materialize_inherited(&mut txn, slide_id, &shape)?;
870 let before = shape_rect(&shape, &txn)?;
871 shape.insert(&mut txn, "x", rect.x as f64);
872 shape.insert(&mut txn, "y", rect.y as f64);
873 shape.insert(&mut txn, "width", rect.width as f64);
874 shape.insert(&mut txn, "height", rect.height as f64);
875 Ok(TransformReceipt {
876 slide_id: slide_id.to_owned(),
877 shape_id: shape_id.to_owned(),
878 before,
879 after: rect,
880 })
881 }
882
883 fn materialize_inherited(
887 &self,
888 txn: &mut TransactionMut<'_>,
889 slide_id: &str,
890 shape: &MapRef,
891 ) -> EditResult<()> {
892 let rect = shape_rect(shape, txn)?;
893 if rect.width > 0 && rect.height > 0 {
894 return Ok(());
895 }
896 let placeholder: Option<Placeholder> = optional_json(shape, txn, "placeholderJson")?;
897 let source_id = required_u32(shape, txn, "sourceId")?;
898 let slide = slide_ref(txn, slide_id)?;
899 let source_part_path = map_string(&slide, txn, "sourcePartPath");
900 let layout_part_path = map_string(&slide, txn, "layoutPartPath");
901 let context = SlideContext::new(
902 &self.package,
903 source_part_path.as_deref(),
904 layout_part_path.as_deref(),
905 );
906 if let Some(transform) = inherited_transform(&context, source_id, placeholder.as_ref()) {
907 shape.insert(txn, "x", transform.x as f64);
908 shape.insert(txn, "y", transform.y as f64);
909 shape.insert(txn, "width", transform.width as f64);
910 shape.insert(txn, "height", transform.height as f64);
911 shape.insert(txn, "rotationDeg", transform.rotation_deg);
912 shape.insert(txn, "flipH", transform.flip_h);
913 shape.insert(txn, "flipV", transform.flip_v);
914 } else if let Some(transform) = placeholder
915 .as_ref()
916 .and_then(|placeholder| layout_transform(&context, placeholder))
917 {
918 shape.insert(txn, "width", transform.width as f64);
919 shape.insert(txn, "height", transform.height as f64);
920 }
921 Ok(())
922 }
923}
924
925pub(crate) fn validate_doc(doc: &Doc) -> EditResult<(PptxPackage, DeckSnapshot)> {
926 let (package, snapshot) = validate_doc_impl(doc, None)?;
927 Ok((package.expect("package_from_meta decodes"), snapshot))
928}
929
930pub(crate) fn validated_snapshot(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
933 validate_doc_impl(doc, Some(package)).map(|(_, snapshot)| snapshot)
934}
935
936fn validate_doc_impl(
937 doc: &Doc,
938 package: Option<&PptxPackage>,
939) -> EditResult<(Option<PptxPackage>, DeckSnapshot)> {
940 let mut owned = None;
941 let package = {
942 let txn = doc.transact();
943 let meta = required_map(&txn, META)?;
944 validate_schema_version(&meta, &txn)?;
945 if map_string(&meta, &txn, "fingerprint").is_none() {
946 return Err(EditError::InvalidState("missing fingerprint".to_owned()));
947 }
948 match package {
949 Some(package) => {
950 if !matches!(
951 meta.get(&txn, "packageJson"),
952 Some(Out::Any(Any::Buffer(_)))
953 ) {
954 return Err(EditError::InvalidState("missing package data".to_owned()));
955 }
956 package
957 }
958 None => {
959 owned = Some(package_from_meta(&meta, &txn)?);
960 owned.as_ref().unwrap()
961 }
962 }
963 };
964 let snapshot = snapshot_doc(doc, package)?;
965 if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
966 return Err(EditError::InvalidState(
967 "slide dimensions must be positive".to_owned(),
968 ));
969 }
970 let txn = doc.transact();
971 let stories = required_map(&txn, STORIES)?;
972 for (story_id, value) in stories.iter(&txn) {
973 let story = value
974 .cast::<TextRef>()
975 .map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
976 validate_story(&story, &txn, story_id)?;
977 }
978 Ok((owned, snapshot))
979}
980
981pub(crate) struct SourceImport<'a> {
985 pub(crate) source: &'a PptxPackage,
987 pub(crate) package: PptxPackage,
989 source_snapshot: Option<DeckSnapshot>,
990 pub(crate) recovery: crate::source_run_properties::RecoveryCache,
991}
992
993impl<'a> SourceImport<'a> {
994 pub(crate) fn new(package: PptxPackage, source: &'a PptxPackage) -> Self {
995 Self {
996 source,
997 package,
998 source_snapshot: None,
999 recovery: Default::default(),
1000 }
1001 }
1002
1003 pub(crate) fn source_snapshot(&mut self) -> EditResult<&DeckSnapshot> {
1005 if self.source_snapshot.is_none() {
1006 self.source_snapshot = Some(baseline_snapshot(self.source)?);
1007 }
1008 Ok(self.source_snapshot.as_ref().unwrap())
1009 }
1010
1011 pub(crate) fn sync_package_json(self, doc: &Doc, baseline: &PptxPackage) -> EditResult<()> {
1013 if &self.package == baseline {
1014 return Ok(());
1015 }
1016 let bytes = serde_json::to_vec(&self.package)
1017 .map_err(|error| EditError::Json(error.to_string()))?;
1018 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1019 let meta = required_map(&txn, META)?;
1020 meta.insert(&mut txn, "packageJson", Any::Buffer(Arc::from(bytes)));
1021 Ok(())
1022 }
1023}
1024
1025#[cfg(test)]
1026pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
1027 let txn = doc.transact();
1028 let meta = required_map(&txn, META)?;
1029 validate_schema_version(&meta, &txn)?;
1030 package_from_meta(&meta, &txn)
1031}
1032
1033pub(crate) fn import_source_render_data(
1034 doc: &Doc,
1035 import: &mut SourceImport<'_>,
1036) -> EditResult<()> {
1037 let source = import.source;
1038 let package = &mut import.package;
1039 let sources = source
1040 .slides
1041 .iter()
1042 .map(|part| (&part.part_path, &part.shapes))
1043 .chain(
1044 source
1045 .layouts
1046 .iter()
1047 .map(|part| (&part.part_path, &part.shapes)),
1048 )
1049 .chain(
1050 source
1051 .masters
1052 .iter()
1053 .map(|part| (&part.part_path, &part.shapes)),
1054 )
1055 .collect::<HashMap<_, _>>();
1056 let mut changed = false;
1057 for (path, shapes) in package
1058 .slides
1059 .iter_mut()
1060 .map(|part| (&part.part_path, &mut part.shapes))
1061 .chain(
1062 package
1063 .layouts
1064 .iter_mut()
1065 .map(|part| (&part.part_path, &mut part.shapes)),
1066 )
1067 .chain(
1068 package
1069 .masters
1070 .iter_mut()
1071 .map(|part| (&part.part_path, &mut part.shapes)),
1072 )
1073 {
1074 if let Some(source) = sources.get(path) {
1075 changed |= merge_source_render_shapes(shapes, source);
1076 }
1077 }
1078 for master in &mut package.masters {
1079 if let Some(source) = source
1080 .masters
1081 .iter()
1082 .find(|source| source.part_path == master.part_path)
1083 {
1084 let target = &mut master.text_styles;
1085 let source = &source.text_styles;
1086 for (target, source) in target
1087 .title
1088 .iter_mut()
1089 .zip(&source.title)
1090 .chain(target.body.iter_mut().zip(&source.body))
1091 .chain(target.other.iter_mut().zip(&source.other))
1092 {
1093 changed |= merge_source_paragraph_properties(target, source);
1094 }
1095 }
1096 }
1097 for chart in &mut package.charts {
1098 if let Some(source) = source
1099 .charts
1100 .iter()
1101 .find(|source| source.part_path == chart.part_path)
1102 {
1103 changed |= merge_source_chart_properties(&mut chart.chart, &source.chart);
1104 }
1105 }
1106 for slide in &mut package.slides {
1107 if let Some(source) = source
1108 .slides
1109 .iter()
1110 .find(|source| source.part_path == slide.part_path)
1111 && slide.notes != source.notes
1112 {
1113 slide.notes.clone_from(&source.notes);
1114 changed = true;
1115 }
1116 }
1117 if package.table_styles != source.table_styles {
1118 package.table_styles.clone_from(&source.table_styles);
1119 changed = true;
1120 }
1121 if !changed {
1122 return Ok(());
1123 }
1124 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1125 backfill_blip_effects(&mut txn, package)?;
1126 backfill_tables(&mut txn, package)?;
1127 Ok(())
1128}
1129
1130fn merge_source_render_shapes(target: &mut [ShapeNode], source: &[ShapeNode]) -> bool {
1131 let mut changed = false;
1132 for source in source {
1133 let Some(target) = target
1134 .iter_mut()
1135 .find(|target| shape_base(target).id == shape_base(source).id)
1136 else {
1137 continue;
1138 };
1139 match (target, source) {
1140 (ShapeNode::Shape(target), ShapeNode::Shape(source)) => {
1141 changed |= target.picture_fill != source.picture_fill;
1142 target.picture_fill.clone_from(&source.picture_fill);
1143 if let (Some(target), Some(source)) = (&mut target.text, &source.text) {
1144 changed |= merge_source_text_body(target, source);
1145 }
1146 }
1147 (ShapeNode::Picture(target), ShapeNode::Picture(source)) => {
1148 if target.effects.is_empty() && !source.effects.is_empty() {
1149 target.effects.clone_from(&source.effects);
1150 changed = true;
1151 }
1152 }
1153 (ShapeNode::Group(target), ShapeNode::Group(source)) => {
1154 changed |= merge_source_render_shapes(&mut target.children, &source.children);
1155 }
1156 (ShapeNode::GraphicFrame(target), ShapeNode::GraphicFrame(source)) => {
1157 if matches!(
1158 target.data,
1159 pptx_parse::GraphicFrameData::Unknown { picture: None, .. }
1160 ) && matches!(
1161 source.data,
1162 pptx_parse::GraphicFrameData::Unknown {
1163 picture: Some(_),
1164 ..
1165 }
1166 ) {
1167 target.data.clone_from(&source.data);
1168 changed = true;
1169 }
1170 if let (
1171 pptx_parse::GraphicFrameData::Table(target),
1172 pptx_parse::GraphicFrameData::Table(source),
1173 ) = (&mut target.data, &source.data)
1174 {
1175 changed |= merge_source_table(target, source);
1176 }
1177 }
1178 _ => {}
1179 }
1180 }
1181 changed
1182}
1183
1184fn merge_source_chart_properties(target: &mut ChartSpace, source: &ChartSpace) -> bool {
1187 let mut changed = target.fill != source.fill;
1188 target.fill.clone_from(&source.fill);
1189 changed |= target.text != source.text || target.title_text != source.title_text;
1190 target.text.clone_from(&source.text);
1191 target.title_text.clone_from(&source.title_text);
1192 if let (Some(target), Some(source)) = (&mut target.legend, &source.legend) {
1193 changed |= target.text != source.text;
1194 target.text.clone_from(&source.text);
1195 }
1196 for (target, source) in target.plot_groups.iter_mut().zip(&source.plot_groups) {
1197 for (target, source) in target.series.iter_mut().zip(&source.series) {
1198 changed |= target.line != source.line;
1199 target.line.clone_from(&source.line);
1200 }
1201 }
1202 let mut merge_axis = |target: Option<&mut ChartAxis>, source: Option<&ChartAxis>| {
1203 if let (Some(target), Some(source)) = (target, source) {
1204 changed |= target.line != source.line || target.text != source.text;
1205 target.line.clone_from(&source.line);
1206 target.text.clone_from(&source.text);
1207 }
1208 };
1209 if let (Some(target), Some(source)) = (&mut target.axes, &source.axes) {
1210 merge_axis(target.category.as_mut(), source.category.as_ref());
1211 merge_axis(target.value.as_mut(), source.value.as_ref());
1212 }
1213 if let (Some(target), Some(source)) = (&mut target.axis_list, &source.axis_list) {
1214 for (target, source) in target.iter_mut().zip(source) {
1215 merge_axis(Some(target), Some(source));
1216 }
1217 }
1218 changed
1219}
1220
1221fn backfill_tables(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1222 let shapes = required_map(txn, SHAPES)?;
1223 for (index, (slide, reference)) in package
1224 .slides
1225 .iter()
1226 .zip(&package.presentation.slides)
1227 .enumerate()
1228 {
1229 let slide_id = seeded_slide_id(index, reference.id);
1230 for (index, node) in slide.shapes.iter().enumerate() {
1231 backfill_table(txn, &shapes, &slide_id, &index.to_string(), node)?;
1232 }
1233 }
1234 Ok(())
1235}
1236
1237fn backfill_table(
1238 txn: &mut TransactionMut<'_>,
1239 shapes: &MapRef,
1240 slide_id: &str,
1241 path: &str,
1242 node: &ShapeNode,
1243) -> EditResult<()> {
1244 if let ShapeNode::GraphicFrame(frame) = node
1245 && let pptx_parse::GraphicFrameData::Table(table) = &frame.data
1246 && !table.grid.is_empty()
1247 && let Some(shape) = shapes
1248 .get(txn, &seeded_shape_id(slide_id, path))
1249 .and_then(|value| value.cast::<MapRef>().ok())
1250 && matches!(
1251 optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
1252 Some(pptx_parse::GraphicFrameData::Table(stored)) if stored.grid.is_empty()
1253 )
1254 {
1255 insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
1256 }
1257 if let ShapeNode::Group(group) = node {
1258 for (index, child) in group.children.iter().enumerate() {
1259 backfill_table(
1260 txn,
1261 shapes,
1262 slide_id,
1263 &seeded_child_path(path, index),
1264 child,
1265 )?;
1266 }
1267 }
1268 Ok(())
1269}
1270
1271fn merge_source_table(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
1273 let mut changed = target.grid != source.grid || target.properties != source.properties;
1274 target.grid.clone_from(&source.grid);
1275 target.properties.clone_from(&source.properties);
1276 for (target, source) in target.rows.iter_mut().zip(&source.rows) {
1277 changed |= target.height != source.height;
1278 target.height = source.height;
1279 for (target, source) in target.cells.iter_mut().zip(&source.cells) {
1280 changed |= target.grid_span != source.grid_span
1281 || target.row_span != source.row_span
1282 || target.merged != source.merged
1283 || target.fill != source.fill
1284 || target.borders != source.borders;
1285 target.grid_span = source.grid_span;
1286 target.row_span = source.row_span;
1287 target.merged = source.merged;
1288 target.fill.clone_from(&source.fill);
1289 target.borders.clone_from(&source.borders);
1290 changed |= merge_source_text_body(&mut target.text, &source.text);
1291 }
1292 }
1293 changed
1294}
1295
1296fn merge_source_text_body(
1297 target: &mut pptx_parse::TextBody,
1298 source: &pptx_parse::TextBody,
1299) -> bool {
1300 let mut changed = target.list_style != source.list_style
1301 || target.default_list_style != source.default_list_style
1302 || target.compat_line_spacing != source.compat_line_spacing
1303 || target.vertical_overflow != source.vertical_overflow
1304 || target.horizontal_overflow != source.horizontal_overflow;
1305 target.vertical_overflow = source.vertical_overflow;
1306 target.horizontal_overflow = source.horizontal_overflow;
1307 target.compat_line_spacing = source.compat_line_spacing;
1308 target.list_style.clone_from(&source.list_style);
1309 target
1310 .default_list_style
1311 .clone_from(&source.default_list_style);
1312 for (target, source) in target.paragraphs.iter_mut().zip(&source.paragraphs) {
1313 changed |= merge_source_paragraph_properties(&mut target.properties, &source.properties);
1314 }
1315 changed
1316}
1317
1318fn merge_source_paragraph_properties(
1319 target: &mut pptx_parse::ParagraphProperties,
1320 source: &pptx_parse::ParagraphProperties,
1321) -> bool {
1322 let mut changed = target.bullet_font != source.bullet_font
1323 || target.bullet_color != source.bullet_color
1324 || target.bullet_size != source.bullet_size
1325 || target.line_spacing != source.line_spacing
1326 || target.margin_right != source.margin_right
1327 || target.space_before != source.space_before
1328 || target.space_after != source.space_after
1329 || target.default_tab_size != source.default_tab_size
1330 || target.tab_stops != source.tab_stops;
1331 target.line_spacing = source.line_spacing;
1332 target.margin_right = source.margin_right;
1333 target.space_before = source.space_before;
1334 target.space_after = source.space_after;
1335 target.default_tab_size = source.default_tab_size;
1336 target.tab_stops.clone_from(&source.tab_stops);
1337 if let (
1338 Some(pptx_parse::Bullet::AutoNumber {
1339 restart: target, ..
1340 }),
1341 Some(pptx_parse::Bullet::AutoNumber {
1342 restart: source, ..
1343 }),
1344 ) = (&mut target.bullet, &source.bullet)
1345 {
1346 changed |= *target != *source;
1347 *target = *source;
1348 }
1349 target.bullet_font.clone_from(&source.bullet_font);
1350 target.bullet_color.clone_from(&source.bullet_color);
1351 target.bullet_size.clone_from(&source.bullet_size);
1352 changed
1353}
1354
1355pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
1356 let txn = doc.transact();
1357 let meta = required_map(&txn, META)?;
1358 map_string(&meta, &txn, "fingerprint")
1359 .ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
1360}
1361
1362pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
1364 let version = {
1365 let txn = doc.transact();
1366 let meta = required_map(&txn, META)?;
1367 schema_version(&meta, &txn)?
1368 };
1369 let package = if version < 2.1 {
1370 migrate_doc_to_v2_1(doc)?
1371 } else if version < SCHEMA_VERSION {
1372 migrate_doc_to_v2_2(doc)?
1373 } else {
1374 return Ok(());
1375 };
1376 restore_preset_defaults(doc, &package)
1377}
1378
1379fn migrate_doc_to_v2_2(doc: &Doc) -> EditResult<PptxPackage> {
1382 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1383 let meta = required_map(&txn, META)?;
1384 let package = package_from_meta(&meta, &txn)?;
1385 let package_json =
1386 serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1387 meta.insert(
1388 &mut txn,
1389 "packageJson",
1390 Any::Buffer(Arc::from(package_json)),
1391 );
1392 defer_legacy_runs(&mut txn, &meta);
1393 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1394 Ok(package)
1395}
1396
1397fn defer_legacy_runs(txn: &mut TransactionMut<'_>, meta: &MapRef) {
1400 meta.insert(txn, "capsPendingSource", true);
1401 meta.insert(txn, "colorsPendingSource", true);
1402}
1403
1404fn restore_preset_defaults(doc: &Doc, package: &PptxPackage) -> EditResult<()> {
1408 let seeded = seeded_adjustments(doc)?;
1409 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1410 let shapes = required_map(&txn, SHAPES)?;
1411 for (index, (slide, reference)) in package
1412 .slides
1413 .iter()
1414 .zip(&package.presentation.slides)
1415 .enumerate()
1416 {
1417 let slide_id = seeded_slide_id(index, reference.id);
1418 for (index, node) in slide.shapes.iter().enumerate() {
1419 restore_shape_defaults(
1420 &mut txn,
1421 &shapes,
1422 &seeded,
1423 &slide_id,
1424 &index.to_string(),
1425 node,
1426 )?;
1427 }
1428 }
1429 Ok(())
1430}
1431
1432fn seeded_adjustments(doc: &Doc) -> EditResult<HashSet<String>> {
1435 let bootstrap = ClientID::new(crate::BOOTSTRAP_CLIENT_ID);
1436 let update = {
1437 let txn = doc.transact();
1438 let mut others = StateVector::default();
1439 for (client, clock) in txn.state_vector().iter() {
1440 if *client != bootstrap {
1441 others.set_max(*client, *clock);
1442 }
1443 }
1444 txn.encode_state_as_update_v1(&others)
1445 };
1446 let view = Doc::new();
1447 let mut txn = view.transact_mut();
1448 txn.apply_update(
1449 Update::decode_v1(&update).map_err(|error| EditError::InvalidUpdate(error.to_string()))?,
1450 )
1451 .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
1452 let Some(shapes) = txn.get_map(SHAPES) else {
1453 return Ok(HashSet::new());
1454 };
1455 Ok(shapes
1456 .iter(&txn)
1457 .filter(|(_, shape)| {
1458 shape
1459 .clone()
1460 .cast::<MapRef>()
1461 .is_ok_and(|shape| shape.contains_key(&txn, "adjustValuesJson"))
1462 })
1463 .map(|(id, _)| id.to_owned())
1464 .collect())
1465}
1466
1467fn restore_shape_defaults(
1470 txn: &mut TransactionMut<'_>,
1471 shapes: &MapRef,
1472 seeded: &HashSet<String>,
1473 slide_id: &str,
1474 path: &str,
1475 node: &ShapeNode,
1476) -> EditResult<()> {
1477 match node {
1478 ShapeNode::Shape(source) => {
1479 let id = seeded_shape_id(slide_id, path);
1480 let Some(shape) = seeded
1481 .contains(&id)
1482 .then(|| shapes.get(txn, &id))
1483 .flatten()
1484 .and_then(|value| value.cast::<MapRef>().ok())
1485 else {
1486 return Ok(());
1487 };
1488 let legacy: BTreeMap<_, _> = legacy_preset_defaults(&source.geometry)
1489 .iter()
1490 .copied()
1491 .collect();
1492 let current = preset_geometry_default_adjustments(&source.geometry);
1493 let mut values: BTreeMap<String, f64> =
1494 optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default();
1495 let mut changed = false;
1496 for name in legacy
1497 .keys()
1498 .copied()
1499 .chain(current.keys().map(String::as_str))
1500 {
1501 let (before, after) = (legacy.get(name).copied(), current.get(name).copied());
1502 if before == after
1503 || source.adjust_values.contains_key(name)
1504 || values.get(name).copied() != before
1505 {
1506 continue;
1507 }
1508 match after {
1509 Some(value) => values.insert(name.to_owned(), value),
1510 None => values.remove(name),
1511 };
1512 changed = true;
1513 }
1514 if changed {
1515 insert_json(&shape, txn, "adjustValuesJson", Some(&values))?;
1516 }
1517 }
1518 ShapeNode::Group(group) => {
1519 for (index, child) in group.children.iter().enumerate() {
1520 restore_shape_defaults(
1521 txn,
1522 shapes,
1523 seeded,
1524 slide_id,
1525 &seeded_child_path(path, index),
1526 child,
1527 )?;
1528 }
1529 }
1530 _ => {}
1531 }
1532 Ok(())
1533}
1534
1535fn legacy_preset_defaults(geometry: &str) -> &'static [(&'static str, f64)] {
1537 match geometry {
1538 "roundRect" => &[("adj", 0.166_67)],
1539 "plus" | "parallelogram" | "hexagon" => &[("adj", 0.25)],
1540 "triangle" | "isosTriangle" | "chevron" | "homePlate" => &[("adj", 0.5)],
1541 "trapezoid" => &[("adj", 0.2)],
1542 "octagon" => &[("adj", 0.292_89)],
1543 "rightArrow" | "leftArrow" | "upArrow" | "downArrow" => &[("adj1", 0.5), ("adj2", 0.5)],
1544 "star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16"
1545 | "star24" | "star32" => &[("adj", 0.45)],
1546 _ => &[],
1547 }
1548}
1549
1550fn migrate_doc_to_v2_1(doc: &Doc) -> EditResult<PptxPackage> {
1558 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1559 let meta = required_map(&txn, META)?;
1560 let package = package_from_meta(&meta, &txn)?;
1561 let package_json =
1562 serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
1563 meta.insert(
1564 &mut txn,
1565 "packageJson",
1566 Any::Buffer(Arc::from(package_json)),
1567 );
1568 backfill_hidden(&mut txn, &package)?;
1569 record_comment_flavor(&mut txn, &meta, &package);
1570 meta.insert(&mut txn, "baselinesPendingSource", true);
1571 meta.insert(&mut txn, "outlineGradientsPendingSource", true);
1572 backfill_blip_effects(&mut txn, &package)?;
1573 backfill_tables(&mut txn, &package)?;
1574 meta.insert(&mut txn, "spacingPendingSource", true);
1575 if package_needs_ole_source(&package) {
1576 meta.insert(&mut txn, "olePicturesPendingSource", true);
1577 }
1578 defer_legacy_runs(&mut txn, &meta);
1579 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
1580 Ok(package)
1581}
1582
1583fn record_comment_flavor(txn: &mut TransactionMut<'_>, meta: &MapRef, package: &PptxPackage) {
1584 if meta.contains_key(txn, "commentFlavor") {
1585 return;
1586 }
1587 if !package.comments.is_empty()
1588 || package
1589 .relationships
1590 .values()
1591 .flatten()
1592 .any(|relationship| {
1593 relationship.is_type(pptx_parse::relationship_types::COMMENTS)
1594 || relationship.is_type(pptx_parse::relationship_types::MODERN_COMMENTS)
1595 })
1596 {
1597 meta.insert(txn, "commentsPendingSource", true);
1598 }
1599 meta.insert(
1600 txn,
1601 "commentFlavor",
1602 flavor_key(package.comment_flavor.unwrap_or_default()),
1603 );
1604}
1605
1606fn backfill_blip_effects(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1607 let shapes = required_map(txn, SHAPES)?;
1608 for (index, (slide, reference)) in package
1609 .slides
1610 .iter()
1611 .zip(&package.presentation.slides)
1612 .enumerate()
1613 {
1614 let slide_id = seeded_slide_id(index, reference.id);
1615 for (index, node) in slide.shapes.iter().enumerate() {
1616 backfill_picture_effects(txn, &shapes, &slide_id, &index.to_string(), node)?;
1617 }
1618 }
1619 Ok(())
1620}
1621
1622fn backfill_picture_effects(
1623 txn: &mut TransactionMut<'_>,
1624 shapes: &MapRef,
1625 slide_id: &str,
1626 path: &str,
1627 node: &ShapeNode,
1628) -> EditResult<()> {
1629 match node {
1630 ShapeNode::Picture(picture) if !picture.effects.is_empty() => {
1631 let id = seeded_shape_id(slide_id, path);
1632 if let Some(shape) = shapes
1633 .get(txn, &id)
1634 .and_then(|shape| shape.cast::<MapRef>().ok())
1635 && !shape.contains_key(txn, "blipEffectsJson")
1636 {
1637 insert_json(&shape, txn, "blipEffectsJson", Some(&picture.effects))?;
1638 }
1639 }
1640 ShapeNode::Group(group) => {
1641 for (index, child) in group.children.iter().enumerate() {
1642 backfill_picture_effects(
1643 txn,
1644 shapes,
1645 slide_id,
1646 &seeded_child_path(path, index),
1647 child,
1648 )?;
1649 }
1650 }
1651 _ => {}
1652 }
1653 Ok(())
1654}
1655
1656fn package_needs_ole_source(package: &PptxPackage) -> bool {
1657 package
1658 .slides
1659 .iter()
1660 .any(|part| needs_ole_source(&part.shapes))
1661 || package
1662 .layouts
1663 .iter()
1664 .any(|part| needs_ole_source(&part.shapes))
1665 || package
1666 .masters
1667 .iter()
1668 .any(|part| needs_ole_source(&part.shapes))
1669}
1670
1671fn needs_ole_source(nodes: &[ShapeNode]) -> bool {
1672 nodes.iter().any(|node| match node {
1673 ShapeNode::GraphicFrame(frame) => matches!(&frame.data,
1674 pptx_parse::GraphicFrameData::Unknown { uri: Some(uri), picture: None }
1675 if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole"),
1676 ShapeNode::Group(group) => needs_ole_source(&group.children),
1677 _ => false,
1678 })
1679}
1680
1681pub(crate) fn import_source_ole_pictures(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
1682 let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
1683 let meta = required_map(&txn, META)?;
1684 if map_bool(&meta, &txn, "olePicturesPendingSource") != Some(true) {
1685 return Ok(());
1686 }
1687 let shapes = required_map(&txn, SHAPES)?;
1688 for (index, (slide, reference)) in source
1689 .slides
1690 .iter()
1691 .zip(&source.presentation.slides)
1692 .enumerate()
1693 {
1694 let slide_id = seeded_slide_id(index, reference.id);
1695 for (index, node) in slide.shapes.iter().enumerate() {
1696 backfill_ole_picture(&mut txn, &shapes, &slide_id, &index.to_string(), node)?;
1697 }
1698 }
1699 meta.remove(&mut txn, "olePicturesPendingSource");
1700 Ok(())
1701}
1702
1703fn backfill_ole_picture(
1704 txn: &mut TransactionMut<'_>,
1705 shapes: &MapRef,
1706 slide_id: &str,
1707 path: &str,
1708 node: &ShapeNode,
1709) -> EditResult<()> {
1710 if let ShapeNode::GraphicFrame(frame) = node
1711 && matches!(
1712 frame.data,
1713 pptx_parse::GraphicFrameData::Unknown {
1714 picture: Some(_),
1715 ..
1716 }
1717 )
1718 && let Some(shape) = shapes
1719 .get(txn, &seeded_shape_id(slide_id, path))
1720 .and_then(|value| value.cast::<MapRef>().ok())
1721 && matches!(
1722 optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
1723 Some(pptx_parse::GraphicFrameData::Unknown { picture: None, .. })
1724 )
1725 {
1726 insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
1727 }
1728 if let ShapeNode::Group(group) = node {
1729 for (index, child) in group.children.iter().enumerate() {
1730 backfill_ole_picture(
1731 txn,
1732 shapes,
1733 slide_id,
1734 &seeded_child_path(path, index),
1735 child,
1736 )?;
1737 }
1738 }
1739 Ok(())
1740}
1741
1742fn backfill_hidden(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
1743 let shapes = required_map(txn, SHAPES)?;
1744 for shape_id in seeded_hidden_shape_ids(package) {
1745 if let Some(shape) = shapes
1746 .get(txn, &shape_id)
1747 .and_then(|value| value.cast::<MapRef>().ok())
1748 {
1749 shape.insert(txn, "hidden", true);
1750 }
1751 }
1752 Ok(())
1753}
1754
1755fn seeded_hidden_shape_ids(package: &PptxPackage) -> Vec<String> {
1756 let mut ids = Vec::new();
1757 for (slide_index, (slide, reference)) in package
1758 .slides
1759 .iter()
1760 .zip(&package.presentation.slides)
1761 .enumerate()
1762 {
1763 let slide_id = seeded_slide_id(slide_index, reference.id);
1764 for (shape_index, shape) in slide.shapes.iter().enumerate() {
1765 collect_hidden_shape_ids(&slide_id, &shape_index.to_string(), shape, &mut ids);
1766 }
1767 }
1768 ids
1769}
1770
1771fn collect_hidden_shape_ids(slide_id: &str, path: &str, shape: &ShapeNode, ids: &mut Vec<String>) {
1772 if shape_base(shape).hidden {
1773 ids.push(seeded_shape_id(slide_id, path));
1774 }
1775 if let ShapeNode::Group(group) = shape {
1776 for (child_index, child) in group.children.iter().enumerate() {
1777 collect_hidden_shape_ids(slide_id, &seeded_child_path(path, child_index), child, ids);
1778 }
1779 }
1780}
1781
1782fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
1783 map_number(meta, txn, "schemaVersion")
1784 .filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
1785 .ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
1786}
1787
1788fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
1789 schema_version(meta, txn).map(|_| ())
1790}
1791
1792fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
1793 let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
1794 return Err(EditError::InvalidState("missing package data".to_owned()));
1795 };
1796 serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
1797}
1798
1799pub(crate) fn live_shape_order<T: ReadTxn>(order: &ArrayRef, txn: &T) -> EditResult<Vec<String>> {
1800 let shapes = required_map(txn, SHAPES)?;
1801 let mut seen = HashSet::new();
1802 Ok(string_array_ref(order, txn)
1803 .into_iter()
1804 .filter(|id| shapes.contains_key(txn, id) && seen.insert(id.clone()))
1805 .collect())
1806}
1807
1808fn snapshot_slide<T: ReadTxn>(
1809 slides: &MapRef,
1810 shapes: &MapRef,
1811 stories: &MapRef,
1812 package: &PptxPackage,
1813 txn: &T,
1814 slide_id: &str,
1815) -> EditResult<SlideSnapshot> {
1816 let SlideParts {
1817 mut snapshot,
1818 shape_ids,
1819 theme,
1820 } = slide_parts(slides, package, txn, slide_id)?;
1821 let slide = slide_ref(txn, slide_id)?;
1822 snapshot.notes = slide_notes(&slide, txn, package);
1823 for shape_id in shape_ids {
1824 snapshot.shapes.push(snapshot_shape(
1825 shapes,
1826 stories,
1827 txn,
1828 &shape_id,
1829 &mut HashSet::new(),
1830 Some(&theme),
1831 )?);
1832 }
1833 record_inherited(
1834 &mut snapshot.shapes,
1835 &SlideContext::new(
1836 package,
1837 snapshot.source_part_path.as_deref(),
1838 snapshot.layout_part_path.as_deref(),
1839 ),
1840 );
1841 Ok(snapshot)
1842}
1843
1844pub(crate) struct SlideParts {
1846 pub snapshot: SlideSnapshot,
1847 pub shape_ids: Vec<String>,
1848 pub theme: Theme,
1849}
1850
1851pub(crate) fn slide_parts<T: ReadTxn>(
1852 slides: &MapRef,
1853 package: &PptxPackage,
1854 txn: &T,
1855 slide_id: &str,
1856) -> EditResult<SlideParts> {
1857 let slide = slides
1858 .get(txn, slide_id)
1859 .and_then(|value| value.cast::<MapRef>().ok())
1860 .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
1861 let source_part_path = map_string(&slide, txn, "sourcePartPath");
1862 let layout_part_path = map_string(&slide, txn, "layoutPartPath");
1863 let theme = pptx_parse::slide_theme(
1864 package,
1865 source_part_path.as_deref(),
1866 layout_part_path.as_deref(),
1867 );
1868 let shape_order = slide_shape_order(&slide, txn)?;
1869 Ok(SlideParts {
1870 shape_ids: live_shape_order(&shape_order, txn)?,
1871 snapshot: SlideSnapshot {
1872 id: slide_id.to_owned(),
1873 source_part_path,
1874 layout_part_path,
1875 name: map_string(&slide, txn, "name"),
1876 notes: String::new(),
1877 shapes: Vec::new(),
1878 },
1879 theme,
1880 })
1881}
1882
1883pub(crate) fn slide_scope(
1884 doc: &Doc,
1885 package: &PptxPackage,
1886 slide_index: usize,
1887) -> EditResult<SlideScope> {
1888 let txn = doc.transact();
1889 let meta = required_map(&txn, META)?;
1890 let order = required_order(&txn)?;
1891 let slides = required_map(&txn, SLIDES)?;
1892 let shapes = required_map(&txn, SHAPES)?;
1893 let stories = required_map(&txn, STORIES)?;
1894 let mut seen_slides = HashSet::new();
1895 let mut position = 0usize;
1896 let mut slide_id = None;
1897 for id in string_array_ref(&order, &txn) {
1898 if !seen_slides.insert(id.clone()) {
1899 continue;
1900 }
1901 if position == slide_index {
1902 slide_id = Some(id);
1903 break;
1904 }
1905 position += 1;
1906 }
1907 let slide_id = slide_id.ok_or(EditError::OutOfBounds {
1908 index: slide_index.min(u32::MAX as usize) as u32,
1909 length: seen_slides.len() as u32,
1910 })?;
1911 Ok(SlideScope {
1912 index: slide_index,
1913 slide: snapshot_slide(&slides, &shapes, &stories, package, &txn, &slide_id)?,
1914 width_emu: required_i64(&meta, &txn, "widthEmu")?,
1915 height_emu: required_i64(&meta, &txn, "heightEmu")?,
1916 })
1917}
1918
1919pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
1920 let txn = doc.transact();
1921 let meta = required_map(&txn, META)?;
1922 let order = required_order(&txn)?;
1923 let slides = required_map(&txn, SLIDES)?;
1924 let shapes = required_map(&txn, SHAPES)?;
1925 let stories = required_map(&txn, STORIES)?;
1926 let mut seen_slides = HashSet::new();
1927 let mut slide_snapshots = Vec::new();
1928 for slide_id in string_array_ref(&order, &txn) {
1929 if !seen_slides.insert(slide_id.clone()) {
1930 continue;
1931 }
1932 slide_snapshots.push(snapshot_slide(
1933 &slides, &shapes, &stories, package, &txn, &slide_id,
1934 )?);
1935 }
1936 Ok(DeckSnapshot {
1937 width_emu: required_i64(&meta, &txn, "widthEmu")?,
1938 height_emu: required_i64(&meta, &txn, "heightEmu")?,
1939 slides: slide_snapshots,
1940 comment_flavor: snapshot_flavor(&txn)?,
1941 comments: snapshot_comments(&txn)?,
1942 })
1943}
1944
1945pub(crate) fn slide_notes<T: ReadTxn>(slide: &MapRef, txn: &T, package: &PptxPackage) -> String {
1946 if let Some(notes) = map_string(slide, txn, "notes") {
1947 return notes;
1948 }
1949 let source_part_path = map_string(slide, txn, "sourcePartPath");
1950 package
1951 .slides
1952 .iter()
1953 .find(|source| Some(&source.part_path) == source_part_path.as_ref())
1954 .map(|source| source.notes.clone())
1955 .unwrap_or_default()
1956}
1957
1958pub(crate) fn snapshot_shape<T: ReadTxn>(
1959 shapes: &MapRef,
1960 stories: &MapRef,
1961 txn: &T,
1962 shape_id: &str,
1963 visiting: &mut HashSet<String>,
1964 theme: Option<&Theme>,
1965) -> EditResult<ShapeSnapshot> {
1966 if visiting.len() >= MAX_SHAPE_DEPTH {
1967 return Err(EditError::InvalidState(format!(
1968 "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
1969 )));
1970 }
1971 if !visiting.insert(shape_id.to_owned()) {
1972 return Err(EditError::InvalidState(format!(
1973 "shape cycle at {shape_id}"
1974 )));
1975 }
1976 let ShapeParts {
1977 mut snapshot,
1978 story_ids,
1979 child_ids,
1980 } = shape_parts(shapes, txn, shape_id, theme)?;
1981 for story_id in story_ids {
1982 let story = stories
1983 .get(txn, &story_id)
1984 .and_then(|value| value.cast::<TextRef>().ok())
1985 .ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
1986 snapshot
1987 .text_stories
1988 .push(snapshot_story(&story, txn, &story_id)?);
1989 }
1990 for child_id in child_ids {
1991 snapshot.children.push(snapshot_shape(
1992 shapes, stories, txn, &child_id, visiting, theme,
1993 )?);
1994 }
1995 visiting.remove(shape_id);
1996 let shape = shape_ref(txn, shape_id)?;
1997 snapshot.graphic = optional_json(&shape, txn, "graphicJson")?;
1998 snapshot.pending_media = match (
1999 map_string(&shape, txn, "pendingMediaBase64"),
2000 map_string(&shape, txn, "pendingMediaContentType"),
2001 ) {
2002 (Some(base64), Some(content_type)) => Some(PendingMedia {
2003 content_type,
2004 base64,
2005 }),
2006 _ => None,
2007 };
2008 Ok(snapshot)
2009}
2010
2011pub(crate) struct ShapeParts {
2014 pub snapshot: ShapeSnapshot,
2015 pub story_ids: Vec<String>,
2016 pub child_ids: Vec<String>,
2017}
2018
2019pub(crate) fn shape_parts<T: ReadTxn>(
2020 shapes: &MapRef,
2021 txn: &T,
2022 shape_id: &str,
2023 theme: Option<&Theme>,
2024) -> EditResult<ShapeParts> {
2025 let shape = shapes
2026 .get(txn, shape_id)
2027 .and_then(|value| value.cast::<MapRef>().ok())
2028 .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
2029 let story_ids = map_string_array(&shape, txn, "textStories")?;
2030 let child_ids = map_string_array(&shape, txn, "children")?;
2031 let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
2032 let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
2033 let resolved_fill_color = fill
2034 .as_ref()
2035 .filter(|fill| fill.fill_type != "none")
2036 .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
2037 let resolved_outline_color = outline
2038 .as_ref()
2039 .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
2040 let snapshot = ShapeSnapshot {
2041 id: shape_id.to_owned(),
2042 source_id: required_u32(&shape, txn, "sourceId")?,
2043 kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
2044 name: required_string(&shape, txn, "name")?,
2045 x: required_i64(&shape, txn, "x")?,
2046 y: required_i64(&shape, txn, "y")?,
2047 width: required_i64(&shape, txn, "width")?,
2048 height: required_i64(&shape, txn, "height")?,
2049 rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
2050 flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
2051 flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
2052 inherited: None,
2053 hidden: map_bool(&shape, txn, "hidden").unwrap_or_default(),
2054 geometry: required_string(&shape, txn, "geometry")?,
2055 adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
2056 placeholder: optional_json(&shape, txn, "placeholderJson")?,
2057 fill,
2058 resolved_fill_color,
2059 outline,
2060 resolved_outline_color,
2061 media_part_path: map_string(&shape, txn, "mediaPartPath"),
2062 pending_media: None,
2063 blip_effects: optional_json(&shape, txn, "blipEffectsJson")?.unwrap_or_default(),
2064 graphic: None,
2065 text_stories: Vec::new(),
2066 children: Vec::new(),
2067 };
2068 Ok(ShapeParts {
2069 snapshot,
2070 story_ids,
2071 child_ids,
2072 })
2073}
2074
2075pub(crate) fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
2078 seeded_snapshot(package, true)
2079}
2080
2081pub(crate) fn legacy_baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
2084 seeded_snapshot(package, false)
2085}
2086
2087fn seeded_snapshot(package: &PptxPackage, color_map: bool) -> EditResult<DeckSnapshot> {
2088 let mut slide_id_by_part = HashMap::new();
2089 let mut slides = Vec::with_capacity(package.slides.len());
2090 for (slide_index, slide) in package.slides.iter().enumerate() {
2091 let slide_id = seeded_slide_id(
2092 slide_index,
2093 package
2094 .presentation
2095 .slides
2096 .get(slide_index)
2097 .ok_or_else(|| {
2098 EditError::InvalidState(format!(
2099 "missing slide reference for slide {slide_index}"
2100 ))
2101 })?
2102 .id,
2103 );
2104 slide_id_by_part.insert(slide.part_path.clone(), slide_id.clone());
2105 slides.push(baseline_slide(package, slide, slide_id, color_map)?);
2106 }
2107 Ok(DeckSnapshot {
2108 width_emu: baseline_integer("widthEmu", package.presentation.width_emu)?,
2109 height_emu: baseline_integer("heightEmu", package.presentation.height_emu)?,
2110 slides,
2111 comment_flavor: package.comment_flavor.unwrap_or_default(),
2112 comments: baseline_comments(package, &|part| slide_id_by_part.get(part).cloned()),
2113 })
2114}
2115
2116fn baseline_slide(
2117 package: &PptxPackage,
2118 slide: &Slide,
2119 slide_id: String,
2120 color_map: bool,
2121) -> EditResult<SlideSnapshot> {
2122 let mut theme = pptx_parse::slide_theme(
2123 package,
2124 Some(&slide.part_path),
2125 slide.layout_part_path.as_deref(),
2126 );
2127 if !color_map {
2128 theme.color_map = ColorMap::default();
2129 }
2130 let mut shapes = Vec::with_capacity(slide.shapes.len());
2131 for (shape_index, shape) in slide.shapes.iter().enumerate() {
2132 shapes.push(baseline_shape(
2133 &slide_id,
2134 &shape_index.to_string(),
2135 shape,
2136 Some(&theme),
2137 )?);
2138 }
2139 record_inherited(
2140 &mut shapes,
2141 &SlideContext::new(
2142 package,
2143 Some(&slide.part_path),
2144 slide.layout_part_path.as_deref(),
2145 ),
2146 );
2147 Ok(SlideSnapshot {
2148 id: slide_id,
2149 source_part_path: Some(slide.part_path.clone()),
2150 layout_part_path: slide.layout_part_path.clone(),
2151 name: slide.name.clone(),
2152 notes: slide.notes.clone(),
2153 shapes,
2154 })
2155}
2156
2157fn baseline_shape(
2158 slide_id: &str,
2159 path: &str,
2160 shape: &ShapeNode,
2161 theme: Option<&Theme>,
2162) -> EditResult<ShapeSnapshot> {
2163 let shape_id = seeded_shape_id(slide_id, path);
2164 let base = shape_base(shape);
2165 let transform = &base.transform;
2166 let kind;
2167 let geometry;
2168 let mut text_stories = Vec::new();
2169 let mut children = Vec::new();
2170 let mut adjust_values = BTreeMap::new();
2171 let mut fill = None;
2172 let mut outline = None;
2173 let mut media_part_path = None;
2174 let mut blip_effects = Vec::new();
2175 let mut graphic = None;
2176 match shape {
2177 ShapeNode::Shape(shape) => {
2178 kind = ShapeKind::Shape;
2179 geometry = shape.geometry.clone();
2180 let mut values = preset_geometry_default_adjustments(&shape.geometry)
2181 .into_iter()
2182 .collect::<BTreeMap<_, _>>();
2183 values.extend(shape.adjust_values.clone());
2184 adjust_values = baseline_json(&values)?;
2185 if let Some(body) = &shape.text {
2186 text_stories.push(baseline_story(&format!("story:{shape_id}:0"), body, theme)?);
2187 }
2188 fill = baseline_json_option(&shape.fill)?;
2189 outline = baseline_json_option(&shape.outline)?;
2190 }
2191 ShapeNode::Picture(picture) => {
2192 kind = ShapeKind::Picture;
2193 geometry = "rect".to_owned();
2194 fill = baseline_json_option(&picture.fill)?;
2195 outline = baseline_json_option(&picture.outline)?;
2196 if !picture.effects.is_empty() {
2197 blip_effects = baseline_json(&picture.effects)?;
2198 }
2199 media_part_path = picture.media_part_path.clone();
2200 }
2201 ShapeNode::GraphicFrame(frame) => {
2202 kind = ShapeKind::GraphicFrame;
2203 geometry = "rect".to_owned();
2204 graphic = Some(baseline_json(&frame.data)?);
2205 if let GraphicFrameData::Table(table) = &frame.data {
2206 for (row_index, row) in table.rows.iter().enumerate() {
2207 for (cell_index, cell) in row.cells.iter().enumerate() {
2208 text_stories.push(baseline_story(
2209 &format!("story:{shape_id}:table:{row_index}:{cell_index}"),
2210 &cell.text,
2211 theme,
2212 )?);
2213 }
2214 }
2215 }
2216 }
2217 ShapeNode::Group(group) => {
2218 kind = ShapeKind::Group;
2219 geometry = "group".to_owned();
2220 for (child_index, child) in group.children.iter().enumerate() {
2221 children.push(baseline_shape(
2222 slide_id,
2223 &seeded_child_path(path, child_index),
2224 child,
2225 theme,
2226 )?);
2227 }
2228 }
2229 }
2230 let resolved_fill_color = fill
2231 .as_ref()
2232 .filter(|fill| fill.fill_type != "none")
2233 .and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
2234 let resolved_outline_color = outline
2235 .as_ref()
2236 .and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
2237 Ok(ShapeSnapshot {
2238 id: shape_id,
2239 source_id: base.id,
2240 kind,
2241 name: base.name.clone(),
2242 x: baseline_integer("x", transform.x)?,
2243 y: baseline_integer("y", transform.y)?,
2244 width: baseline_integer("width", transform.width)?,
2245 height: baseline_integer("height", transform.height)?,
2246 rotation_deg: if transform.rotation_deg.is_finite() {
2247 transform.rotation_deg
2248 } else {
2249 0.0
2250 },
2251 flip_h: transform.flip_h,
2252 flip_v: transform.flip_v,
2253 inherited: None,
2254 hidden: base.hidden,
2255 geometry,
2256 adjust_values,
2257 placeholder: baseline_json_option(&base.placeholder)?,
2258 fill,
2259 resolved_fill_color,
2260 outline,
2261 resolved_outline_color,
2262 media_part_path,
2263 pending_media: None,
2264 blip_effects,
2265 graphic,
2266 text_stories,
2267 children,
2268 })
2269}
2270
2271fn baseline_integer(key: &str, value: i64) -> EditResult<i64> {
2273 let number = value as f64;
2274 if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
2275 return Err(EditError::InvalidState(format!("invalid integer {key}")));
2276 }
2277 Ok(number as i64)
2278}
2279
2280fn baseline_json<T: serde::Serialize + DeserializeOwned>(value: &T) -> EditResult<T> {
2283 let json = serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
2284 serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
2285}
2286
2287fn baseline_json_option<T: serde::Serialize + DeserializeOwned>(
2288 value: &Option<T>,
2289) -> EditResult<Option<T>> {
2290 value.as_ref().map(baseline_json).transpose()
2291}
2292
2293fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
2294 match value {
2295 "shape" => Ok(ShapeKind::Shape),
2296 "picture" => Ok(ShapeKind::Picture),
2297 "graphicFrame" => Ok(ShapeKind::GraphicFrame),
2298 "group" => Ok(ShapeKind::Group),
2299 _ => Err(EditError::InvalidState(format!(
2300 "unknown shape kind {value}"
2301 ))),
2302 }
2303}
2304
2305pub(crate) fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
2306 txn.get_array(SLIDE_ORDER)
2307 .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
2308}
2309
2310pub(crate) fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
2311 txn.get_map(name)
2312 .ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
2313}
2314
2315pub(crate) fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
2316 required_map(txn, SLIDES)?
2317 .get(txn, slide_id)
2318 .and_then(|value| value.cast::<MapRef>().ok())
2319 .ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
2320}
2321
2322pub(crate) fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
2323 required_map(txn, SHAPES)?
2324 .get(txn, shape_id)
2325 .and_then(|value| value.cast::<MapRef>().ok())
2326 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
2327}
2328
2329fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
2330 if required_string(shape, txn, "kind")? == "shape" {
2331 Ok(())
2332 } else {
2333 Err(EditError::InvalidGeometry(
2334 "only preset shapes support shape styling".to_owned(),
2335 ))
2336 }
2337}
2338
2339pub(crate) fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
2340 slide
2341 .get(txn, "shapes")
2342 .and_then(|value| value.cast::<ArrayRef>().ok())
2343 .ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
2344}
2345
2346fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
2347 let slide = slide_ref(txn, slide_id)?;
2348 let order = slide_shape_order(&slide, txn)?;
2349 if array_index(&order, txn, shape_id).is_some() {
2350 Ok(())
2351 } else {
2352 Err(EditError::ShapeNotFound(shape_id.to_owned()))
2353 }
2354}
2355
2356fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
2357 let shapes = required_map(txn, SHAPES)?;
2358 let stories = required_map(txn, STORIES)?;
2359 let mut entries = ShapeEntries::default();
2360 for shape_id in root_shape_ids {
2361 collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
2362 }
2363 for story_id in entries.story_ids {
2364 stories.remove(txn, &story_id);
2365 }
2366 for shape_id in entries.shape_ids {
2367 shapes.remove(txn, &shape_id);
2368 }
2369 Ok(())
2370}
2371
2372#[derive(Default)]
2373struct ShapeEntries {
2374 shape_ids: Vec<String>,
2375 seen_shape_ids: HashSet<String>,
2376 story_ids: Vec<String>,
2377 seen_story_ids: HashSet<String>,
2378}
2379
2380fn collect_shape_entries<T: ReadTxn>(
2381 shapes: &MapRef,
2382 txn: &T,
2383 shape_id: &str,
2384 visiting: &mut HashSet<String>,
2385 entries: &mut ShapeEntries,
2386) -> EditResult<()> {
2387 if visiting.len() >= MAX_SHAPE_DEPTH {
2388 return Err(EditError::InvalidState(format!(
2389 "shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
2390 )));
2391 }
2392 if !visiting.insert(shape_id.to_owned()) {
2393 return Err(EditError::InvalidState(format!(
2394 "shape cycle at {shape_id}"
2395 )));
2396 }
2397 if entries.seen_shape_ids.contains(shape_id) {
2398 visiting.remove(shape_id);
2399 return Ok(());
2400 }
2401 let shape = shapes
2402 .get(txn, shape_id)
2403 .and_then(|value| value.cast::<MapRef>().ok())
2404 .ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
2405 for story_id in map_string_array(&shape, txn, "textStories")? {
2406 if entries.seen_story_ids.insert(story_id.clone()) {
2407 entries.story_ids.push(story_id);
2408 }
2409 }
2410 for child_id in map_string_array(&shape, txn, "children")? {
2411 collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
2412 }
2413 visiting.remove(shape_id);
2414 entries.seen_shape_ids.insert(shape_id.to_owned());
2415 entries.shape_ids.push(shape_id.to_owned());
2416 Ok(())
2417}
2418
2419fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
2420 array
2421 .iter(txn)
2422 .enumerate()
2423 .find(|(_, item)| out_string(item).as_deref() == Some(value))
2424 .map(|(index, _)| index as u32)
2425}
2426
2427pub(crate) fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
2428 array
2429 .iter(txn)
2430 .filter_map(|value| out_string(&value))
2431 .collect()
2432}
2433
2434pub(crate) fn map_string_array<T: ReadTxn>(
2435 map: &MapRef,
2436 txn: &T,
2437 key: &str,
2438) -> EditResult<Vec<String>> {
2439 match map.get(txn, key) {
2440 Some(Out::Any(Any::Array(values))) => Ok(values
2441 .iter()
2442 .filter_map(|value| match value {
2443 Any::String(value) => Some(value.to_string()),
2444 _ => None,
2445 })
2446 .collect()),
2447 None => Ok(Vec::new()),
2448 _ => Err(EditError::InvalidState(format!("{key} is not an array"))),
2449 }
2450}
2451
2452fn string_array(values: &[String]) -> Any {
2453 Any::Array(Arc::from(
2454 values
2455 .iter()
2456 .map(|value| Any::from(value.as_str()))
2457 .collect::<Vec<_>>(),
2458 ))
2459}
2460
2461fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
2462 Ok(ShapeRect {
2463 x: required_i64(shape, txn, "x")?,
2464 y: required_i64(shape, txn, "y")?,
2465 width: required_i64(shape, txn, "width")?,
2466 height: required_i64(shape, txn, "height")?,
2467 })
2468}
2469
2470fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
2471 let value = required_i64(map, txn, key)?;
2472 u32::try_from(value).map_err(|_| {
2473 EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
2474 })
2475}
2476
2477pub(crate) fn validate_rect(rect: ShapeRect) -> EditResult<()> {
2478 validate_coordinate(rect.x)?;
2479 validate_coordinate(rect.y)?;
2480 if rect.width <= 0 || rect.height <= 0 {
2481 return Err(EditError::InvalidGeometry(
2482 "shape width and height must be positive".to_owned(),
2483 ));
2484 }
2485 validate_coordinate(rect.width)?;
2486 validate_coordinate(rect.height)
2487}
2488
2489fn validate_coordinate(value: i64) -> EditResult<()> {
2490 if value.unsigned_abs() > MAX_GEOMETRY as u64 {
2491 return Err(EditError::InvalidGeometry(format!(
2492 "coordinate {value} exceeds the safe range"
2493 )));
2494 }
2495 Ok(())
2496}
2497
2498fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
2499 if adjustments.len() > MAX_ADJUSTMENTS {
2500 return Err(EditError::InvalidAdjustment(format!(
2501 "at most {MAX_ADJUSTMENTS} values are allowed"
2502 )));
2503 }
2504 for (name, value) in adjustments {
2505 if !valid_adjustment_name(name) {
2506 return Err(EditError::InvalidAdjustment(format!(
2507 "unrecognized guide name {name:?}"
2508 )));
2509 }
2510 if !value.is_finite() {
2511 return Err(EditError::InvalidAdjustment(format!(
2512 "guide {name:?} must be finite"
2513 )));
2514 }
2515 }
2516 Ok(())
2517}
2518
2519fn valid_adjustment_name(name: &str) -> bool {
2520 if name == "adj" {
2521 return true;
2522 }
2523 let Some(index) = name.strip_prefix("adj") else {
2524 return false;
2525 };
2526 index
2527 .parse::<usize>()
2528 .ok()
2529 .filter(|value| value.to_string() == index)
2530 .is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
2531}
2532
2533pub(crate) fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
2534 Ok(match color {
2535 Some(color) => ShapeFill {
2536 fill_type: "solid".to_owned(),
2537 color: Some(color_value(color)?),
2538 gradient: None,
2539 },
2540 None => ShapeFill::named("none"),
2541 })
2542}
2543
2544pub(crate) fn stroked_outline(
2546 existing: Option<ShapeOutline>,
2547 stroke: &ShapeStroke,
2548) -> EditResult<ShapeOutline> {
2549 let color = stroke.color.as_deref().map(color_value).transpose()?;
2550 if let Some(width) = stroke.width_pt
2551 && (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
2552 {
2553 return Err(EditError::InvalidGeometry(format!(
2554 "stroke width {width}pt is outside the safe range"
2555 )));
2556 }
2557 if stroke.color.is_none() && stroke.width_pt.is_none() {
2558 return Ok(ShapeOutline::default());
2559 }
2560 let mut outline = existing.unwrap_or_default();
2561 if let Some(color) = color {
2562 if outline.width.is_none() {
2563 outline.width = Some(EMU_PER_POINT);
2564 }
2565 outline.color = Some(color);
2566 outline.gradient = None;
2567 } else if outline.color.is_none() && outline.gradient.is_none() {
2568 outline.color = Some(color_value("#000000")?);
2569 }
2570 if let Some(width) = stroke.width_pt {
2571 outline.width = Some(width * EMU_PER_POINT);
2572 }
2573 Ok(outline)
2574}
2575
2576fn fill_color(fill: &ShapeFill) -> Option<String> {
2577 (fill.fill_type != "none")
2578 .then(|| resolve_color_value_to_hex(fill.color.as_ref()))
2579 .flatten()
2580}
2581
2582fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
2583 Some(ShapeStroke {
2584 color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
2585 width_pt: outline.width.map(|width| width / EMU_PER_POINT),
2586 })
2587}
2588
2589fn color_value(color: &str) -> EditResult<ColorValue> {
2590 let rgb = color.strip_prefix('#').unwrap_or(color);
2591 if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2592 return Err(EditError::InvalidGeometry(format!(
2593 "color {color:?} must be a six-digit hex value"
2594 )));
2595 }
2596 Ok(ColorValue {
2597 rgb: Some(rgb.to_ascii_uppercase()),
2598 ..ColorValue::default()
2599 })
2600}
2601
2602fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
2603 map_string(map, txn, key)
2604 .ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
2605}
2606
2607pub(crate) fn graphic_json<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<Option<Arc<str>>> {
2609 Ok(match shape_ref(txn, shape_id)?.get(txn, "graphicJson") {
2610 Some(Out::Any(Any::String(json))) => Some(json),
2611 _ => None,
2612 })
2613}
2614
2615pub(crate) fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
2616 map.get(txn, key).and_then(|value| out_string(&value))
2617}
2618
2619fn out_string(value: &Out) -> Option<String> {
2620 match value {
2621 Out::Any(Any::String(value)) => Some(value.to_string()),
2622 _ => None,
2623 }
2624}
2625
2626fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
2627 let number = map_number(map, txn, key)
2628 .ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
2629 if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
2630 return Err(EditError::InvalidState(format!("invalid integer {key}")));
2631 }
2632 Ok(number as i64)
2633}
2634
2635pub(crate) fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
2636 match map.get(txn, key) {
2637 Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
2638 Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
2639 _ => None,
2640 }
2641}
2642
2643pub(crate) fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
2644 match map.get(txn, key) {
2645 Some(Out::Any(Any::Bool(value))) => Some(value),
2646 _ => None,
2647 }
2648}
2649
2650fn optional_json<T: DeserializeOwned, R: ReadTxn>(
2651 map: &MapRef,
2652 txn: &R,
2653 key: &str,
2654) -> EditResult<Option<T>> {
2655 map_string(map, txn, key)
2656 .map(|json| {
2657 serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
2658 })
2659 .transpose()
2660}
2661
2662#[cfg(test)]
2663mod tests {
2664 use super::*;
2665 use crate::TextStyle;
2666
2667 const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
2668 const HIDDEN_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/hidden-shapes.pptx");
2669
2670 #[test]
2671 fn a_reattached_source_restores_the_series_lines_a_stored_package_lacks() {
2672 let source = ChartSpace {
2673 plot_groups: vec![pptx_parse::ChartPlotGroup {
2674 series: vec![pptx_parse::ChartSeries {
2675 line: Some(ooxml_drawingml::chart::ChartLine {
2676 none: false,
2677 color: Some("#FFFFFF".to_owned()),
2678 width_emu: Some(41275.0),
2679 }),
2680 ..pptx_parse::ChartSeries::default()
2681 }],
2682 ..pptx_parse::ChartPlotGroup::default()
2683 }],
2684 ..ChartSpace::default()
2685 };
2686 let mut target = source.clone();
2687 target.plot_groups[0].series[0].line = None;
2688
2689 assert!(merge_source_chart_properties(&mut target, &source));
2690 assert_eq!(target, source);
2691 assert!(!merge_source_chart_properties(&mut target, &source));
2692 }
2693
2694 #[test]
2695 fn a_reattached_source_restores_the_right_margin_a_stored_package_lacks() {
2696 let source = pptx_parse::ParagraphProperties {
2697 margin_right: Some(914_400),
2698 ..pptx_parse::ParagraphProperties::default()
2699 };
2700 let mut target = pptx_parse::ParagraphProperties::default();
2701
2702 assert!(merge_source_paragraph_properties(&mut target, &source));
2703 assert_eq!(target.margin_right, Some(914_400));
2704 assert!(!merge_source_paragraph_properties(&mut target, &source));
2705 }
2706
2707 #[test]
2708 fn a_reattached_source_restores_the_paragraph_spacing_a_stored_package_lacks() {
2709 let source = pptx_parse::ParagraphProperties {
2710 space_before: Some(pptx_parse::LineSpacing::Points { value: 10.0 }),
2711 space_after: Some(pptx_parse::LineSpacing::Percent { value: 0.2 }),
2712 ..pptx_parse::ParagraphProperties::default()
2713 };
2714 let mut target = pptx_parse::ParagraphProperties::default();
2715
2716 assert!(merge_source_paragraph_properties(&mut target, &source));
2717 assert_eq!(target.space_before, source.space_before);
2718 assert_eq!(target.space_after, source.space_after);
2719 assert!(!merge_source_paragraph_properties(&mut target, &source));
2720 }
2721
2722 #[test]
2723 fn attaching_a_source_refreshes_chart_text_a_stored_package_never_carried() {
2724 use ooxml_drawingml::chart::{ChartLegend, ChartTextProperties};
2725
2726 let styled = ChartTextProperties {
2727 spacing_pt: Some(3.0),
2728 ..ChartTextProperties::default()
2729 };
2730 let source = ChartSpace {
2731 text: Some(styled.clone()),
2732 title_text: Some(styled.clone()),
2733 legend: Some(ChartLegend {
2734 visible: true,
2735 text: Some(styled.clone()),
2736 ..ChartLegend::default()
2737 }),
2738 axis_list: Some(vec![ChartAxis {
2739 text: Some(styled.clone()),
2740 ..ChartAxis::default()
2741 }]),
2742 ..ChartSpace::default()
2743 };
2744 let mut target = ChartSpace {
2745 legend: Some(ChartLegend {
2746 visible: true,
2747 ..ChartLegend::default()
2748 }),
2749 axis_list: Some(vec![ChartAxis::default()]),
2750 ..ChartSpace::default()
2751 };
2752 assert!(merge_source_chart_properties(&mut target, &source));
2753 assert_eq!(target.text.as_ref(), Some(&styled));
2754 assert_eq!(target.title_text.as_ref(), Some(&styled));
2755 assert_eq!(target.legend.unwrap().text.as_ref(), Some(&styled));
2756 assert_eq!(target.axis_list.unwrap()[0].text.as_ref(), Some(&styled));
2757 assert!(!merge_source_chart_properties(&mut source.clone(), &source));
2758 }
2759
2760 #[test]
2761 fn an_unmigratable_schema_version_is_reported_before_package_json() {
2762 let session = DeckSession::open(FIXTURE, 100).unwrap();
2763 {
2764 let mut txn = session.doc.transact_mut();
2765 let meta = required_map(&txn, META).unwrap();
2766 meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
2767 meta.insert(
2768 &mut txn,
2769 "packageJson",
2770 Any::Buffer(Arc::from(b"{}".to_vec())),
2771 );
2772 }
2773
2774 assert!(matches!(
2775 validate_doc(&session.doc),
2776 Err(EditError::InvalidState(message))
2777 if message == "unsupported deck schema version"
2778 ));
2779 assert!(matches!(
2780 DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
2781 Err(EditError::InvalidState(message))
2782 if message == "unsupported deck schema version"
2783 ));
2784 }
2785
2786 #[test]
2787 fn migration_leaves_a_current_document_untouched() {
2788 let session = DeckSession::open(FIXTURE, 102).unwrap();
2789 let before = session.encode_state_as_update_v1();
2790 migrate_doc(&session.doc).unwrap();
2791 assert_eq!(session.encode_state_as_update_v1(), before);
2792 }
2793
2794 #[test]
2795 fn legacy_migrations_commit_once_and_normalise_the_package() {
2796 use std::sync::Mutex;
2797
2798 const V1: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v1.update.bin");
2799 const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-connectors.update.bin");
2800 for update in [V1, V2] {
2801 let doc = crate::doc_with_client_id(920);
2802 crate::hydrate_doc(&doc, update).unwrap();
2803 let before = snapshot_doc(&doc, &package_from_doc(&doc).unwrap()).unwrap();
2804 let observed = Arc::new(Mutex::new(Vec::new()));
2805 let events = observed.clone();
2806 let _subscription = doc
2807 .observe_update_v1(move |txn, _| {
2808 let meta = required_map(txn, META).unwrap();
2809 let Some(Out::Any(Any::Buffer(package))) = meta.get(txn, "packageJson") else {
2810 panic!("missing package data");
2811 };
2812 events
2813 .lock()
2814 .unwrap()
2815 .push((map_number(&meta, txn, "schemaVersion").unwrap(), package));
2816 })
2817 .unwrap();
2818
2819 migrate_doc(&doc).unwrap();
2820
2821 let events = observed.lock().unwrap();
2822 assert_eq!(
2823 events
2824 .iter()
2825 .map(|(version, _)| *version)
2826 .collect::<Vec<_>>(),
2827 [SCHEMA_VERSION]
2828 );
2829 for (_, package) in events.iter() {
2830 let json: serde_json::Value = serde_json::from_slice(package).unwrap();
2831 assert!(json.get("charts").unwrap().is_array());
2832 assert!(json.get("shapeElements").is_none());
2833 assert!(json["presentation"].get("firstSlideNum").is_none());
2834 }
2835 let package = package_from_doc(&doc).unwrap();
2836 assert_eq!(snapshot_doc(&doc, &package).unwrap(), before);
2837 assert!(!package.models_connectors());
2838 assert_eq!(package.presentation.first_slide_num, 1);
2839 }
2840 }
2841
2842 #[test]
2843 fn a_v2_hidden_snapshot_backfills_flags_without_rewriting_the_package() {
2844 use std::sync::Mutex;
2845 use yrs::Update;
2846 use yrs::updates::decoder::Decode;
2847
2848 const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-hidden.update.bin");
2849 let doc = crate::doc_with_client_id(9430);
2850 doc.transact_mut()
2851 .apply_update(Update::decode_v1(V2).unwrap())
2852 .unwrap();
2853 let before = package_from_doc(&doc).unwrap();
2854 let observed = Arc::new(Mutex::new(Vec::new()));
2855 let events = observed.clone();
2856 let _subscription = doc
2857 .observe_update_v1(move |txn, _| {
2858 let meta = required_map(txn, META).unwrap();
2859 let shapes = required_map(txn, SHAPES).unwrap();
2860 let mut hidden: Vec<String> = shapes
2861 .iter(txn)
2862 .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2863 .filter(|(_, shape)| map_bool(shape, txn, "hidden").unwrap_or(false))
2864 .map(|(id, _)| id.to_owned())
2865 .collect();
2866 hidden.sort();
2867 events.lock().unwrap().push((
2868 map_number(&meta, txn, "schemaVersion").unwrap(),
2869 package_from_meta(&meta, txn).unwrap(),
2870 hidden,
2871 ));
2872 })
2873 .unwrap();
2874 migrate_doc(&doc).unwrap();
2875 let events = observed.lock().unwrap();
2876 assert_eq!(
2877 events
2878 .iter()
2879 .map(|(version, _, _)| *version)
2880 .collect::<Vec<_>>(),
2881 [SCHEMA_VERSION]
2882 );
2883 for (_, package, hidden) in events.iter() {
2884 assert_eq!(
2885 serde_json::to_vec(package).unwrap(),
2886 serde_json::to_vec(&before).unwrap()
2887 );
2888 assert_eq!(
2889 hidden,
2890 &[
2891 "slide:0:256:shape:0",
2892 "slide:0:256:shape:8",
2893 "slide:0:256:shape:8.13",
2894 "slide:1:257:shape:16",
2895 ]
2896 );
2897 }
2898 }
2899
2900 #[test]
2904 fn baseline_matches_seeded_doc_snapshot() {
2905 let mut files: Vec<Vec<u8>> = vec![
2906 FIXTURE.to_vec(),
2907 HIDDEN_FIXTURE.to_vec(),
2908 include_bytes!("../tests/fixtures/blip-shadow.pptx").to_vec(),
2909 include_bytes!("../tests/fixtures/chart-text-overflow.pptx").to_vec(),
2910 include_bytes!("../tests/fixtures/deck-schema-v2-connectors.pptx").to_vec(),
2911 include_bytes!("../tests/fixtures/deck-schema-v2-nested-connectors.pptx").to_vec(),
2912 include_bytes!("../tests/fixtures/metafile-tracking.pptx").to_vec(),
2913 include_bytes!("../tests/fixtures/modern-comments.pptx").to_vec(),
2914 include_bytes!("../tests/fixtures/run-spacing-shadow.pptx").to_vec(),
2915 ];
2916 if let Ok(dir) = std::env::var("PPTX_DIR") {
2917 let mut extra: Vec<_> = std::fs::read_dir(dir)
2918 .unwrap()
2919 .filter_map(|e| e.ok().map(|e| e.path()))
2920 .filter(|p| p.extension().is_some_and(|e| e == "pptx"))
2921 .collect();
2922 extra.sort();
2923 files.extend(extra.iter().map(|path| std::fs::read(path).unwrap()));
2924 }
2925 for (index, bytes) in files.iter().enumerate() {
2926 let package = pptx_parse::parse_pptx(bytes).unwrap();
2927 let doc = crate::doc_with_client_id(crate::BOOTSTRAP_CLIENT_ID);
2928 seed_doc(&doc, &package, "").unwrap();
2929 let seeded = snapshot_doc(&doc, &package).unwrap();
2930 let direct = baseline_snapshot(&package).unwrap();
2931 assert_eq!(seeded, direct, "baseline snapshot differs for file {index}");
2932 }
2933 }
2934
2935 #[test]
2936 fn seeding_stores_hidden_only_for_hidden_shapes() {
2937 assert!(hidden_keys(&DeckSession::open(FIXTURE, 103).unwrap()).is_empty());
2938
2939 let session = DeckSession::open(HIDDEN_FIXTURE, 104).unwrap();
2940 assert_eq!(
2941 hidden_keys(&session),
2942 [
2943 "slide:0:256:shape:0",
2944 "slide:0:256:shape:8",
2945 "slide:0:256:shape:8.13",
2946 "slide:1:257:shape:16",
2947 "slide:1:257:shape:4",
2948 ]
2949 );
2950 let snapshot = session.snapshot().unwrap();
2951 let group = &snapshot.slides[0].shapes[8];
2952 assert!(snapshot.slides[0].shapes[0].hidden);
2953 assert!(group.hidden);
2954 assert_eq!(group.children.len(), 14);
2955 assert!(group.children[..13].iter().all(|child| !child.hidden));
2956 assert!(group.children[13].hidden);
2957 }
2958
2959 #[test]
2960 fn hidden_flags_serialise_sparsely_and_round_trip() {
2961 let snapshot = DeckSession::open(HIDDEN_FIXTURE, 106)
2962 .unwrap()
2963 .snapshot()
2964 .unwrap();
2965 let json = serde_json::to_string(&snapshot).unwrap();
2966 assert_eq!(json.matches("\"hidden\":true").count(), 5);
2967 assert!(!json.contains("\"hidden\":false"));
2968 assert_eq!(
2969 serde_json::from_str::<DeckSnapshot>(&json).unwrap(),
2970 snapshot
2971 );
2972 }
2973
2974 fn hidden_keys(session: &DeckSession) -> Vec<String> {
2975 let txn = session.doc.transact();
2976 let shapes = required_map(&txn, SHAPES).unwrap();
2977 let mut ids: Vec<String> = shapes
2978 .iter(&txn)
2979 .filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
2980 .filter(|(_, shape)| shape.get(&txn, "hidden").is_some())
2981 .map(|(id, _)| id.to_owned())
2982 .collect();
2983 ids.sort();
2984 ids
2985 }
2986
2987 #[test]
2988 fn delete_operations_remove_owned_map_entries() {
2989 let session = DeckSession::open(FIXTURE, 101).unwrap();
2990 let context = EditCtx::local("test");
2991 let baseline = map_lengths(&session);
2992
2993 for cycle in 0..3 {
2994 let index = session.snapshot().unwrap().slides.len() as u32;
2995 let slide = session.insert_slide(&context, index, None).unwrap();
2996 let first = session
2997 .add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
2998 .unwrap();
2999 session
3000 .add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
3001 .unwrap();
3002 assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
3003
3004 session
3005 .remove_shape(&context, &slide.slide_id, &first.shape_id)
3006 .unwrap();
3007 assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
3008
3009 session.delete_slide(&context, &slide.slide_id).unwrap();
3010 assert_eq!(map_lengths(&session), baseline);
3011 }
3012 }
3013
3014 #[test]
3015 fn deleting_seeded_slide_cascades_descendant_entries() {
3016 let session = DeckSession::open(FIXTURE, 202).unwrap();
3017 let slide = session.snapshot().unwrap().slides[0].clone();
3018 let before = map_lengths(&session);
3019 let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
3020 let next = shape_entry_lengths(shape);
3021 (total.0 + next.0, total.1 + next.1)
3022 });
3023
3024 session
3025 .delete_slide(&EditCtx::local("test"), &slide.id)
3026 .unwrap();
3027
3028 assert!(owned.0 > slide.shapes.len() as u32);
3029 assert_eq!(
3030 map_lengths(&session),
3031 (before.0 - 1, before.1 - owned.0, before.2 - owned.1)
3032 );
3033 }
3034
3035 fn draft(cycle: u32, index: u32) -> ShapeDraft {
3036 ShapeDraft {
3037 name: format!("Shape {cycle}:{index}"),
3038 rect: ShapeRect {
3039 x: 100_000,
3040 y: 100_000,
3041 width: 1_000_000,
3042 height: 500_000,
3043 },
3044 text: "Text".to_owned(),
3045 style: TextStyle::default(),
3046 }
3047 }
3048
3049 fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
3050 let txn = session.doc.transact();
3051 (
3052 required_map(&txn, SLIDES).unwrap().len(&txn),
3053 required_map(&txn, SHAPES).unwrap().len(&txn),
3054 required_map(&txn, STORIES).unwrap().len(&txn),
3055 )
3056 }
3057
3058 fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
3059 shape.children.iter().fold(
3060 (1, shape.text_stories.len() as u32),
3061 |(shape_count, story_count), child| {
3062 let child_counts = shape_entry_lengths(child);
3063 (shape_count + child_counts.0, story_count + child_counts.1)
3064 },
3065 )
3066 }
3067
3068 fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
3069 (left.0 + right.0, left.1 + right.1, left.2 + right.2)
3070 }
3071
3072 #[test]
3073 fn a_v2_comment_snapshot_records_its_flavour_while_deferring_the_import() {
3074 use std::sync::Mutex;
3075 use yrs::Update;
3076 use yrs::updates::decoder::Decode;
3077
3078 const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-comments.update.bin");
3079 let doc = crate::doc_with_client_id(9620);
3080 doc.transact_mut()
3081 .apply_update(Update::decode_v1(V2).unwrap())
3082 .unwrap();
3083 let before = {
3084 let txn = doc.transact();
3085 let meta = required_map(&txn, META).unwrap();
3086 assert_eq!(map_number(&meta, &txn, "schemaVersion"), Some(2.0));
3087 assert!(meta.get(&txn, "commentFlavor").is_none());
3088 package_from_meta(&meta, &txn).unwrap()
3089 };
3090 let observed = Arc::new(Mutex::new(Vec::new()));
3091 let events = observed.clone();
3092 let _subscription = doc
3093 .observe_update_v1(move |txn, _| {
3094 let meta = required_map(txn, META).unwrap();
3095 events.lock().unwrap().push((
3096 map_number(&meta, txn, "schemaVersion").unwrap(),
3097 package_from_meta(&meta, txn).unwrap(),
3098 map_string(&meta, txn, "commentFlavor"),
3099 map_bool(&meta, txn, "commentsPendingSource"),
3100 ));
3101 })
3102 .unwrap();
3103 migrate_doc(&doc).unwrap();
3104 let events = observed.lock().unwrap();
3105 assert_eq!(
3106 *events,
3107 [(
3108 SCHEMA_VERSION,
3109 before,
3110 Some("legacy".to_owned()),
3111 Some(true)
3112 )]
3113 );
3114 }
3115}