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#[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 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 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 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
201fn 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
226fn 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
288fn 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
323fn 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| ¶graph.runs));
383 let new = tokens(¶graph.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
429const 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 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}