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