Skip to main content

pptx_edit/
proposal_diff.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{
4    DeckSession, DeckSnapshot, Proposal, ProposalResult, ShapeSnapshot, StorySnapshot,
5    TextRunSnapshot, TextStyle,
6};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub enum ProposalTextChangeKind {
11    Insertion,
12    Deletion,
13}
14
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ProposalTextChange {
18    pub story_id: String,
19    pub start: u32,
20    pub end: u32,
21    pub kind: ProposalTextChangeKind,
22}
23
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct ProposalDiffPreview {
27    pub proposal: Proposal,
28    pub snapshot: DeckSnapshot,
29    pub text_changes: Vec<ProposalTextChange>,
30}
31
32impl DeckSession {
33    /// Builds a render-only snapshot whose offsets are not editable.
34    pub fn preview_proposal_diff(&self, id: &str) -> ProposalResult<ProposalDiffPreview> {
35        let preview = self.preview_proposal(id)?;
36        let mut snapshot = preview.snapshot;
37        let mut text_changes = Vec::new();
38        for change in &preview.proposal.changes {
39            let Some(before) = &change.before else {
40                continue;
41            };
42            let Some(slide) = snapshot
43                .slides
44                .iter_mut()
45                .find(|slide| slide.id == change.slide_id)
46            else {
47                continue;
48            };
49            let Some(after) = find_shape_mut(&mut slide.shapes, &before.id) else {
50                continue;
51            };
52            for story in &mut after.text_stories {
53                if let Some(original) = before.text_stories.iter().find(|old| old.id == story.id) {
54                    diff_story(original, story, &mut text_changes);
55                }
56            }
57        }
58        Ok(ProposalDiffPreview {
59            proposal: preview.proposal,
60            snapshot,
61            text_changes,
62        })
63    }
64}
65
66fn find_shape_mut<'a>(shapes: &'a mut [ShapeSnapshot], id: &str) -> Option<&'a mut ShapeSnapshot> {
67    for shape in shapes {
68        if shape.id == id {
69            return Some(shape);
70        }
71        if let Some(child) = find_shape_mut(&mut shape.children, id) {
72            return Some(child);
73        }
74    }
75    None
76}
77
78#[derive(Clone, Debug, PartialEq)]
79struct Token {
80    text: String,
81    style: TextStyle,
82}
83
84fn tokens(runs: &[TextRunSnapshot]) -> Vec<Token> {
85    let mut result: Vec<Token> = Vec::new();
86    for run in runs {
87        for ch in run.text.chars() {
88            if let Some(last) = result.last_mut()
89                && last.style == run.style
90                && last.text.ends_with(char::is_whitespace) == ch.is_whitespace()
91            {
92                last.text.push(ch);
93            } else {
94                result.push(Token {
95                    text: ch.to_string(),
96                    style: run.style.clone(),
97                });
98            }
99        }
100    }
101    result
102}
103
104fn diff_story(
105    before: &StorySnapshot,
106    after: &mut StorySnapshot,
107    changes: &mut Vec<ProposalTextChange>,
108) {
109    let mut offset = 0;
110    for paragraph in &mut after.paragraphs {
111        let old = before.paragraphs.iter().find(|old| old.id == paragraph.id);
112        let old = tokens(old.map_or(&[], |paragraph| &paragraph.runs));
113        let new = tokens(&paragraph.runs);
114        let mut runs: Vec<TextRunSnapshot> = Vec::new();
115        for (token, kind) in diff_tokens(&old, &new) {
116            let start = offset;
117            offset += token.text.encode_utf16().count() as u32;
118            let mut style = token.style.clone();
119            if let Some(kind) = kind {
120                style.color = Some(
121                    match kind {
122                        ProposalTextChangeKind::Insertion => "#166534",
123                        ProposalTextChangeKind::Deletion => "#b91c1c",
124                    }
125                    .into(),
126                );
127                if let Some(last) = changes.last_mut()
128                    && last.story_id == after.id
129                    && last.kind == kind
130                    && last.end == start
131                {
132                    last.end = offset;
133                } else {
134                    changes.push(ProposalTextChange {
135                        story_id: after.id.clone(),
136                        start,
137                        end: offset,
138                        kind,
139                    });
140                }
141            }
142            if let Some(last) = runs.last_mut()
143                && last.style == style
144            {
145                last.text.push_str(&token.text);
146            } else {
147                runs.push(TextRunSnapshot {
148                    text: token.text.clone(),
149                    style,
150                });
151            }
152        }
153        paragraph.runs = runs;
154        offset += 1;
155    }
156    after.length = offset;
157}
158
159fn diff_tokens<'a>(
160    old: &'a [Token],
161    new: &'a [Token],
162) -> Vec<(&'a Token, Option<ProposalTextChangeKind>)> {
163    use ProposalTextChangeKind::{Deletion, Insertion};
164    let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
165    let suffix = old[prefix..]
166        .iter()
167        .rev()
168        .zip(new[prefix..].iter().rev())
169        .take_while(|(a, b)| a == b)
170        .count();
171    let mut result: Vec<_> = new[..prefix].iter().map(|token| (token, None)).collect();
172    let a = &old[prefix..old.len() - suffix];
173    let b = &new[prefix..new.len() - suffix];
174    if a.len()
175        .saturating_add(1)
176        .saturating_mul(b.len().saturating_add(1))
177        > 250_000
178    {
179        result.extend(a.iter().map(|token| (token, Some(Deletion))));
180        result.extend(b.iter().map(|token| (token, Some(Insertion))));
181    } else {
182        let width = b.len() + 1;
183        let mut lengths = vec![0u32; (a.len() + 1) * width];
184        for i in (0..a.len()).rev() {
185            for j in (0..b.len()).rev() {
186                lengths[i * width + j] = if a[i] == b[j] {
187                    lengths[(i + 1) * width + j + 1] + 1
188                } else {
189                    lengths[(i + 1) * width + j].max(lengths[i * width + j + 1])
190                };
191            }
192        }
193        let (mut i, mut j) = (0, 0);
194        while i < a.len() || j < b.len() {
195            if i < a.len() && j < b.len() && a[i] == b[j] {
196                result.push((&b[j], None));
197                i += 1;
198                j += 1;
199            } else if i < a.len()
200                && (j == b.len() || lengths[(i + 1) * width + j] >= lengths[i * width + j + 1])
201            {
202                result.push((&a[i], Some(Deletion)));
203                i += 1;
204            } else {
205                result.push((&b[j], Some(Insertion)));
206                j += 1;
207            }
208        }
209    }
210    result.extend(new[new.len() - suffix..].iter().map(|token| (token, None)));
211    result
212}