pptx_edit/
proposal_diff.rs1use 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 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| ¶graph.runs));
113 let new = tokens(¶graph.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}