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, map_string, map_string_array, required_map, required_order, slide_notes,
11 slide_ref, slide_shape_order, snapshot_shape, string_array_ref,
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 let mut seen_slides = HashSet::new();
294 for slide_id in string_array_ref(&required_order(txn)?, txn) {
295 if !seen_slides.insert(slide_id.clone()) {
296 continue;
297 }
298 let slide = slides
299 .get(txn, &slide_id)
300 .and_then(|value| value.cast::<MapRef>().ok())
301 .ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
302 let mut pending = live_shape_order(&slide_shape_order(&slide, txn)?, txn)?;
303 pending.reverse();
304 let mut seen_shapes = HashSet::new();
305 while let Some(shape_id) = pending.pop() {
306 if !seen_shapes.insert(shape_id.clone()) {
307 continue;
308 }
309 let shape = shapes
310 .get(txn, &shape_id)
311 .and_then(|value| value.cast::<MapRef>().ok())
312 .ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
313 if map_string_array(&shape, txn, "textStories")?
314 .iter()
315 .any(|id| id.as_str() == story_id)
316 {
317 return Ok(Some((slide_id, shape_id)));
318 }
319 let mut children = map_string_array(&shape, txn, "children")?;
320 children.reverse();
321 pending.extend(children);
322 }
323 }
324 Ok(None)
325}
326
327fn shape_in_tree<T: ReadTxn>(txn: &T, order: &ArrayRef, shape_id: &str) -> EditResult<bool> {
330 let shapes = required_map(txn, SHAPES)?;
331 let mut pending = live_shape_order(order, txn)?;
332 pending.reverse();
333 let mut seen = HashSet::new();
334 while let Some(id) = pending.pop() {
335 if id == shape_id {
336 return Ok(true);
337 }
338 if !seen.insert(id.clone()) {
339 continue;
340 }
341 let shape = shapes
342 .get(txn, &id)
343 .and_then(|value| value.cast::<MapRef>().ok())
344 .ok_or_else(|| EditError::InvalidState(format!("missing shape {id}")))?;
345 let mut children = map_string_array(&shape, txn, "children")?;
346 children.reverse();
347 pending.extend(children);
348 }
349 Ok(false)
350}
351
352#[derive(Clone, Debug, PartialEq)]
353struct Token {
354 text: String,
355 style: TextStyle,
356}
357
358fn tokens(runs: &[TextRunSnapshot]) -> Vec<Token> {
359 let mut result: Vec<Token> = Vec::new();
360 for run in runs {
361 for ch in run.text.chars() {
362 if let Some(last) = result.last_mut()
363 && last.style == run.style
364 && last.text.ends_with(char::is_whitespace) == ch.is_whitespace()
365 {
366 last.text.push(ch);
367 } else {
368 result.push(Token {
369 text: ch.to_string(),
370 style: run.style.clone(),
371 });
372 }
373 }
374 }
375 result
376}
377
378fn diff_story(
379 before: &StorySnapshot,
380 after: &mut StorySnapshot,
381 changes: &mut Vec<ProposalTextChange>,
382) {
383 let mut offset = 0;
384 for paragraph in &mut after.paragraphs {
385 let old = before.paragraphs.iter().find(|old| old.id == paragraph.id);
386 let old = tokens(old.map_or(&[], |paragraph| ¶graph.runs));
387 let new = tokens(¶graph.runs);
388 let mut runs: Vec<TextRunSnapshot> = Vec::new();
389 for (token, kind) in diff_tokens(&old, &new) {
390 let start = offset;
391 offset += token.text.encode_utf16().count() as u32;
392 let mut style = token.style.clone();
393 if let Some(kind) = kind {
394 style.color = Some(
395 match kind {
396 ProposalTextChangeKind::Insertion => "#166534",
397 ProposalTextChangeKind::Deletion => "#b91c1c",
398 }
399 .into(),
400 );
401 if let Some(last) = changes.last_mut()
402 && last.story_id == after.id
403 && last.kind == kind
404 && last.end == start
405 {
406 last.end = offset;
407 } else {
408 changes.push(ProposalTextChange {
409 story_id: after.id.clone(),
410 start,
411 end: offset,
412 kind,
413 });
414 }
415 }
416 if let Some(last) = runs.last_mut()
417 && last.style == style
418 {
419 last.text.push_str(&token.text);
420 } else {
421 runs.push(TextRunSnapshot {
422 text: token.text.clone(),
423 style,
424 });
425 }
426 }
427 paragraph.runs = runs;
428 offset += 1;
429 }
430 after.length = offset;
431}
432
433const PREVIEW_LCS_CELLS: usize = 250_000;
435
436fn diff_tokens<'a>(
437 old: &'a [Token],
438 new: &'a [Token],
439) -> Vec<(&'a Token, Option<ProposalTextChangeKind>)> {
440 use ProposalTextChangeKind::{Deletion, Insertion};
441 let limits = DiffLimits::new(usize::MAX, PREVIEW_LCS_CELLS, LimitFallback::ReplaceMiddle);
442 let diff = ooxml_diff::diff_tokens(old, new, limits)
443 .expect("the replace-middle fallback never refuses");
444 let mut result = Vec::with_capacity(old.len().max(new.len()));
445 for hunk in diff.hunks {
446 match hunk.kind {
447 DiffKind::Equal => result.extend(new[hunk.new].iter().map(|token| (token, None))),
448 DiffKind::Delete => {
449 result.extend(old[hunk.old].iter().map(|token| (token, Some(Deletion))))
450 }
451 DiffKind::Insert => {
452 result.extend(new[hunk.new].iter().map(|token| (token, Some(Insertion))))
453 }
454 }
455 }
456 result
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn reference<'a>(
465 old: &'a [Token],
466 new: &'a [Token],
467 ) -> Vec<(&'a Token, Option<ProposalTextChangeKind>)> {
468 use ProposalTextChangeKind::{Deletion, Insertion};
469 let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
470 let suffix = old[prefix..]
471 .iter()
472 .rev()
473 .zip(new[prefix..].iter().rev())
474 .take_while(|(a, b)| a == b)
475 .count();
476 let mut result: Vec<_> = new[..prefix].iter().map(|token| (token, None)).collect();
477 let a = &old[prefix..old.len() - suffix];
478 let b = &new[prefix..new.len() - suffix];
479 if a.len()
480 .saturating_add(1)
481 .saturating_mul(b.len().saturating_add(1))
482 > 250_000
483 {
484 result.extend(a.iter().map(|token| (token, Some(Deletion))));
485 result.extend(b.iter().map(|token| (token, Some(Insertion))));
486 } else {
487 let width = b.len() + 1;
488 let mut lengths = vec![0u32; (a.len() + 1) * width];
489 for i in (0..a.len()).rev() {
490 for j in (0..b.len()).rev() {
491 lengths[i * width + j] = if a[i] == b[j] {
492 lengths[(i + 1) * width + j + 1] + 1
493 } else {
494 lengths[(i + 1) * width + j].max(lengths[i * width + j + 1])
495 };
496 }
497 }
498 let (mut i, mut j) = (0, 0);
499 while i < a.len() || j < b.len() {
500 if i < a.len() && j < b.len() && a[i] == b[j] {
501 result.push((&b[j], None));
502 i += 1;
503 j += 1;
504 } else if i < a.len()
505 && (j == b.len() || lengths[(i + 1) * width + j] >= lengths[i * width + j + 1])
506 {
507 result.push((&a[i], Some(Deletion)));
508 i += 1;
509 } else {
510 result.push((&b[j], Some(Insertion)));
511 j += 1;
512 }
513 }
514 }
515 result.extend(new[new.len() - suffix..].iter().map(|token| (token, None)));
516 result
517 }
518
519 fn runs(text: &str, bold: bool) -> Vec<TextRunSnapshot> {
520 vec![TextRunSnapshot {
521 text: text.to_owned(),
522 style: TextStyle {
523 bold: Some(bold),
524 ..TextStyle::default()
525 },
526 }]
527 }
528
529 fn assert_parity(old: &[TextRunSnapshot], new: &[TextRunSnapshot]) {
530 let (old, new) = (tokens(old), tokens(new));
531 assert_eq!(diff_tokens(&old, &new), reference(&old, &new));
532 }
533
534 #[test]
535 fn preview_diff_matches_the_previous_lcs() {
536 let cases = [
537 ("", ""),
538 ("same text", "same text"),
539 ("the quick brown fox", "the slow brown fox jumps"),
540 ("ab", "ba"),
541 ("a b a b", "b a b a"),
542 ("x y z", ""),
543 ("", "x y z"),
544 ("one two three", "three two one"),
545 ("tied tied tied", "tied tied"),
546 ];
547 for (old, new) in cases {
548 assert_parity(&runs(old, false), &runs(new, false));
549 }
550 assert_parity(&runs("style only", false), &runs("style only", true));
551 }
552
553 #[test]
554 fn oversized_preview_diffs_still_replace_the_middle() {
555 let words = |prefix: &str| {
556 (0..600)
557 .map(|index| format!("{prefix}{index}"))
558 .collect::<Vec<_>>()
559 .join(" ")
560 };
561 let (old, new) = (
562 format!("start {} end", words("a")),
563 format!("start {} end", words("b")),
564 );
565 assert_parity(&runs(&old, false), &runs(&new, false));
566 let (old_tokens, new_tokens) = (tokens(&runs(&old, false)), tokens(&runs(&new, false)));
567 let diff = diff_tokens(&old_tokens, &new_tokens);
568 let first_insertion = diff
569 .iter()
570 .position(|(_, kind)| *kind == Some(ProposalTextChangeKind::Insertion))
571 .unwrap();
572 assert!(
573 diff[..first_insertion]
574 .iter()
575 .skip(2)
576 .all(|(_, kind)| *kind == Some(ProposalTextChangeKind::Deletion))
577 );
578 }
579}