Skip to main content

pptx_edit/
proposal_diff.rs

1use std::collections::{BTreeSet, HashSet};
2
3use ooxml_diff::{DiffKind, DiffLimits, LimitFallback};
4use pptx_parse::PptxPackage;
5use serde::{Deserialize, Serialize};
6use yrs::{ArrayRef, Map, MapRef, ReadTxn, Transact};
7
8use crate::comments::{snapshot_comments, snapshot_flavor};
9use crate::deck::{
10    live_shape_order, live_slide_ids, map_string, map_string_array, required_map, slide_notes,
11    slide_ref, slide_shape_order, snapshot_shape,
12};
13use crate::inherit::{SlideContext, record_inherited};
14use crate::proposals::{apply_edit, shape_text};
15use crate::{
16    DeckSession, DeckSnapshot, EditError, EditResult, Proposal, ProposalChange, ProposalEdit,
17    ProposalResult, SHAPES, SLIDES, ShapeSnapshot, SlideScope, StorySnapshot, TextRunSnapshot,
18    TextStyle,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub enum ProposalTextChangeKind {
24    Insertion,
25    Deletion,
26}
27
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ProposalTextChange {
31    pub story_id: String,
32    pub start: u32,
33    pub end: u32,
34    pub kind: ProposalTextChangeKind,
35}
36
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ProposalDiffPreview {
40    pub proposal: Proposal,
41    pub snapshot: DeckSnapshot,
42    pub text_changes: Vec<ProposalTextChange>,
43}
44
45/// Slide-scoped diff preview: `scope` is the render input, `snapshot` carries that slide.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ProposalSlideDiff {
49    pub proposal: Proposal,
50    pub scope: SlideScope,
51    pub snapshot: DeckSnapshot,
52    pub text_changes: Vec<ProposalTextChange>,
53}
54
55impl DeckSession {
56    /// Slide-scoped `preview_proposal_diff`.
57    pub fn preview_proposal_diff_slide(
58        &self,
59        id: &str,
60        slide_index: usize,
61    ) -> ProposalResult<ProposalSlideDiff> {
62        let mut proposal = self.pending_proposal(id)?;
63        let (targets, before, stale_targets) = {
64            let txn = self.doc.transact();
65            let mut targets = BTreeSet::new();
66            for edit in &proposal.edits {
67                let (slide_id, shape_id) = scoped_target(&txn, edit)?;
68                scoped_capture(&txn, &self.package, &slide_id, shape_id.as_deref())?;
69                targets.insert((slide_id, shape_id));
70            }
71            let stale_targets = scoped_stale_targets(&txn, &self.package, &proposal);
72            let mut before = Vec::with_capacity(targets.len());
73            for (slide_id, shape_id) in &targets {
74                before.push(scoped_capture(
75                    &txn,
76                    &self.package,
77                    slide_id,
78                    shape_id.as_deref(),
79                )?);
80            }
81            (targets, before, stale_targets)
82        };
83        proposal.stale_targets = stale_targets;
84        let preview = self.preview_doc_with_edits(&proposal.edits)?;
85        let (changes, comment_flavor, comments) = {
86            let txn = preview.doc.transact();
87            let mut changes = Vec::with_capacity(targets.len());
88            for ((slide_id, shape_id), (before, old_text)) in targets.iter().zip(before) {
89                let (after, new_text) =
90                    scoped_capture(&txn, &preview.package, slide_id, shape_id.as_deref())?;
91                changes.push(ProposalChange {
92                    slide_id: slide_id.clone(),
93                    shape_id: shape_id.clone(),
94                    before,
95                    after,
96                    old_text,
97                    new_text,
98                });
99            }
100            (changes, snapshot_flavor(&txn)?, snapshot_comments(&txn)?)
101        };
102        proposal.changes = changes;
103        let mut scope = preview.slide_scope(slide_index)?;
104        let mut text_changes = Vec::new();
105        for change in &proposal.changes {
106            let Some(before) = &change.before else {
107                continue;
108            };
109            if change.slide_id == scope.slide.id {
110                let Some(after) = find_shape_mut(&mut scope.slide.shapes, &before.id) else {
111                    continue;
112                };
113                for story in &mut after.text_stories {
114                    if let Some(original) =
115                        before.text_stories.iter().find(|old| old.id == story.id)
116                    {
117                        diff_story(original, story, &mut text_changes);
118                    }
119                }
120            } else if let Some(after) = &change.after {
121                let mut after = after.clone();
122                for story in &mut after.text_stories {
123                    if let Some(original) =
124                        before.text_stories.iter().find(|old| old.id == story.id)
125                    {
126                        diff_story(original, story, &mut text_changes);
127                    }
128                }
129            }
130        }
131        Ok(ProposalSlideDiff {
132            proposal,
133            snapshot: DeckSnapshot {
134                width_emu: scope.width_emu,
135                height_emu: scope.height_emu,
136                slides: vec![scope.slide.clone()],
137                comment_flavor,
138                comments,
139            },
140            scope,
141            text_changes,
142        })
143    }
144
145    /// Hydrates a scratch doc from the live state and applies `edits` — the
146    /// `preview_edits` tail, with target validation left to the caller.
147    fn preview_doc_with_edits(&self, edits: &[ProposalEdit]) -> ProposalResult<DeckSession> {
148        let preview = self.stage()?;
149        for edit in edits {
150            apply_edit(&preview, edit)?;
151        }
152        preview.validated_snapshot()?;
153        Ok(preview)
154    }
155
156    /// Builds a render-only snapshot whose offsets are not editable.
157    pub fn preview_proposal_diff(&self, id: &str) -> ProposalResult<ProposalDiffPreview> {
158        let preview = self.preview_proposal(id)?;
159        let mut snapshot = preview.snapshot;
160        let mut text_changes = Vec::new();
161        for change in &preview.proposal.changes {
162            let Some(before) = &change.before else {
163                continue;
164            };
165            let Some(slide) = snapshot
166                .slides
167                .iter_mut()
168                .find(|slide| slide.id == change.slide_id)
169            else {
170                continue;
171            };
172            let Some(after) = find_shape_mut(&mut slide.shapes, &before.id) else {
173                continue;
174            };
175            for story in &mut after.text_stories {
176                if let Some(original) = before.text_stories.iter().find(|old| old.id == story.id) {
177                    diff_story(original, story, &mut text_changes);
178                }
179            }
180        }
181        Ok(ProposalDiffPreview {
182            proposal: preview.proposal,
183            snapshot,
184            text_changes,
185        })
186    }
187}
188
189fn find_shape_mut<'a>(shapes: &'a mut [ShapeSnapshot], id: &str) -> Option<&'a mut ShapeSnapshot> {
190    for shape in shapes {
191        if shape.id == id {
192            return Some(shape);
193        }
194        if let Some(child) = find_shape_mut(&mut shape.children, id) {
195            return Some(child);
196        }
197    }
198    None
199}
200
201/// `proposals::target` against the doc maps: only story edits need a lookup —
202/// the first slide+shape owning the story in document order.
203fn scoped_target<T: ReadTxn>(txn: &T, edit: &ProposalEdit) -> EditResult<(String, Option<String>)> {
204    match edit {
205        ProposalEdit::ReplaceText { story_id, .. }
206        | ProposalEdit::FormatText { story_id, .. }
207        | ProposalEdit::SetParagraphAlignment { story_id, .. } => story_owner(txn, story_id)?
208            .map(|(slide_id, shape_id)| (slide_id, Some(shape_id)))
209            .ok_or_else(|| EditError::StoryNotFound(story_id.clone())),
210        ProposalEdit::SetShapeRect {
211            slide_id, shape_id, ..
212        }
213        | ProposalEdit::SetShapeFill {
214            slide_id, shape_id, ..
215        }
216        | ProposalEdit::SetShapeStroke {
217            slide_id, shape_id, ..
218        }
219        | ProposalEdit::SetShapeAdjust {
220            slide_id, shape_id, ..
221        } => Ok((slide_id.clone(), Some(shape_id.clone()))),
222        ProposalEdit::SetSlideNotes { slide_id, .. } => Ok((slide_id.clone(), None)),
223    }
224}
225
226/// `proposals::capture` against the doc maps: materializes only the targeted
227/// shape subtree instead of a whole deck snapshot.
228fn scoped_capture<T: ReadTxn>(
229    txn: &T,
230    package: &PptxPackage,
231    slide_id: &str,
232    shape_id: Option<&str>,
233) -> EditResult<(Option<ShapeSnapshot>, String)> {
234    let slide = slide_ref(txn, slide_id)?;
235    match shape_id {
236        Some(shape_id) => {
237            if !shape_in_tree(txn, &slide_shape_order(&slide, txn)?, shape_id)? {
238                return Err(EditError::ShapeNotFound(shape_id.to_owned()));
239            }
240            let source_part_path = map_string(&slide, txn, "sourcePartPath");
241            let layout_part_path = map_string(&slide, txn, "layoutPartPath");
242            let theme = pptx_parse::slide_theme(
243                package,
244                source_part_path.as_deref(),
245                layout_part_path.as_deref(),
246            );
247            let mut shape = snapshot_shape(
248                &required_map(txn, SHAPES)?,
249                &required_map(txn, crate::STORIES)?,
250                txn,
251                shape_id,
252                &mut HashSet::new(),
253                Some(&theme),
254            )?;
255            record_inherited(
256                std::slice::from_mut(&mut shape),
257                &SlideContext::new(
258                    package,
259                    source_part_path.as_deref(),
260                    layout_part_path.as_deref(),
261                ),
262            );
263            let text = shape_text(&shape);
264            Ok((Some(shape), text))
265        }
266        None => Ok((None, slide_notes(&slide, txn, package))),
267    }
268}
269
270fn scoped_stale_targets<T: ReadTxn>(
271    txn: &T,
272    package: &PptxPackage,
273    proposal: &Proposal,
274) -> Vec<String> {
275    proposal
276        .changes
277        .iter()
278        .filter(|change| {
279            scoped_capture(txn, package, &change.slide_id, change.shape_id.as_deref())
280                .map_or(true, |(shape, text)| {
281                    shape != change.before || text != change.old_text
282                })
283        })
284        .map(ProposalChange::key)
285        .collect()
286}
287
288/// The first (slide_id, shape_id) owning `story_id`, walking each slide's
289/// shape tree in document order like `find_story_shape` over a snapshot.
290fn story_owner<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<Option<(String, String)>> {
291    let slides = required_map(txn, SLIDES)?;
292    let shapes = required_map(txn, SHAPES)?;
293    for slide_id in live_slide_ids(txn)? {
294        let slide = slides
295            .get(txn, &slide_id)
296            .and_then(|value| value.cast::<MapRef>().ok())
297            .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
298        let mut pending = live_shape_order(&slide_shape_order(&slide, txn)?, txn)?;
299        pending.reverse();
300        let mut seen_shapes = HashSet::new();
301        while let Some(shape_id) = pending.pop() {
302            if !seen_shapes.insert(shape_id.clone()) {
303                continue;
304            }
305            let shape = shapes
306                .get(txn, &shape_id)
307                .and_then(|value| value.cast::<MapRef>().ok())
308                .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
309            if map_string_array(&shape, txn, "textStories")?
310                .iter()
311                .any(|id| id.as_str() == story_id)
312            {
313                return Ok(Some((slide_id, shape_id)));
314            }
315            let mut children = map_string_array(&shape, txn, "children")?;
316            children.reverse();
317            pending.extend(children);
318        }
319    }
320    Ok(None)
321}
322
323/// Whether `shape_id` is reachable from the slide's shape order — the scoped
324/// equivalent of `find_shape` over a materialized slide.
325fn shape_in_tree<T: ReadTxn>(txn: &T, order: &ArrayRef, shape_id: &str) -> EditResult<bool> {
326    let shapes = required_map(txn, SHAPES)?;
327    let mut pending = live_shape_order(order, txn)?;
328    pending.reverse();
329    let mut seen = HashSet::new();
330    while let Some(id) = pending.pop() {
331        if id == shape_id {
332            return Ok(true);
333        }
334        if !seen.insert(id.clone()) {
335            continue;
336        }
337        let shape = shapes
338            .get(txn, &id)
339            .and_then(|value| value.cast::<MapRef>().ok())
340            .ok_or_else(|| EditError::InvalidState(format!("missing shape {id}")))?;
341        let mut children = map_string_array(&shape, txn, "children")?;
342        children.reverse();
343        pending.extend(children);
344    }
345    Ok(false)
346}
347
348#[derive(Clone, Debug, PartialEq)]
349struct Token {
350    text: String,
351    style: TextStyle,
352}
353
354fn tokens(runs: &[TextRunSnapshot]) -> Vec<Token> {
355    let mut result: Vec<Token> = Vec::new();
356    for run in runs {
357        for ch in run.text.chars() {
358            if let Some(last) = result.last_mut()
359                && last.style == run.style
360                && last.text.ends_with(char::is_whitespace) == ch.is_whitespace()
361            {
362                last.text.push(ch);
363            } else {
364                result.push(Token {
365                    text: ch.to_string(),
366                    style: run.style.clone(),
367                });
368            }
369        }
370    }
371    result
372}
373
374fn diff_story(
375    before: &StorySnapshot,
376    after: &mut StorySnapshot,
377    changes: &mut Vec<ProposalTextChange>,
378) {
379    let mut offset = 0;
380    for paragraph in &mut after.paragraphs {
381        let old = before.paragraphs.iter().find(|old| old.id == paragraph.id);
382        let old = tokens(old.map_or(&[], |paragraph| &paragraph.runs));
383        let new = tokens(&paragraph.runs);
384        let mut runs: Vec<TextRunSnapshot> = Vec::new();
385        for (token, kind) in diff_tokens(&old, &new) {
386            let start = offset;
387            offset += token.text.encode_utf16().count() as u32;
388            let mut style = token.style.clone();
389            if let Some(kind) = kind {
390                style.color = Some(
391                    match kind {
392                        ProposalTextChangeKind::Insertion => "#166534",
393                        ProposalTextChangeKind::Deletion => "#b91c1c",
394                    }
395                    .into(),
396                );
397                if let Some(last) = changes.last_mut()
398                    && last.story_id == after.id
399                    && last.kind == kind
400                    && last.end == start
401                {
402                    last.end = offset;
403                } else {
404                    changes.push(ProposalTextChange {
405                        story_id: after.id.clone(),
406                        start,
407                        end: offset,
408                        kind,
409                    });
410                }
411            }
412            if let Some(last) = runs.last_mut()
413                && last.style == style
414            {
415                last.text.push_str(&token.text);
416            } else {
417                runs.push(TextRunSnapshot {
418                    text: token.text.clone(),
419                    style,
420                });
421            }
422        }
423        paragraph.runs = runs;
424        offset += 1;
425    }
426    after.length = offset;
427}
428
429/// Proposal previews coarsen a middle whose LCS table would exceed this many cells.
430const PREVIEW_LCS_CELLS: usize = 250_000;
431
432fn diff_tokens<'a>(
433    old: &'a [Token],
434    new: &'a [Token],
435) -> Vec<(&'a Token, Option<ProposalTextChangeKind>)> {
436    use ProposalTextChangeKind::{Deletion, Insertion};
437    let limits = DiffLimits::new(usize::MAX, PREVIEW_LCS_CELLS, LimitFallback::ReplaceMiddle);
438    let diff = ooxml_diff::diff_tokens(old, new, limits)
439        .expect("the replace-middle fallback never refuses");
440    let mut result = Vec::with_capacity(old.len().max(new.len()));
441    for hunk in diff.hunks {
442        match hunk.kind {
443            DiffKind::Equal => result.extend(new[hunk.new].iter().map(|token| (token, None))),
444            DiffKind::Delete => {
445                result.extend(old[hunk.old].iter().map(|token| (token, Some(Deletion))))
446            }
447            DiffKind::Insert => {
448                result.extend(new[hunk.new].iter().map(|token| (token, Some(Insertion))))
449            }
450        }
451    }
452    result
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    /// The proposal diff as it was before the LCS moved into `ooxml-diff`.
460    fn reference<'a>(
461        old: &'a [Token],
462        new: &'a [Token],
463    ) -> Vec<(&'a Token, Option<ProposalTextChangeKind>)> {
464        use ProposalTextChangeKind::{Deletion, Insertion};
465        let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
466        let suffix = old[prefix..]
467            .iter()
468            .rev()
469            .zip(new[prefix..].iter().rev())
470            .take_while(|(a, b)| a == b)
471            .count();
472        let mut result: Vec<_> = new[..prefix].iter().map(|token| (token, None)).collect();
473        let a = &old[prefix..old.len() - suffix];
474        let b = &new[prefix..new.len() - suffix];
475        if a.len()
476            .saturating_add(1)
477            .saturating_mul(b.len().saturating_add(1))
478            > 250_000
479        {
480            result.extend(a.iter().map(|token| (token, Some(Deletion))));
481            result.extend(b.iter().map(|token| (token, Some(Insertion))));
482        } else {
483            let width = b.len() + 1;
484            let mut lengths = vec![0u32; (a.len() + 1) * width];
485            for i in (0..a.len()).rev() {
486                for j in (0..b.len()).rev() {
487                    lengths[i * width + j] = if a[i] == b[j] {
488                        lengths[(i + 1) * width + j + 1] + 1
489                    } else {
490                        lengths[(i + 1) * width + j].max(lengths[i * width + j + 1])
491                    };
492                }
493            }
494            let (mut i, mut j) = (0, 0);
495            while i < a.len() || j < b.len() {
496                if i < a.len() && j < b.len() && a[i] == b[j] {
497                    result.push((&b[j], None));
498                    i += 1;
499                    j += 1;
500                } else if i < a.len()
501                    && (j == b.len() || lengths[(i + 1) * width + j] >= lengths[i * width + j + 1])
502                {
503                    result.push((&a[i], Some(Deletion)));
504                    i += 1;
505                } else {
506                    result.push((&b[j], Some(Insertion)));
507                    j += 1;
508                }
509            }
510        }
511        result.extend(new[new.len() - suffix..].iter().map(|token| (token, None)));
512        result
513    }
514
515    fn runs(text: &str, bold: bool) -> Vec<TextRunSnapshot> {
516        vec![TextRunSnapshot {
517            text: text.to_owned(),
518            style: TextStyle {
519                bold: Some(bold),
520                ..TextStyle::default()
521            },
522        }]
523    }
524
525    fn assert_parity(old: &[TextRunSnapshot], new: &[TextRunSnapshot]) {
526        let (old, new) = (tokens(old), tokens(new));
527        assert_eq!(diff_tokens(&old, &new), reference(&old, &new));
528    }
529
530    #[test]
531    fn preview_diff_matches_the_previous_lcs() {
532        let cases = [
533            ("", ""),
534            ("same text", "same text"),
535            ("the quick brown fox", "the slow brown fox jumps"),
536            ("ab", "ba"),
537            ("a b a b", "b a b a"),
538            ("x y z", ""),
539            ("", "x y z"),
540            ("one two three", "three two one"),
541            ("tied tied tied", "tied tied"),
542        ];
543        for (old, new) in cases {
544            assert_parity(&runs(old, false), &runs(new, false));
545        }
546        assert_parity(&runs("style only", false), &runs("style only", true));
547    }
548
549    #[test]
550    fn oversized_preview_diffs_still_replace_the_middle() {
551        let words = |prefix: &str| {
552            (0..600)
553                .map(|index| format!("{prefix}{index}"))
554                .collect::<Vec<_>>()
555                .join(" ")
556        };
557        let (old, new) = (
558            format!("start {} end", words("a")),
559            format!("start {} end", words("b")),
560        );
561        assert_parity(&runs(&old, false), &runs(&new, false));
562        let (old_tokens, new_tokens) = (tokens(&runs(&old, false)), tokens(&runs(&new, false)));
563        let diff = diff_tokens(&old_tokens, &new_tokens);
564        let first_insertion = diff
565            .iter()
566            .position(|(_, kind)| *kind == Some(ProposalTextChangeKind::Insertion))
567            .unwrap();
568        assert!(
569            diff[..first_insertion]
570                .iter()
571                .skip(2)
572                .all(|(_, kind)| *kind == Some(ProposalTextChangeKind::Deletion))
573        );
574    }
575}