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