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};
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    /// Serializes the deck with all edits applied. Untouched slides keep their
26    /// exact source part bytes; edited slides are patched at the XML level.
27    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, &current)
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(&current, &baseline)?;
63        let copy_origins = comment_copy_origins(&self.doc.transact())?;
64        let deck = deck_write(&current, &baseline, &self.package, &autofit, &copy_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 &current.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
171/// The background write a slide's snapshot change needs; `None` keeps the
172/// source declaration untouched.
173fn background_write(current: &SlideSnapshot, baseline: &SlideSnapshot) -> Option<BackgroundWrite> {
174    match &current.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 = &current.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
548/// Checks the source identity before resolving an ordinal.
549fn 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}