1use std::collections::{BTreeMap, HashMap};
5
6use base64::Engine as _;
7use ooxml_drawingml::{ColorValue, ShapeFill};
8use pptx_parse::{
9 BackgroundWrite, Bullet, CommentAuthorWrite, CommentFlavor, CommentSlide, CommentWrite,
10 CommentsWrite, DeckWrite, InheritedTransform, NotesWrite, ParagraphWrite, PictureAdd,
11 PptxPackage, RunProperties, RunWrite, ShapeAdd, ShapeNode, ShapePatch, ShapeWrite, SlideWrite,
12 TextAutofit, TextTarget, TextWrite,
13};
14
15use crate::comments::{derived_guid, seeded_comment_id};
16use crate::deck::baseline_snapshot;
17use crate::paragraph::{SlideParents, find_placeholder};
18use crate::{
19 CommentSnapshot, DeckSession, DeckSnapshot, EditError, EditResult, ParagraphSnapshot,
20 ShapeKind, ShapeSnapshot, SlideSnapshot, StorySnapshot, TextRunSnapshot,
21};
22
23impl DeckSession {
24 pub fn save(&self) -> EditResult<Vec<u8>> {
27 self.save_with_autofit(&HashMap::new())
28 }
29
30 #[doc(hidden)]
31 pub fn save_with_autofit(&self, autofit: &HashMap<String, TextAutofit>) -> EditResult<Vec<u8>> {
32 self.save_with_autofit_resolver(|_, _| Ok(autofit.clone()))
33 }
34
35 #[doc(hidden)]
36 pub fn save_with_autofit_resolver(
37 &self,
38 resolve: impl FnOnce(&DeckSnapshot, &DeckSnapshot) -> EditResult<HashMap<String, TextAutofit>>,
39 ) -> EditResult<Vec<u8>> {
40 if !self.package.has_parts() {
41 return Err(EditError::Write(
42 "this session carries no source file bytes; open it from the \
43 file, or from the update plus the source file, to save"
44 .to_owned(),
45 ));
46 }
47 let current = self.snapshot()?;
48 if let Some(attribute) =
49 crate::source_run_properties::unrecovered_attribute(&self.doc, ¤t)
50 {
51 return Err(EditError::Write(format!(
52 "run {attribute} could not be recovered from the source file for some \
53 restored text, and saving now would drop it"
54 )));
55 }
56 let baseline = baseline_snapshot(&self.package)?;
57 if current == baseline {
58 return pptx_parse::write_pptx(&self.package)
59 .map_err(|error| EditError::Write(error.to_string()));
60 }
61 let autofit = resolve(¤t, &baseline)?;
62 let deck = deck_write(¤t, &baseline, &self.package, &autofit)?;
63 pptx_parse::write_pptx_with_edits(&self.package, &deck)
64 .map_err(|error| EditError::Write(error.to_string()))
65 }
66}
67
68fn deck_write(
69 current: &DeckSnapshot,
70 baseline: &DeckSnapshot,
71 package: &PptxPackage,
72 autofit: &HashMap<String, TextAutofit>,
73) -> EditResult<DeckWrite> {
74 let baseline_slides: HashMap<&str, &SlideSnapshot> = baseline
75 .slides
76 .iter()
77 .map(|slide| (slide.id.as_str(), slide))
78 .collect();
79 let mut slides = Vec::with_capacity(current.slides.len());
80 for slide in ¤t.slides {
81 let context = SlideParents::resolve(
82 package,
83 slide.source_part_path.as_deref(),
84 slide.layout_part_path.as_deref(),
85 );
86 let write = match baseline_slides.get(slide.id.as_str()) {
87 Some(base) if *base == slide => SlideWrite::Keep {
88 part_path: source_part_path(slide)?,
89 },
90 Some(base) => SlideWrite::Patch {
91 part_path: source_part_path(slide)?,
92 shapes: shape_writes(
93 &slide.shapes,
94 &base.shapes,
95 source_shapes(package, slide),
96 autofit,
97 &context,
98 )?,
99 background: background_write(slide, base),
100 layout_part_path: changed_layout(slide, base),
101 },
102 None if slide.source_part_path.is_some() => {
103 let mut base = baseline
104 .slides
105 .iter()
106 .find(|base| base.source_part_path == slide.source_part_path)
107 .ok_or_else(|| EditError::Write("duplicate source slide is missing".into()))?
108 .clone();
109 let old_id = base.id.clone();
110 for shape in &mut base.shapes {
111 rename_duplicate_shape(shape, &old_id, &slide.id);
112 }
113 SlideWrite::Clone {
114 part_path: source_part_path(slide)?,
115 shapes: shape_writes(
116 &slide.shapes,
117 &base.shapes,
118 source_shapes(package, slide),
119 autofit,
120 &context,
121 )?,
122 background: background_write(slide, &base),
123 layout_part_path: changed_layout(slide, &base),
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 background: slide.background.clone().map(Box::new),
135 },
136 };
137 slides.push(write);
138 }
139 Ok(DeckWrite {
140 slides,
141 comments: comments_write(current, baseline, package),
142 notes: notes_write(current, baseline),
143 })
144}
145
146fn changed_layout(current: &SlideSnapshot, baseline: &SlideSnapshot) -> Option<String> {
147 (current.layout_part_path != baseline.layout_part_path)
148 .then(|| current.layout_part_path.clone())
149 .flatten()
150}
151
152fn rename_duplicate_shape(shape: &mut ShapeSnapshot, old_slide: &str, new_slide: &str) {
153 shape.id = shape.id.replacen(old_slide, new_slide, 1);
154 for story in &mut shape.text_stories {
155 let old_prefix = format!("para:{}:", story.id);
156 story.id = story.id.replacen(old_slide, new_slide, 1);
157 for paragraph in &mut story.paragraphs {
158 if let Some(index) = paragraph.id.strip_prefix(&old_prefix) {
159 paragraph.id = format!("para:{}:{index}", story.id);
160 }
161 }
162 }
163 for child in &mut shape.children {
164 rename_duplicate_shape(child, old_slide, new_slide);
165 }
166}
167
168fn background_write(current: &SlideSnapshot, baseline: &SlideSnapshot) -> Option<BackgroundWrite> {
171 match ¤t.background {
172 Some(fill) if current.background != baseline.background => {
173 Some(BackgroundWrite::Set(Box::new(fill.clone())))
174 }
175 None if current.background_edited || baseline.background.is_some() => {
176 Some(BackgroundWrite::Clear)
177 }
178 _ => None,
179 }
180}
181
182fn notes_write(current: &DeckSnapshot, baseline: &DeckSnapshot) -> Option<NotesWrite> {
183 let baseline_notes: HashMap<&str, &str> = baseline
184 .slides
185 .iter()
186 .map(|slide| (slide.id.as_str(), slide.notes.as_str()))
187 .collect();
188 let mut per_slide = Vec::new();
189 for (index, slide) in current.slides.iter().enumerate() {
190 let changed = baseline_notes
191 .get(slide.id.as_str())
192 .copied()
193 .or_else(|| {
194 slide.source_part_path.as_ref().and_then(|path| {
195 baseline
196 .slides
197 .iter()
198 .find(|base| base.source_part_path.as_ref() == Some(path))
199 .map(|base| base.notes.as_str())
200 })
201 })
202 .is_none_or(|base| base != slide.notes.as_str());
203 if !changed {
204 continue;
205 }
206 let target = match slide
207 .source_part_path
208 .clone()
209 .filter(|_| baseline.slides.iter().any(|base| base.id == slide.id))
210 {
211 Some(part_path) => CommentSlide::Existing(part_path),
212 None => CommentSlide::Added(index),
213 };
214 per_slide.push((target, slide.notes.clone()));
215 }
216 (!per_slide.is_empty()).then_some(NotesWrite { per_slide })
217}
218
219fn comments_write(
220 current: &DeckSnapshot,
221 baseline: &DeckSnapshot,
222 package: &PptxPackage,
223) -> Option<CommentsWrite> {
224 if current.comments == baseline.comments && current.comment_flavor == baseline.comment_flavor {
225 return None;
226 }
227 let baseline_live: Vec<&CommentSnapshot> = baseline
228 .comments
229 .iter()
230 .filter(|comment| {
231 current
232 .slides
233 .iter()
234 .any(|slide| slide.id == comment.slide_id)
235 })
236 .collect();
237 if !current.comments.is_empty()
238 && current.comments.len() < baseline.comments.len()
239 && current.comment_flavor == baseline.comment_flavor
240 && current.comments.iter().eq(baseline_live)
241 {
242 return None;
243 }
244 let flavor = current.comment_flavor;
245 let live: Vec<&CommentSnapshot> = current
246 .comments
247 .iter()
248 .filter(|comment| {
249 current
250 .slides
251 .iter()
252 .any(|slide| slide.id == comment.slide_id)
253 })
254 .collect();
255
256 let source: HashMap<_, _> = package
257 .comments
258 .iter()
259 .enumerate()
260 .filter(|(_, comment)| comment.flavor == flavor)
261 .map(|(index, comment)| (seeded_comment_id(index, &comment.id), comment))
262 .collect();
263 let mut authors: Vec<CommentAuthorWrite> = if live.is_empty() || source.is_empty() {
264 Vec::new()
265 } else {
266 package
267 .comment_authors
268 .iter()
269 .map(|author| CommentAuthorWrite {
270 id: author.id.clone(),
271 name: author.name.clone(),
272 initials: author.initials.clone(),
273 last_index: author.last_index.unwrap_or_default(),
274 color_index: author.color_index.unwrap_or_default(),
275 user_id: author.user_id.clone(),
276 provider_id: author.provider_id.clone(),
277 })
278 .collect()
279 };
280 let mut per_slide: Vec<(CommentSlide, Vec<CommentWrite>)> = current
281 .slides
282 .iter()
283 .enumerate()
284 .map(|(index, slide)| {
285 let target = match slide
286 .source_part_path
287 .clone()
288 .filter(|_| baseline.slides.iter().any(|base| base.id == slide.id))
289 {
290 Some(part_path) => CommentSlide::Existing(part_path),
291 None => CommentSlide::Added(index),
292 };
293 (target, Vec::new())
294 })
295 .collect();
296
297 for comment in live.iter().filter(|comment| comment.parent_id.is_none()) {
298 let Some(index) = current
299 .slides
300 .iter()
301 .position(|slide| slide.id == comment.slide_id)
302 else {
303 continue;
304 };
305 let mut write = preserved_comment(&mut authors, flavor, comment, &source);
306 for reply in live
307 .iter()
308 .filter(|reply| reply.parent_id.as_deref() == Some(comment.id.as_str()))
309 {
310 write
311 .replies
312 .push(preserved_comment(&mut authors, flavor, reply, &source));
313 }
314 if let Some((_, slide_comments)) = per_slide.get_mut(index) {
315 slide_comments.push(write);
316 }
317 }
318
319 Some(CommentsWrite {
320 flavor,
321 authors,
322 per_slide: per_slide
323 .into_iter()
324 .enumerate()
325 .filter_map(|(index, entry)| {
326 let slide_id = ¤t.slides[index].id;
327 let unchanged = flavor == baseline.comment_flavor
328 && current
329 .comments
330 .iter()
331 .filter(|comment| &comment.slide_id == slide_id)
332 .eq(baseline
333 .comments
334 .iter()
335 .filter(|comment| &comment.slide_id == slide_id));
336 (!unchanged).then_some(entry)
337 })
338 .collect(),
339 })
340}
341
342fn preserved_comment(
343 authors: &mut Vec<CommentAuthorWrite>,
344 flavor: CommentFlavor,
345 comment: &CommentSnapshot,
346 source: &HashMap<String, &pptx_parse::Comment>,
347) -> CommentWrite {
348 let Some(original) = source.get(&comment.id) else {
349 for author in authors.iter_mut() {
350 author.last_index = source
351 .values()
352 .filter(|item| item.author_id == author.id)
353 .filter_map(|item| item.id.rsplit_once(':')?.1.parse::<u32>().ok())
354 .fold(author.last_index, u32::max);
355 }
356 return charge_author(authors, flavor, comment);
357 };
358 let index = original
359 .id
360 .rsplit_once(':')
361 .and_then(|(_, index)| index.parse().ok())
362 .unwrap_or(0);
363 let mut write = comment_write(comment, original.author_id.clone(), index);
364 write.id = original.id.clone();
365 if comment.resolved == (original.status.as_deref() == Some("resolved")) {
366 write.status = original.status.clone();
367 } else if !comment.resolved {
368 write.status = Some("active".to_owned());
369 }
370 write
371}
372
373fn charge_author(
374 authors: &mut Vec<CommentAuthorWrite>,
375 flavor: CommentFlavor,
376 comment: &CommentSnapshot,
377) -> CommentWrite {
378 let slot = match authors
379 .iter()
380 .position(|known| known.name == comment.author && known.initials == comment.initials)
381 {
382 Some(slot) => slot,
383 None => {
384 let slot = authors.len();
385 let id = match flavor {
386 CommentFlavor::Legacy => authors
387 .iter()
388 .filter_map(|author| author.id.parse::<u32>().ok())
389 .max()
390 .map_or(0, |id| id + 1)
391 .to_string(),
392 CommentFlavor::Modern => {
393 derived_guid(&format!("author:{}:{}", comment.author, comment.initials))
394 }
395 };
396 authors.push(CommentAuthorWrite {
397 id,
398 name: comment.author.clone(),
399 initials: comment.initials.clone(),
400 last_index: 0,
401 color_index: slot as u32,
402 user_id: None,
403 provider_id: None,
404 });
405 slot
406 }
407 };
408 authors[slot].last_index += 1;
409 comment_write(comment, authors[slot].id.clone(), authors[slot].last_index)
410}
411
412fn comment_write(comment: &CommentSnapshot, author_id: String, index: u32) -> CommentWrite {
413 CommentWrite {
414 id: derived_guid(&comment.id),
415 author_id,
416 index,
417 text: comment.text.clone(),
418 created: comment.created.clone(),
419 x_emu: comment.x_emu,
420 y_emu: comment.y_emu,
421 status: comment.resolved.then(|| "resolved".to_owned()),
422 replies: Vec::new(),
423 }
424}
425
426fn source_part_path(slide: &SlideSnapshot) -> EditResult<String> {
427 slide
428 .source_part_path
429 .clone()
430 .ok_or_else(|| EditError::Write(format!("slide {} has no source part", slide.id)))
431}
432
433fn source_shapes<'a>(package: &'a PptxPackage, slide: &SlideSnapshot) -> &'a [ShapeNode] {
434 slide
435 .source_part_path
436 .as_deref()
437 .and_then(|path| {
438 package
439 .slides
440 .iter()
441 .find(|source| source.part_path == path)
442 })
443 .map(|source| source.shapes.as_slice())
444 .unwrap_or_default()
445}
446
447fn shape_writes(
448 current: &[ShapeSnapshot],
449 baseline: &[ShapeSnapshot],
450 source: &[ShapeNode],
451 autofit: &HashMap<String, TextAutofit>,
452 context: &SlideParents<'_>,
453) -> EditResult<Vec<ShapeWrite>> {
454 let baseline_shapes: HashMap<&str, &ShapeSnapshot> = baseline
455 .iter()
456 .map(|shape| (shape.id.as_str(), shape))
457 .collect();
458 let mut writes = Vec::with_capacity(current.len());
459 for shape in current {
460 let write = match baseline_shapes.get(shape.id.as_str()) {
461 Some(base) if *base == shape => ShapeWrite::Keep {
462 source_index: addressed_source_index(shape, source)?,
463 },
464 Some(base) => {
465 let index = addressed_source_index(shape, source)?;
466 ShapeWrite::Patch {
467 source_index: index,
468 patch: Box::new(shape_patch(
469 shape,
470 base,
471 &source[index],
472 group_children(source, index),
473 autofit,
474 context,
475 )?),
476 }
477 }
478 None => ShapeWrite::Add(Box::new(shape_add(shape)?)),
479 };
480 writes.push(write);
481 }
482 Ok(writes)
483}
484
485fn addressed_source_index(shape: &ShapeSnapshot, source: &[ShapeNode]) -> EditResult<usize> {
487 let index = source_index(&shape.id)?;
488 match source.get(index) {
489 Some(node) if node.id() == shape.source_id => Ok(index),
490 _ => Err(EditError::Write(format!(
491 "shape {} no longer addresses source shape {}: this update was seeded by a \
492 different parse of the file and has to be re-seeded from it",
493 shape.id, shape.source_id
494 ))),
495 }
496}
497
498fn group_children(source: &[ShapeNode], index: usize) -> &[ShapeNode] {
499 match source.get(index) {
500 Some(ShapeNode::Group(group)) => &group.children,
501 _ => &[],
502 }
503}
504
505fn source_index(shape_id: &str) -> EditResult<usize> {
506 shape_id
507 .rfind(":shape:")
508 .map(|position| &shape_id[position + ":shape:".len()..])
509 .and_then(|path| path.rsplit('.').next())
510 .and_then(|segment| segment.parse().ok())
511 .ok_or_else(|| EditError::Write(format!("shape {shape_id} has no source index")))
512}
513
514fn shape_patch(
515 shape: &ShapeSnapshot,
516 base: &ShapeSnapshot,
517 source: &ShapeNode,
518 source_children: &[ShapeNode],
519 autofit: &HashMap<String, TextAutofit>,
520 context: &SlideParents<'_>,
521) -> EditResult<ShapePatch> {
522 let mut patch = ShapePatch::default();
523 if shape.placeholder != base.placeholder {
524 patch.placeholder = shape.placeholder.clone();
525 }
526 let source_inherited = base.width <= 0 || base.height <= 0;
527 let materialized = source_inherited && shape.width > 0 && shape.height > 0;
528 let moved = materialized || (shape.x, shape.y) != (base.x, base.y);
529 let resized = (shape.width, shape.height) != (base.width, base.height);
530 if moved || resized {
531 patch.offset = moved.then_some((shape.x, shape.y));
532 patch.extent = resized.then_some((shape.width, shape.height));
533 patch.materializes = base.inherited.is_some();
534 patch.inherited = source_inherited.then(|| {
535 base.inherited
536 .filter(|_| !materialized)
537 .map(|transform| InheritedTransform {
538 x: transform.x,
539 y: transform.y,
540 width: transform.width,
541 height: transform.height,
542 rotation_deg: transform.rotation_deg,
543 flip_horizontal: transform.flip_h,
544 flip_vertical: transform.flip_v,
545 })
546 .unwrap_or(InheritedTransform {
547 x: shape.x,
548 y: shape.y,
549 width: shape.width,
550 height: shape.height,
551 rotation_deg: shape.rotation_deg,
552 flip_horizontal: shape.flip_h,
553 flip_vertical: shape.flip_v,
554 })
555 });
556 }
557 if shape.fill != base.fill {
558 patch.fill = Some(
559 shape
560 .fill
561 .clone()
562 .unwrap_or_else(|| ShapeFill::named("none")),
563 );
564 }
565 if shape.outline != base.outline {
566 patch.outline = Some(shape.outline.clone().unwrap_or_default());
567 }
568 if shape.adjust_values != base.adjust_values {
569 patch.adjust_values = Some(shape.adjust_values.clone());
570 }
571 patch.table_structure = shape.table_structure.clone();
572 let baseline_stories: HashMap<&str, &StorySnapshot> = base
573 .text_stories
574 .iter()
575 .map(|story| (story.id.as_str(), story))
576 .collect();
577 for (index, story) in shape.text_stories.iter().enumerate() {
578 let mapped_base = shape.table_structure.as_ref().and_then(|structure| {
579 let columns = structure.grid.len();
580 let &[row, cell] = structure
581 .rows
582 .get(index / columns)?
583 .cells
584 .get(index % columns)?;
585 let old = baseline_stories
586 .get(format!("story:{}:table:{row}:{cell}", base.id).as_str())
587 .copied()?;
588 let mut mapped = old.clone();
589 mapped.id.clone_from(&story.id);
590 for paragraph in &mut mapped.paragraphs {
591 paragraph.id = paragraph.id.replacen(
592 &format!("para:{}:", old.id),
593 &format!("para:{}:", story.id),
594 1,
595 );
596 }
597 Some(mapped)
598 });
599 let base_story = mapped_base
600 .as_ref()
601 .or_else(|| baseline_stories.get(story.id.as_str()).copied());
602 if base_story == Some(story) {
603 continue;
604 }
605 let target = text_target(&story.id, &shape.id)?;
606 let owned_autofit = node_autofit(source);
607 let inherited_autofit = inherited_autofit(shape, context);
608 let autofit = autofit
609 .get(&story.id)
610 .filter(|value| {
611 matches!(target, TextTarget::TableCell { .. })
612 || matches!(owned_autofit, Some(TextAutofit::Normal { .. }))
613 || autofit_values(Some(*value)) != autofit_values(inherited_autofit)
614 })
615 .cloned();
616 patch.texts.push(TextWrite {
617 target,
618 autofit,
619 paragraphs: paragraph_writes(story, base_story)?,
620 });
621 }
622 if shape.children != base.children {
623 patch.children = shape_writes(
624 &shape.children,
625 &base.children,
626 source_children,
627 autofit,
628 context,
629 )?;
630 }
631 Ok(patch)
632}
633
634fn node_autofit(node: &ShapeNode) -> Option<&TextAutofit> {
635 match node {
636 ShapeNode::Shape(shape) => shape.text.as_ref()?.autofit.as_ref(),
637 _ => None,
638 }
639}
640
641fn inherited_autofit<'a>(
642 shape: &ShapeSnapshot,
643 context: &SlideParents<'a>,
644) -> Option<&'a TextAutofit> {
645 let placeholder = shape.placeholder.as_ref()?;
646 [
647 context.layout.map(|layout| layout.shapes.as_slice()),
648 context.master.map(|master| master.shapes.as_slice()),
649 ]
650 .into_iter()
651 .flatten()
652 .find_map(|nodes| find_placeholder(nodes, placeholder).and_then(node_autofit))
653}
654
655fn autofit_values(autofit: Option<&TextAutofit>) -> (f64, f64) {
656 match autofit {
657 Some(TextAutofit::Normal {
658 font_scale,
659 line_space_reduction,
660 }) => (
661 font_scale.unwrap_or(1.0),
662 line_space_reduction.unwrap_or(0.0),
663 ),
664 _ => (1.0, 0.0),
665 }
666}
667
668pub(crate) fn text_target(story_id: &str, shape_id: &str) -> EditResult<TextTarget> {
669 let suffix = story_id
670 .strip_prefix("story:")
671 .and_then(|rest| rest.strip_prefix(shape_id))
672 .and_then(|rest| rest.strip_prefix(':'))
673 .ok_or_else(|| EditError::Write(format!("story {story_id} does not match its shape")))?;
674 if let Some(cell) = suffix.strip_prefix("table:") {
675 let (row, cell) = cell
676 .split_once(':')
677 .and_then(|(row, cell)| {
678 Some((row.parse().ok()?, cell.split(':').next()?.parse().ok()?))
679 })
680 .ok_or_else(|| EditError::Write(format!("story {story_id} has no table address")))?;
681 return Ok(TextTarget::TableCell { row, cell });
682 }
683 Ok(TextTarget::Body)
684}
685
686fn paragraph_writes(
687 story: &StorySnapshot,
688 baseline: Option<&StorySnapshot>,
689) -> EditResult<Vec<ParagraphWrite>> {
690 let baseline_paragraphs: HashMap<&str, &ParagraphSnapshot> = baseline
691 .into_iter()
692 .flat_map(|story| &story.paragraphs)
693 .map(|paragraph| (paragraph.id.as_str(), paragraph))
694 .collect();
695 let prefix = format!("para:{}:", story.id);
696 let mut writes = Vec::with_capacity(story.paragraphs.len());
697 for paragraph in &story.paragraphs {
698 let source_index = paragraph
699 .id
700 .strip_prefix(&prefix)
701 .and_then(|index| index.parse::<usize>().ok())
702 .filter(|_| paragraph.preserved_xml.is_none());
703 let base = baseline_paragraphs.get(paragraph.id.as_str()).copied();
704 if base == Some(paragraph) && source_index.is_some() {
705 writes.push(ParagraphWrite {
706 source_index,
707 source_paragraphs: Vec::new(),
708 preserved_xml: None,
709 rebuild: false,
710 properties_changed: false,
711 alignment: None,
712 level: 0,
713 bullet: None,
714 runs: Vec::new(),
715 });
716 continue;
717 }
718 let properties_changed = match base {
719 Some(base) => {
720 base.alignment != paragraph.alignment
721 || base.level != paragraph.level
722 || base.bullet_json != paragraph.bullet_json
723 }
724 None => true,
725 };
726 let bullet = paragraph
727 .bullet_json
728 .as_deref()
729 .map(serde_json::from_str::<Bullet>)
730 .transpose()
731 .map_err(|error| EditError::Json(error.to_string()))?;
732 writes.push(ParagraphWrite {
733 source_index,
734 source_paragraphs: paragraph
735 .source_paragraphs
736 .iter()
737 .map(|source| source.xml.clone())
738 .collect(),
739 preserved_xml: paragraph.preserved_xml.clone(),
740 rebuild: true,
741 properties_changed,
742 alignment: paragraph.alignment.clone(),
743 level: paragraph.level,
744 bullet,
745 runs: paragraph.runs.iter().map(run_write).collect(),
746 });
747 }
748 Ok(writes)
749}
750
751fn run_write(run: &TextRunSnapshot) -> RunWrite {
752 RunWrite {
753 text: run.text.clone(),
754 properties: RunProperties {
755 font_size_pt: run.style.font_size_pt,
756 spacing_pt: run.style.spacing_pt,
757 baseline_pct: run.style.baseline_pct,
758 kern_pt: run.style.kern_pt,
759 bold: run.style.bold,
760 italic: run.style.italic,
761 underline: run.style.underline.clone(),
762 caps: run.style.caps,
763 font_family: run.style.font_family.clone(),
764 color: run.style.color.as_deref().map(color_from_hex),
765 language: None,
766 hyperlink_relationship_id: None,
767 effects: None,
768 },
769 }
770}
771
772fn color_from_hex(color: &str) -> ColorValue {
773 ColorValue {
774 rgb: Some(
775 color
776 .strip_prefix('#')
777 .unwrap_or(color)
778 .to_ascii_uppercase(),
779 ),
780 ..ColorValue::default()
781 }
782}
783
784fn shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
785 if shape.kind == ShapeKind::Picture {
786 return picture_shape_add(shape);
787 }
788 if shape.kind != ShapeKind::Shape {
789 return Err(EditError::Write(format!(
790 "shape {} cannot be written as a new shape",
791 shape.id
792 )));
793 }
794 let paragraphs = shape
795 .text_stories
796 .first()
797 .map(|story| paragraph_writes(story, None))
798 .transpose()?;
799 Ok(ShapeAdd {
800 name: shape.name.clone(),
801 geometry: shape.geometry.clone(),
802 text_box: shape.text_box,
803 x: shape.x,
804 y: shape.y,
805 width: shape.width,
806 height: shape.height,
807 adjust_values: shape.adjust_values.clone(),
808 fill: shape.fill.clone(),
809 outline: shape.outline.clone(),
810 paragraphs,
811 picture: None,
812 })
813}
814
815fn picture_shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
816 let pending = shape.pending_media.as_ref().ok_or_else(|| {
817 EditError::Write(format!(
818 "shape {} is a picture but carries no pending image data",
819 shape.id
820 ))
821 })?;
822 let media_bytes = base64::engine::general_purpose::STANDARD
823 .decode(&pending.base64)
824 .map_err(|error| EditError::Write(format!("invalid pending image data: {error}")))?;
825 Ok(ShapeAdd {
826 name: shape.name.clone(),
827 geometry: "rect".to_owned(),
828 text_box: false,
829 x: shape.x,
830 y: shape.y,
831 width: shape.width,
832 height: shape.height,
833 adjust_values: BTreeMap::new(),
834 fill: None,
835 outline: None,
836 paragraphs: None,
837 picture: Some(PictureAdd {
838 media_bytes,
839 content_type: pending.content_type.clone(),
840 }),
841 })
842}