Skip to main content

pptx_edit/
save.rs

1//! Baseline-diff write-back: the live CRDT state is compared against the
2//! package's seeded baseline snapshot and only differences are written.
3
4use 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    /// Serializes the deck with all edits applied. Untouched slides keep their
25    /// exact source part bytes; edited slides are patched at the XML level.
26    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, &current)
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(&current, &baseline)?;
62        let deck = deck_write(&current, &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 &current.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
168/// The background write a slide's snapshot change needs; `None` keeps the
169/// source declaration untouched.
170fn background_write(current: &SlideSnapshot, baseline: &SlideSnapshot) -> Option<BackgroundWrite> {
171    match &current.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 = &current.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
485/// Checks the source identity before resolving an ordinal.
486fn 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}