1use std::collections::HashMap;
5
6use ooxml_drawingml::{ColorValue, ShapeFill};
7use pptx_parse::{
8 Bullet, CommentAuthorWrite, CommentFlavor, CommentSlide, CommentWrite, CommentsWrite,
9 DeckWrite, InheritedTransform, NotesWrite, ParagraphWrite, Placeholder, PptxPackage,
10 RunProperties, RunWrite, ShapeAdd, ShapeNode, ShapePatch, ShapeTransform, ShapeWrite,
11 SlideLayout, SlideMaster, SlideWrite, TextTarget, TextWrite,
12};
13
14use crate::comments::{derived_guid, seeded_comment_id};
15use crate::deck::{seed_doc, snapshot_doc};
16use crate::{
17 BOOTSTRAP_CLIENT_ID, CommentSnapshot, DeckSession, DeckSnapshot, EditError, EditResult,
18 ParagraphSnapshot, ShapeKind, ShapeSnapshot, SlideSnapshot, StorySnapshot, TextRunSnapshot,
19 doc_with_client_id,
20};
21
22struct SlideContext<'a> {
24 layout: Option<&'a SlideLayout>,
25 master: Option<&'a SlideMaster>,
26 source_shapes: &'a [ShapeNode],
27}
28
29impl<'a> SlideContext<'a> {
30 fn new(package: &'a PptxPackage, snapshot: &SlideSnapshot) -> Self {
31 let source_shapes = snapshot
32 .source_part_path
33 .as_deref()
34 .and_then(|path| package.slides.iter().find(|slide| slide.part_path == path))
35 .map(|slide| slide.shapes.as_slice())
36 .unwrap_or_default();
37 let layout = snapshot
38 .layout_part_path
39 .as_deref()
40 .and_then(|path| {
41 package
42 .layouts
43 .iter()
44 .find(|layout| layout.part_path == path)
45 })
46 .or_else(|| package.layouts.first());
47 let master = layout
48 .and_then(|layout| layout.master_part_path.as_deref())
49 .and_then(|path| {
50 package
51 .masters
52 .iter()
53 .find(|master| master.part_path == path)
54 })
55 .or_else(|| {
56 layout.and_then(|layout| {
57 package.masters.iter().find(|master| {
58 master
59 .layout_part_paths
60 .iter()
61 .any(|path| path == &layout.part_path)
62 })
63 })
64 })
65 .or_else(|| package.masters.first());
66 Self {
67 layout,
68 master,
69 source_shapes,
70 }
71 }
72}
73
74impl DeckSession {
75 pub fn save(&self) -> EditResult<Vec<u8>> {
78 if !self.package.has_parts() {
79 return Err(EditError::Write(
80 "this session carries no source file bytes; open it from the \
81 file, or from the update plus the source file, to save"
82 .to_owned(),
83 ));
84 }
85 let current = self.snapshot()?;
86 let baseline = baseline_snapshot(&self.package)?;
87 if current == baseline {
88 return pptx_parse::write_pptx(&self.package)
89 .map_err(|error| EditError::Write(error.to_string()));
90 }
91 let deck = deck_write(¤t, &baseline, &self.package)?;
92 pptx_parse::write_pptx_with_edits(&self.package, &deck)
93 .map_err(|error| EditError::Write(error.to_string()))
94 }
95}
96
97fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
98 let doc = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
99 seed_doc(&doc, package, "")?;
100 snapshot_doc(&doc, package)
101}
102
103fn deck_write(
104 current: &DeckSnapshot,
105 baseline: &DeckSnapshot,
106 package: &PptxPackage,
107) -> EditResult<DeckWrite> {
108 let baseline_slides: HashMap<&str, &SlideSnapshot> = baseline
109 .slides
110 .iter()
111 .map(|slide| (slide.id.as_str(), slide))
112 .collect();
113 let mut slides = Vec::with_capacity(current.slides.len());
114 for slide in ¤t.slides {
115 let write = match baseline_slides.get(slide.id.as_str()) {
116 Some(base) if *base == slide => SlideWrite::Keep {
117 part_path: source_part_path(slide)?,
118 },
119 Some(base) => SlideWrite::Patch {
120 part_path: source_part_path(slide)?,
121 shapes: {
122 let context = SlideContext::new(package, slide);
123 shape_writes(&slide.shapes, &base.shapes, &context, context.source_shapes)?
124 },
125 },
126 None => SlideWrite::Add {
127 name: slide.name.clone(),
128 layout_part_path: slide.layout_part_path.clone(),
129 shapes: slide
130 .shapes
131 .iter()
132 .map(shape_add)
133 .collect::<EditResult<Vec<_>>>()?,
134 },
135 };
136 slides.push(write);
137 }
138 Ok(DeckWrite {
139 slides,
140 comments: comments_write(current, baseline, package),
141 notes: notes_write(current, baseline),
142 })
143}
144
145fn notes_write(current: &DeckSnapshot, baseline: &DeckSnapshot) -> Option<NotesWrite> {
146 let baseline_notes: HashMap<&str, &str> = baseline
147 .slides
148 .iter()
149 .map(|slide| (slide.id.as_str(), slide.notes.as_str()))
150 .collect();
151 let mut per_slide = Vec::new();
152 for (index, slide) in current.slides.iter().enumerate() {
153 let changed = baseline_notes
154 .get(slide.id.as_str())
155 .is_none_or(|base| *base != slide.notes.as_str());
156 if !changed {
157 continue;
158 }
159 let target = match slide.source_part_path.clone() {
160 Some(part_path) => CommentSlide::Existing(part_path),
161 None => CommentSlide::Added(index),
162 };
163 per_slide.push((target, slide.notes.clone()));
164 }
165 (!per_slide.is_empty()).then_some(NotesWrite { per_slide })
166}
167
168fn comments_write(
169 current: &DeckSnapshot,
170 baseline: &DeckSnapshot,
171 package: &PptxPackage,
172) -> Option<CommentsWrite> {
173 if current.comments == baseline.comments && current.comment_flavor == baseline.comment_flavor {
174 return None;
175 }
176 let baseline_live: Vec<&CommentSnapshot> = baseline
177 .comments
178 .iter()
179 .filter(|comment| {
180 current
181 .slides
182 .iter()
183 .any(|slide| slide.id == comment.slide_id)
184 })
185 .collect();
186 if !current.comments.is_empty()
187 && current.comments.len() < baseline.comments.len()
188 && current.comment_flavor == baseline.comment_flavor
189 && current.comments.iter().eq(baseline_live)
190 {
191 return None;
192 }
193 let flavor = current.comment_flavor;
194 let live: Vec<&CommentSnapshot> = current
195 .comments
196 .iter()
197 .filter(|comment| {
198 current
199 .slides
200 .iter()
201 .any(|slide| slide.id == comment.slide_id)
202 })
203 .collect();
204
205 let source: HashMap<_, _> = package
206 .comments
207 .iter()
208 .enumerate()
209 .filter(|(_, comment)| comment.flavor == flavor)
210 .map(|(index, comment)| (seeded_comment_id(index, &comment.id), comment))
211 .collect();
212 let mut authors: Vec<CommentAuthorWrite> = if live.is_empty() || source.is_empty() {
213 Vec::new()
214 } else {
215 package
216 .comment_authors
217 .iter()
218 .map(|author| CommentAuthorWrite {
219 id: author.id.clone(),
220 name: author.name.clone(),
221 initials: author.initials.clone(),
222 last_index: author.last_index.unwrap_or_default(),
223 color_index: author.color_index.unwrap_or_default(),
224 user_id: author.user_id.clone(),
225 provider_id: author.provider_id.clone(),
226 })
227 .collect()
228 };
229 let mut per_slide: Vec<(CommentSlide, Vec<CommentWrite>)> = current
230 .slides
231 .iter()
232 .enumerate()
233 .map(|(index, slide)| {
234 let target = match slide.source_part_path.clone() {
235 Some(part_path) => CommentSlide::Existing(part_path),
236 None => CommentSlide::Added(index),
237 };
238 (target, Vec::new())
239 })
240 .collect();
241
242 for comment in live.iter().filter(|comment| comment.parent_id.is_none()) {
243 let Some(index) = current
244 .slides
245 .iter()
246 .position(|slide| slide.id == comment.slide_id)
247 else {
248 continue;
249 };
250 let mut write = preserved_comment(&mut authors, flavor, comment, &source);
251 if flavor == CommentFlavor::Modern {
252 for reply in live
253 .iter()
254 .filter(|reply| reply.parent_id.as_deref() == Some(comment.id.as_str()))
255 {
256 write
257 .replies
258 .push(preserved_comment(&mut authors, flavor, reply, &source));
259 }
260 }
261 if let Some((_, slide_comments)) = per_slide.get_mut(index) {
262 slide_comments.push(write);
263 }
264 }
265
266 Some(CommentsWrite {
267 flavor,
268 authors,
269 per_slide: per_slide
270 .into_iter()
271 .enumerate()
272 .filter_map(|(index, entry)| {
273 let slide_id = ¤t.slides[index].id;
274 let unchanged = flavor == baseline.comment_flavor
275 && current
276 .comments
277 .iter()
278 .filter(|comment| &comment.slide_id == slide_id)
279 .eq(baseline
280 .comments
281 .iter()
282 .filter(|comment| &comment.slide_id == slide_id));
283 (!unchanged).then_some(entry)
284 })
285 .collect(),
286 })
287}
288
289fn preserved_comment(
290 authors: &mut Vec<CommentAuthorWrite>,
291 flavor: CommentFlavor,
292 comment: &CommentSnapshot,
293 source: &HashMap<String, &pptx_parse::Comment>,
294) -> CommentWrite {
295 let Some(original) = source.get(&comment.id) else {
296 for author in authors.iter_mut() {
297 author.last_index = source
298 .values()
299 .filter(|item| item.author_id == author.id)
300 .filter_map(|item| item.id.rsplit_once(':')?.1.parse::<u32>().ok())
301 .fold(author.last_index, u32::max);
302 }
303 return charge_author(authors, flavor, comment);
304 };
305 let index = original
306 .id
307 .rsplit_once(':')
308 .and_then(|(_, index)| index.parse().ok())
309 .unwrap_or(0);
310 let mut write = comment_write(comment, original.author_id.clone(), index);
311 write.id = original.id.clone();
312 if comment.resolved == (original.status.as_deref() == Some("resolved")) {
313 write.status = original.status.clone();
314 } else if !comment.resolved {
315 write.status = Some("active".to_owned());
316 }
317 write
318}
319
320fn charge_author(
321 authors: &mut Vec<CommentAuthorWrite>,
322 flavor: CommentFlavor,
323 comment: &CommentSnapshot,
324) -> CommentWrite {
325 let slot = match authors
326 .iter()
327 .position(|known| known.name == comment.author && known.initials == comment.initials)
328 {
329 Some(slot) => slot,
330 None => {
331 let slot = authors.len();
332 let id = match flavor {
333 CommentFlavor::Legacy => authors
334 .iter()
335 .filter_map(|author| author.id.parse::<u32>().ok())
336 .max()
337 .map_or(0, |id| id + 1)
338 .to_string(),
339 CommentFlavor::Modern => {
340 derived_guid(&format!("author:{}:{}", comment.author, comment.initials))
341 }
342 };
343 authors.push(CommentAuthorWrite {
344 id,
345 name: comment.author.clone(),
346 initials: comment.initials.clone(),
347 last_index: 0,
348 color_index: slot as u32,
349 user_id: None,
350 provider_id: None,
351 });
352 slot
353 }
354 };
355 authors[slot].last_index += 1;
356 comment_write(comment, authors[slot].id.clone(), authors[slot].last_index)
357}
358
359fn comment_write(comment: &CommentSnapshot, author_id: String, index: u32) -> CommentWrite {
360 CommentWrite {
361 id: derived_guid(&comment.id),
362 author_id,
363 index,
364 text: comment.text.clone(),
365 created: comment.created.clone(),
366 x_emu: comment.x_emu,
367 y_emu: comment.y_emu,
368 status: comment.resolved.then(|| "resolved".to_owned()),
369 replies: Vec::new(),
370 }
371}
372
373fn source_part_path(slide: &SlideSnapshot) -> EditResult<String> {
374 slide
375 .source_part_path
376 .clone()
377 .ok_or_else(|| EditError::Write(format!("slide {} has no source part", slide.id)))
378}
379
380fn shape_writes(
381 current: &[ShapeSnapshot],
382 baseline: &[ShapeSnapshot],
383 context: &SlideContext<'_>,
384 source: &[ShapeNode],
385) -> EditResult<Vec<ShapeWrite>> {
386 let baseline_shapes: HashMap<&str, &ShapeSnapshot> = baseline
387 .iter()
388 .map(|shape| (shape.id.as_str(), shape))
389 .collect();
390 let mut writes = Vec::with_capacity(current.len());
391 for shape in current {
392 let write = match baseline_shapes.get(shape.id.as_str()) {
393 Some(base) if *base == shape => ShapeWrite::Keep {
394 source_index: addressed_source_index(shape, source)?,
395 },
396 Some(base) => {
397 let index = addressed_source_index(shape, source)?;
398 ShapeWrite::Patch {
399 source_index: index,
400 patch: Box::new(shape_patch(
401 shape,
402 base,
403 context,
404 group_children(source, index),
405 )?),
406 }
407 }
408 None => ShapeWrite::Add(Box::new(shape_add(shape)?)),
409 };
410 writes.push(write);
411 }
412 Ok(writes)
413}
414
415fn addressed_source_index(shape: &ShapeSnapshot, source: &[ShapeNode]) -> EditResult<usize> {
417 let index = source_index(&shape.id)?;
418 match source.get(index) {
419 Some(node) if node.id() == shape.source_id => Ok(index),
420 _ => Err(EditError::Write(format!(
421 "shape {} no longer addresses source shape {}: this update was seeded by a \
422 different parse of the file and has to be re-seeded from it",
423 shape.id, shape.source_id
424 ))),
425 }
426}
427
428fn group_children(source: &[ShapeNode], index: usize) -> &[ShapeNode] {
429 match source.get(index) {
430 Some(ShapeNode::Group(group)) => &group.children,
431 _ => &[],
432 }
433}
434
435fn source_index(shape_id: &str) -> EditResult<usize> {
436 shape_id
437 .rfind(":shape:")
438 .map(|position| &shape_id[position + ":shape:".len()..])
439 .and_then(|path| path.rsplit('.').next())
440 .and_then(|segment| segment.parse().ok())
441 .ok_or_else(|| EditError::Write(format!("shape {shape_id} has no source index")))
442}
443
444fn shape_patch(
445 shape: &ShapeSnapshot,
446 base: &ShapeSnapshot,
447 context: &SlideContext<'_>,
448 source_children: &[ShapeNode],
449) -> EditResult<ShapePatch> {
450 let mut patch = ShapePatch::default();
451 let moved = (shape.x, shape.y) != (base.x, base.y);
452 let resized = (shape.width, shape.height) != (base.width, base.height);
453 if moved || resized {
454 patch.offset = moved.then_some((shape.x, shape.y));
455 patch.extent = resized.then_some((shape.width, shape.height));
456 let source_inherited = base.width <= 0 || base.height <= 0;
457 patch.inherited = source_inherited.then(|| {
458 inherited_transform(shape, context)
459 .map(|transform| InheritedTransform {
460 x: transform.x,
461 y: transform.y,
462 width: transform.width,
463 height: transform.height,
464 rotation_deg: transform.rotation_deg,
465 flip_horizontal: transform.flip_h,
466 flip_vertical: transform.flip_v,
467 })
468 .unwrap_or(InheritedTransform {
469 x: shape.x,
470 y: shape.y,
471 width: shape.width,
472 height: shape.height,
473 rotation_deg: shape.rotation_deg,
474 flip_horizontal: shape.flip_h,
475 flip_vertical: shape.flip_v,
476 })
477 });
478 }
479 if shape.fill != base.fill {
480 patch.fill = Some(
481 shape
482 .fill
483 .clone()
484 .unwrap_or_else(|| ShapeFill::named("none")),
485 );
486 }
487 if shape.outline != base.outline {
488 patch.outline = Some(shape.outline.clone().unwrap_or_default());
489 }
490 if shape.adjust_values != base.adjust_values {
491 patch.adjust_values = Some(shape.adjust_values.clone());
492 }
493 let baseline_stories: HashMap<&str, &StorySnapshot> = base
494 .text_stories
495 .iter()
496 .map(|story| (story.id.as_str(), story))
497 .collect();
498 for story in &shape.text_stories {
499 let base_story = baseline_stories.get(story.id.as_str()).copied();
500 if base_story == Some(story) {
501 continue;
502 }
503 patch.texts.push(TextWrite {
504 target: text_target(&story.id, &shape.id)?,
505 paragraphs: paragraph_writes(story, base_story)?,
506 });
507 }
508 if shape.children != base.children {
509 patch.children = shape_writes(&shape.children, &base.children, context, source_children)?;
510 }
511 Ok(patch)
512}
513
514fn inherited_transform<'a>(
518 shape: &ShapeSnapshot,
519 context: &SlideContext<'a>,
520) -> Option<&'a ShapeTransform> {
521 let layout = shape.placeholder.as_ref().and_then(|placeholder| {
522 context
523 .layout
524 .and_then(|layout| find_placeholder(&layout.shapes, placeholder))
525 });
526 let master = shape.placeholder.as_ref().and_then(|placeholder| {
527 context
528 .master
529 .and_then(|master| find_placeholder(&master.shapes, placeholder))
530 });
531 [layout, master]
532 .into_iter()
533 .flatten()
534 .map(node_transform)
535 .find(|transform| transform.width > 0 && transform.height > 0)
536}
537
538fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
539 for node in nodes {
540 if node_placeholder(node).is_some_and(|value| placeholders_match(value, target)) {
541 return Some(node);
542 }
543 if let ShapeNode::Group(group) = node
544 && let Some(found) = find_placeholder(&group.children, target)
545 {
546 return Some(found);
547 }
548 }
549 None
550}
551
552fn placeholders_match(left: &Placeholder, right: &Placeholder) -> bool {
553 match (left.index, right.index) {
554 (Some(left), Some(right)) => left == right,
555 _ => {
556 normalize_placeholder_type(left.placeholder_type.as_deref())
557 == normalize_placeholder_type(right.placeholder_type.as_deref())
558 }
559 }
560}
561
562fn normalize_placeholder_type(value: Option<&str>) -> &str {
563 match value.unwrap_or("body") {
564 "ctrTitle" => "title",
565 "obj" => "body",
566 value => value,
567 }
568}
569
570fn node_placeholder(node: &ShapeNode) -> Option<&Placeholder> {
571 node_base(node).placeholder.as_ref()
572}
573
574fn node_transform(node: &ShapeNode) -> &ShapeTransform {
575 &node_base(node).transform
576}
577
578fn node_base(node: &ShapeNode) -> &pptx_parse::ShapeBase {
579 match node {
580 ShapeNode::Shape(shape) => &shape.base,
581 ShapeNode::Picture(shape) => &shape.base,
582 ShapeNode::GraphicFrame(shape) => &shape.base,
583 ShapeNode::Group(shape) => &shape.base,
584 }
585}
586
587fn text_target(story_id: &str, shape_id: &str) -> EditResult<TextTarget> {
588 let suffix = story_id
589 .strip_prefix("story:")
590 .and_then(|rest| rest.strip_prefix(shape_id))
591 .and_then(|rest| rest.strip_prefix(':'))
592 .ok_or_else(|| EditError::Write(format!("story {story_id} does not match its shape")))?;
593 if let Some(cell) = suffix.strip_prefix("table:") {
594 let (row, cell) = cell
595 .split_once(':')
596 .and_then(|(row, cell)| Some((row.parse().ok()?, cell.parse().ok()?)))
597 .ok_or_else(|| EditError::Write(format!("story {story_id} has no table address")))?;
598 return Ok(TextTarget::TableCell { row, cell });
599 }
600 Ok(TextTarget::Body)
601}
602
603fn paragraph_writes(
604 story: &StorySnapshot,
605 baseline: Option<&StorySnapshot>,
606) -> EditResult<Vec<ParagraphWrite>> {
607 let baseline_paragraphs: HashMap<&str, &ParagraphSnapshot> = baseline
608 .into_iter()
609 .flat_map(|story| &story.paragraphs)
610 .map(|paragraph| (paragraph.id.as_str(), paragraph))
611 .collect();
612 let prefix = format!("para:{}:", story.id);
613 let mut writes = Vec::with_capacity(story.paragraphs.len());
614 for paragraph in &story.paragraphs {
615 let source_index = paragraph
616 .id
617 .strip_prefix(&prefix)
618 .and_then(|index| index.parse::<usize>().ok());
619 let base = baseline_paragraphs.get(paragraph.id.as_str()).copied();
620 if base == Some(paragraph) && source_index.is_some() {
621 writes.push(ParagraphWrite {
622 source_index,
623 rebuild: false,
624 properties_changed: false,
625 alignment: None,
626 level: 0,
627 bullet: None,
628 runs: Vec::new(),
629 });
630 continue;
631 }
632 let properties_changed = match base {
633 Some(base) => {
634 base.alignment != paragraph.alignment
635 || base.level != paragraph.level
636 || base.bullet_json != paragraph.bullet_json
637 }
638 None => true,
639 };
640 let bullet = paragraph
641 .bullet_json
642 .as_deref()
643 .map(serde_json::from_str::<Bullet>)
644 .transpose()
645 .map_err(|error| EditError::Json(error.to_string()))?;
646 writes.push(ParagraphWrite {
647 source_index,
648 rebuild: true,
649 properties_changed,
650 alignment: paragraph.alignment.clone(),
651 level: paragraph.level,
652 bullet,
653 runs: paragraph.runs.iter().map(run_write).collect(),
654 });
655 }
656 Ok(writes)
657}
658
659fn run_write(run: &TextRunSnapshot) -> RunWrite {
660 RunWrite {
661 text: run.text.clone(),
662 properties: RunProperties {
663 font_size_pt: run.style.font_size_pt,
664 spacing_pt: run.style.spacing_pt,
665 baseline_pct: run.style.baseline_pct,
666 bold: run.style.bold,
667 italic: run.style.italic,
668 underline: run.style.underline.clone(),
669 font_family: run.style.font_family.clone(),
670 color: run.style.color.as_deref().map(color_from_hex),
671 language: None,
672 hyperlink_relationship_id: None,
673 },
674 }
675}
676
677fn color_from_hex(color: &str) -> ColorValue {
678 ColorValue {
679 rgb: Some(
680 color
681 .strip_prefix('#')
682 .unwrap_or(color)
683 .to_ascii_uppercase(),
684 ),
685 ..ColorValue::default()
686 }
687}
688
689fn shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
690 if shape.kind != ShapeKind::Shape {
691 return Err(EditError::Write(format!(
692 "shape {} cannot be written as a new shape",
693 shape.id
694 )));
695 }
696 let paragraphs = shape
697 .text_stories
698 .first()
699 .map(|story| paragraph_writes(story, None))
700 .transpose()?;
701 Ok(ShapeAdd {
702 name: shape.name.clone(),
703 geometry: shape.geometry.clone(),
704 x: shape.x,
705 y: shape.y,
706 width: shape.width,
707 height: shape.height,
708 adjust_values: shape.adjust_values.clone(),
709 fill: shape.fill.clone(),
710 outline: shape.outline.clone(),
711 paragraphs,
712 })
713}