1use crate::model::{DiffOpKind, HighlightSpan, PageText, Rect, TextToken};
4use crate::theme::{HIGHLIGHT_PAD_X, HIGHLIGHT_PAD_Y};
5use clap::ValueEnum;
6use similar::{capture_diff_slices, Algorithm};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
10pub enum DiffGranularity {
11 #[default]
13 Word,
14 Line,
16 Character,
18}
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct PageDiffResult {
23 pub page_index: usize,
25 pub base_highlights: Vec<HighlightSpan>,
27 pub tailored_highlights: Vec<HighlightSpan>,
29 pub has_differences: bool,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct DocumentDiffResult {
36 pub pages: Vec<PageDiffResult>,
38 pub total_differences: usize,
40 pub has_differences: bool,
42}
43
44#[derive(Debug, Clone)]
48struct LineGroup<'a> {
49 #[allow(dead_code)]
50 pub line_index: usize,
51 #[allow(dead_code)]
52 pub text: String,
53 pub normalized_text: String,
54 pub tokens: &'a [TextToken],
55}
56
57fn extract_normalized_line_strings<'a>(lines: &'a [LineGroup<'a>]) -> Vec<&'a str> {
59 lines.iter().map(|l| l.normalized_text.as_str()).collect()
60}
61
62pub fn diff_documents(
67 base_pages: &[PageText],
68 tailored_pages: &[PageText],
69 granularity: DiffGranularity,
70) -> DocumentDiffResult {
71 let max_pages = base_pages.len().max(tailored_pages.len());
72 let mut page_results = Vec::with_capacity(max_pages);
73 let mut total_diffs = 0;
74
75 for i in 0..max_pages {
76 let base_page = base_pages.get(i);
77 let tailored_page = tailored_pages.get(i);
78
79 let result = diff_page_pair(i, base_page, tailored_page, granularity);
80 if result.has_differences {
81 total_diffs += result.base_highlights.len() + result.tailored_highlights.len();
82 }
83 page_results.push(result);
84 }
85
86 let has_differences = total_diffs > 0;
87 DocumentDiffResult {
88 pages: page_results,
89 total_differences: total_diffs,
90 has_differences,
91 }
92}
93
94pub fn diff_page_pair(
96 page_index: usize,
97 base_page: Option<&PageText>,
98 tailored_page: Option<&PageText>,
99 granularity: DiffGranularity,
100) -> PageDiffResult {
101 let empty_tokens = Vec::new();
102 let base_tokens = base_page
103 .map(|p| p.tokens.as_slice())
104 .unwrap_or(&empty_tokens);
105 let tailored_tokens = tailored_page
106 .map(|p| p.tokens.as_slice())
107 .unwrap_or(&empty_tokens);
108
109 if base_tokens.is_empty() && tailored_tokens.is_empty() {
110 return PageDiffResult {
111 page_index,
112 base_highlights: Vec::new(),
113 tailored_highlights: Vec::new(),
114 has_differences: false,
115 };
116 }
117
118 if base_tokens.is_empty() {
120 let highlights = merge_contiguous_highlights(tailored_tokens, DiffOpKind::Insert, false);
121 return PageDiffResult {
122 page_index,
123 base_highlights: Vec::new(),
124 tailored_highlights: highlights,
125 has_differences: true,
126 };
127 }
128
129 if tailored_tokens.is_empty() {
131 let highlights = merge_contiguous_highlights(base_tokens, DiffOpKind::Delete, false);
132 return PageDiffResult {
133 page_index,
134 base_highlights: highlights,
135 tailored_highlights: Vec::new(),
136 has_differences: true,
137 };
138 }
139
140 match granularity {
142 DiffGranularity::Line => diff_by_lines_only(page_index, base_tokens, tailored_tokens),
143 DiffGranularity::Word | DiffGranularity::Character => {
144 diff_token_stream(page_index, base_tokens, tailored_tokens)
145 }
146 }
147}
148
149fn diff_token_stream(
161 page_index: usize,
162 base_tokens: &[TextToken],
163 tailored_tokens: &[TextToken],
164) -> PageDiffResult {
165 let base_words: Vec<&str> = base_tokens
166 .iter()
167 .map(|t| t.normalized_text.as_str())
168 .collect();
169 let tailored_words: Vec<&str> = tailored_tokens
170 .iter()
171 .map(|t| t.normalized_text.as_str())
172 .collect();
173
174 let diff_ops = capture_diff_slices(Algorithm::Myers, &base_words, &tailored_words);
175
176 let mut base_highlights = Vec::new();
177 let mut tailored_highlights = Vec::new();
178 let mut has_differences = false;
179
180 for op in diff_ops {
181 match op {
182 similar::DiffOp::Equal { .. } => {
183 }
185 similar::DiffOp::Delete {
186 old_index, old_len, ..
187 } => {
188 has_differences = true;
189 let slice = &base_tokens[old_index..old_index + old_len];
190 base_highlights.extend(merge_contiguous_highlights(
191 slice,
192 DiffOpKind::Delete,
193 false,
194 ));
195 }
196 similar::DiffOp::Insert {
197 new_index, new_len, ..
198 } => {
199 has_differences = true;
200 let slice = &tailored_tokens[new_index..new_index + new_len];
201 tailored_highlights.extend(merge_contiguous_highlights(
202 slice,
203 DiffOpKind::Insert,
204 false,
205 ));
206 }
207 similar::DiffOp::Replace {
208 old_index,
209 old_len,
210 new_index,
211 new_len,
212 } => {
213 has_differences = true;
214 let old_slice = &base_tokens[old_index..old_index + old_len];
216 let new_slice = &tailored_tokens[new_index..new_index + new_len];
217 base_highlights.extend(merge_contiguous_highlights(
218 old_slice,
219 DiffOpKind::Delete,
220 false,
221 ));
222 tailored_highlights.extend(merge_contiguous_highlights(
223 new_slice,
224 DiffOpKind::Insert,
225 false,
226 ));
227 }
228 }
229 }
230
231 PageDiffResult {
232 page_index,
233 base_highlights,
234 tailored_highlights,
235 has_differences,
236 }
237}
238
239fn diff_by_lines_only(
241 page_index: usize,
242 base_tokens: &[TextToken],
243 tailored_tokens: &[TextToken],
244) -> PageDiffResult {
245 let base_lines = group_tokens_by_line(base_tokens);
246 let tailored_lines = group_tokens_by_line(tailored_tokens);
247
248 let base_line_strs = extract_normalized_line_strings(&base_lines);
249 let tailored_line_strs = extract_normalized_line_strings(&tailored_lines);
250
251 let diff_ops = capture_diff_slices(Algorithm::Myers, &base_line_strs, &tailored_line_strs);
252
253 let mut base_highlights = Vec::new();
254 let mut tailored_highlights = Vec::new();
255 let mut has_differences = false;
256
257 for op in diff_ops {
258 match op {
259 similar::DiffOp::Equal { .. } => {}
260 similar::DiffOp::Delete {
261 old_index, old_len, ..
262 } => {
263 has_differences = true;
264 for line in &base_lines[old_index..old_index + old_len] {
265 if let Some(bounds) = compute_line_bounds(line.tokens) {
266 base_highlights.push(HighlightSpan::new(bounds, DiffOpKind::Delete, false));
267 }
268 }
269 }
270 similar::DiffOp::Insert {
271 new_index, new_len, ..
272 } => {
273 has_differences = true;
274 for line in &tailored_lines[new_index..new_index + new_len] {
275 if let Some(bounds) = compute_line_bounds(line.tokens) {
276 tailored_highlights.push(HighlightSpan::new(
277 bounds,
278 DiffOpKind::Insert,
279 false,
280 ));
281 }
282 }
283 }
284 similar::DiffOp::Replace {
285 old_index,
286 old_len,
287 new_index,
288 new_len,
289 } => {
290 has_differences = true;
291 for line in &base_lines[old_index..old_index + old_len] {
292 if let Some(bounds) = compute_line_bounds(line.tokens) {
293 base_highlights.push(HighlightSpan::new(bounds, DiffOpKind::Delete, false));
294 }
295 }
296 for line in &tailored_lines[new_index..new_index + new_len] {
297 if let Some(bounds) = compute_line_bounds(line.tokens) {
298 tailored_highlights.push(HighlightSpan::new(
299 bounds,
300 DiffOpKind::Insert,
301 false,
302 ));
303 }
304 }
305 }
306 }
307 }
308
309 PageDiffResult {
310 page_index,
311 base_highlights,
312 tailored_highlights,
313 has_differences,
314 }
315}
316
317fn group_tokens_by_line(tokens: &[TextToken]) -> Vec<LineGroup<'_>> {
319 if tokens.is_empty() {
320 return Vec::new();
321 }
322
323 let mut lines = Vec::new();
324 let mut start_idx = 0;
325 let mut current_line = tokens[0].line_index;
326
327 for (i, token) in tokens.iter().enumerate() {
328 if token.line_index != current_line {
329 let slice = &tokens[start_idx..i];
330 lines.push(build_line_group(current_line, slice));
331 start_idx = i;
332 current_line = token.line_index;
333 }
334 }
335
336 if start_idx < tokens.len() {
337 let slice = &tokens[start_idx..];
338 lines.push(build_line_group(current_line, slice));
339 }
340
341 lines
342}
343
344fn build_line_group<'a>(line_index: usize, slice: &'a [TextToken]) -> LineGroup<'a> {
345 let mut text = String::new();
346 let mut normalized_text = String::new();
347
348 for (i, t) in slice.iter().enumerate() {
349 text.push_str(&t.text);
350 normalized_text.push_str(&t.normalized_text);
351 if t.trailing_space && i + 1 < slice.len() {
352 text.push(' ');
353 normalized_text.push(' ');
354 }
355 }
356
357 LineGroup {
358 line_index,
359 text,
360 normalized_text,
361 tokens: slice,
362 }
363}
364
365fn compute_line_bounds(tokens: &[TextToken]) -> Option<Rect> {
367 tokens
368 .first()
369 .map(|first| {
370 tokens
371 .iter()
372 .fold(first.bounds, |acc, t| acc.union(&t.bounds))
373 })
374 .map(|r| r.expand(HIGHLIGHT_PAD_X, HIGHLIGHT_PAD_Y))
375}
376
377fn merge_token_highlights<T: std::borrow::Borrow<TextToken>>(
379 tokens: &[T],
380 op: DiffOpKind,
381 is_modified_token: bool,
382) -> Vec<HighlightSpan> {
383 if tokens.is_empty() {
384 return Vec::new();
385 }
386
387 let mut spans = Vec::new();
388 let first = tokens[0].borrow();
389 let mut current_line = first.line_index;
390 let mut current_box = first.bounds;
391
392 for t in &tokens[1..] {
393 let t = t.borrow();
394 if t.line_index == current_line {
395 current_box = current_box.union(&t.bounds);
396 } else {
397 spans.push(HighlightSpan::new(
398 current_box.expand(HIGHLIGHT_PAD_X, HIGHLIGHT_PAD_Y),
399 op,
400 is_modified_token,
401 ));
402 current_line = t.line_index;
403 current_box = t.bounds;
404 }
405 }
406
407 spans.push(HighlightSpan::new(
408 current_box.expand(HIGHLIGHT_PAD_X, HIGHLIGHT_PAD_Y),
409 op,
410 is_modified_token,
411 ));
412
413 spans
414}
415
416fn merge_contiguous_highlights(
418 tokens: &[TextToken],
419 op: DiffOpKind,
420 is_modified_token: bool,
421) -> Vec<HighlightSpan> {
422 merge_token_highlights(tokens, op, is_modified_token)
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn make_token(text: &str, x0: f32, y0: f32, x1: f32, y1: f32, line: usize) -> TextToken {
430 TextToken::new(
431 text.to_string(),
432 text.to_string(),
433 Rect::new(x0, y0, x1, y1),
434 0,
435 0,
436 line,
437 true,
438 )
439 }
440
441 #[test]
442 fn test_identical_documents() {
443 let t1 = make_token("Hello", 50.0, 750.0, 90.0, 762.0, 0);
444 let t2 = make_token("World", 95.0, 750.0, 140.0, 762.0, 0);
445
446 let p1 = PageText::new(vec![t1.clone(), t2.clone()], 612.0, 792.0, 0);
447 let p2 = PageText::new(vec![t1, t2], 612.0, 792.0, 0);
448
449 let res = diff_documents(&[p1], &[p2], DiffGranularity::Word);
450 assert!(!res.has_differences);
451 assert_eq!(res.total_differences, 0);
452 assert!(res.pages[0].base_highlights.is_empty());
453 assert!(res.pages[0].tailored_highlights.is_empty());
454 }
455
456 #[test]
457 fn test_word_insertion_and_deletion() {
458 let b1 = make_token("Software", 50.0, 750.0, 100.0, 762.0, 0);
459 let b2 = make_token("Engineer", 105.0, 750.0, 160.0, 762.0, 0);
460
461 let t1 = make_token("Principal", 50.0, 750.0, 105.0, 762.0, 0);
462 let t2 = make_token("Software", 110.0, 750.0, 160.0, 762.0, 0);
463 let t3 = make_token("Architect", 165.0, 750.0, 220.0, 762.0, 0);
464
465 let base_page = PageText::new(vec![b1, b2], 612.0, 792.0, 0);
466 let tailored_page = PageText::new(vec![t1, t2, t3], 612.0, 792.0, 0);
467
468 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
469 assert!(res.has_differences);
470 assert!(!res.pages[0].base_highlights.is_empty());
471 assert!(!res.pages[0].tailored_highlights.is_empty());
472 }
473
474 #[test]
475 fn test_page_count_mismatch() {
476 let b1 = make_token("Page1", 50.0, 750.0, 90.0, 762.0, 0);
477 let t1 = make_token("Page1", 50.0, 750.0, 90.0, 762.0, 0);
478 let t2 = make_token("Page2", 50.0, 750.0, 90.0, 762.0, 0);
479
480 let base_pages = vec![PageText::new(vec![b1], 612.0, 792.0, 0)];
481 let tailored_pages = vec![
482 PageText::new(vec![t1], 612.0, 792.0, 0),
483 PageText::new(vec![t2], 612.0, 792.0, 1),
484 ];
485
486 let res = diff_documents(&base_pages, &tailored_pages, DiffGranularity::Word);
487 assert!(res.has_differences);
488 assert_eq!(res.pages.len(), 2);
489 assert!(!res.pages[0].has_differences);
490 assert!(res.pages[1].has_differences);
491 assert!(res.pages[1].base_highlights.is_empty());
492 assert!(!res.pages[1].tailored_highlights.is_empty());
493 }
494
495 #[test]
496 fn test_line_granularity_diff() {
497 let b1 = make_token("Deleted Line", 50.0, 750.0, 150.0, 762.0, 0);
498 let b2 = make_token("Modified Base Line", 50.0, 720.0, 200.0, 732.0, 1);
499
500 let t1 = make_token("Modified Tailored Line", 50.0, 750.0, 210.0, 762.0, 0);
501 let t2 = make_token("Inserted Line", 50.0, 720.0, 160.0, 732.0, 1);
502
503 let base_page = PageText::new(vec![b1, b2], 612.0, 792.0, 0);
504 let tailored_page = PageText::new(vec![t1, t2], 612.0, 792.0, 0);
505
506 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Line);
507 assert!(res.has_differences);
508 assert!(!res.pages[0].base_highlights.is_empty());
509 assert!(!res.pages[0].tailored_highlights.is_empty());
510 }
511
512 #[test]
513 fn test_empty_page_pairs() {
514 let empty_base = PageText::new(Vec::new(), 612.0, 792.0, 0);
515 let empty_tailored = PageText::new(Vec::new(), 612.0, 792.0, 0);
516
517 let res = diff_documents(&[empty_base], &[empty_tailored], DiffGranularity::Word);
518 assert!(!res.has_differences);
519
520 let t = make_token("New", 50.0, 700.0, 80.0, 712.0, 0);
522 let single_tailored = PageText::new(vec![t], 612.0, 792.0, 0);
523 let res_missing_base =
524 diff_page_pair(0, None, Some(&single_tailored), DiffGranularity::Word);
525 assert!(res_missing_base.has_differences);
526 assert!(!res_missing_base.tailored_highlights.is_empty());
527
528 let b = make_token("Old", 50.0, 700.0, 80.0, 712.0, 0);
530 let single_base = PageText::new(vec![b], 612.0, 792.0, 0);
531 let res_missing_tailored =
532 diff_page_pair(0, Some(&single_base), None, DiffGranularity::Word);
533 assert!(res_missing_tailored.has_differences);
534 assert!(!res_missing_tailored.base_highlights.is_empty());
535 }
536
537 #[test]
538 fn test_character_granularity_diff() {
539 let b1 = make_token("Rust", 50.0, 750.0, 100.0, 762.0, 0);
540 let t1 = make_token("Rest", 50.0, 750.0, 100.0, 762.0, 0);
541
542 let base_page = PageText::new(vec![b1], 612.0, 792.0, 0);
543 let tailored_page = PageText::new(vec![t1], 612.0, 792.0, 0);
544
545 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Character);
546 assert!(res.has_differences);
547 assert!(!res.pages[0].base_highlights.is_empty());
548 assert!(!res.pages[0].tailored_highlights.is_empty());
549 }
550
551 #[test]
552 fn test_contiguous_multi_line_token_merging() {
553 let t1 = make_token("Line0-A", 50.0, 750.0, 100.0, 762.0, 0);
554 let t2 = make_token("Line0-B", 105.0, 750.0, 150.0, 762.0, 0);
555 let t3 = make_token("Line1-A", 50.0, 720.0, 100.0, 732.0, 1);
556
557 let merged = merge_contiguous_highlights(&[t1, t2, t3], DiffOpKind::Delete, false);
558 assert_eq!(merged.len(), 2); }
560
561 #[test]
562 fn test_paragraph_reflow_insertion_no_false_positives() {
563 let b0_0 = make_token("The", 50.0, 750.0, 70.0, 762.0, 0);
567 let b0_1 = make_token("quick", 75.0, 750.0, 105.0, 762.0, 0);
568 let b0_2 = make_token("brown", 110.0, 750.0, 145.0, 762.0, 0);
569 let b0_3 = make_token("fox", 150.0, 750.0, 170.0, 762.0, 0);
570 let b1_0 = make_token("jumps", 50.0, 730.0, 85.0, 742.0, 1);
571 let b1_1 = make_token("over", 90.0, 730.0, 115.0, 742.0, 1);
572 let b1_2 = make_token("the", 120.0, 730.0, 140.0, 742.0, 1);
573 let b1_3 = make_token("lazy", 145.0, 730.0, 170.0, 742.0, 1);
574 let b1_4 = make_token("dog", 175.0, 730.0, 195.0, 742.0, 1);
575
576 let t0_0 = make_token("The", 50.0, 750.0, 70.0, 762.0, 0);
580 let t0_1 = make_token("very", 75.0, 750.0, 100.0, 762.0, 0); let t0_2 = make_token("quick", 105.0, 750.0, 135.0, 762.0, 0);
582 let t1_0 = make_token("brown", 50.0, 730.0, 85.0, 742.0, 1); let t1_1 = make_token("fox", 90.0, 730.0, 110.0, 742.0, 1); let t1_2 = make_token("jumps", 115.0, 730.0, 150.0, 742.0, 1);
585 let t1_3 = make_token("over", 155.0, 730.0, 180.0, 742.0, 1);
586 let t1_4 = make_token("the", 185.0, 730.0, 205.0, 742.0, 1);
587 let t1_5 = make_token("lazy", 210.0, 730.0, 235.0, 742.0, 1);
588 let t1_6 = make_token("dog", 240.0, 730.0, 260.0, 742.0, 1);
589
590 let base_page = PageText::new(
591 vec![b0_0, b0_1, b0_2, b0_3, b1_0, b1_1, b1_2, b1_3, b1_4],
592 612.0,
593 792.0,
594 0,
595 );
596 let tailored_page = PageText::new(
597 vec![t0_0, t0_1, t0_2, t1_0, t1_1, t1_2, t1_3, t1_4, t1_5, t1_6],
598 612.0,
599 792.0,
600 0,
601 );
602
603 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
604 assert!(res.has_differences);
605 assert!(
607 res.pages[0].base_highlights.is_empty(),
608 "Expected 0 base highlights, got: {:?}",
609 res.pages[0].base_highlights
610 );
611 assert_eq!(
613 res.pages[0].tailored_highlights.len(),
614 1,
615 "Expected 1 tailored highlight for 'very', got: {:?}",
616 res.pages[0].tailored_highlights
617 );
618 assert_eq!(res.pages[0].tailored_highlights[0].op, DiffOpKind::Insert);
619 }
620
621 #[test]
622 fn test_paragraph_reflow_deletion_no_false_positives() {
623 let b0_0 = make_token("Software", 50.0, 750.0, 100.0, 762.0, 0);
625 let b0_1 = make_token("development", 105.0, 750.0, 170.0, 762.0, 0); let b0_2 = make_token("lifecycle", 175.0, 750.0, 225.0, 762.0, 0); let b0_3 = make_token("management", 230.0, 750.0, 300.0, 762.0, 0);
628 let b1_0 = make_token("practices", 50.0, 730.0, 105.0, 742.0, 1);
629 let b1_1 = make_token("and", 110.0, 730.0, 130.0, 742.0, 1);
630 let b1_2 = make_token("tooling", 135.0, 730.0, 180.0, 742.0, 1);
631
632 let t0_0 = make_token("Software", 50.0, 750.0, 100.0, 762.0, 0);
634 let t0_1 = make_token("management", 105.0, 750.0, 175.0, 762.0, 0);
635 let t0_2 = make_token("practices", 180.0, 750.0, 235.0, 762.0, 0); let t1_0 = make_token("and", 50.0, 730.0, 70.0, 742.0, 1);
637 let t1_1 = make_token("tooling", 75.0, 730.0, 120.0, 742.0, 1);
638
639 let base_page = PageText::new(
640 vec![b0_0, b0_1, b0_2, b0_3, b1_0, b1_1, b1_2],
641 612.0,
642 792.0,
643 0,
644 );
645 let tailored_page = PageText::new(vec![t0_0, t0_1, t0_2, t1_0, t1_1], 612.0, 792.0, 0);
646
647 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
648 assert!(res.has_differences);
649 assert_eq!(
651 res.pages[0].base_highlights.len(),
652 1,
653 "Expected 1 merged base highlight, got: {:?}",
654 res.pages[0].base_highlights
655 );
656 assert_eq!(res.pages[0].base_highlights[0].op, DiffOpKind::Delete);
657 assert!(
659 res.pages[0].tailored_highlights.is_empty(),
660 "Expected 0 tailored highlights, got: {:?}",
661 res.pages[0].tailored_highlights
662 );
663 }
664
665 #[test]
666 fn test_inline_multiple_discrete_word_edits() {
667 let b0 = make_token("The", 50.0, 750.0, 70.0, 762.0, 0);
669 let b1 = make_token("red", 75.0, 750.0, 95.0, 762.0, 0);
670 let b2 = make_token("brown", 100.0, 750.0, 135.0, 762.0, 0);
671 let b3 = make_token("fox", 140.0, 750.0, 160.0, 762.0, 0);
672
673 let t0 = make_token("The", 50.0, 750.0, 70.0, 762.0, 0);
675 let t1 = make_token("quick", 75.0, 750.0, 105.0, 762.0, 0);
676 let t2 = make_token("blue", 110.0, 750.0, 135.0, 762.0, 0);
677 let t3 = make_token("fox", 140.0, 750.0, 160.0, 762.0, 0);
678
679 let base_page = PageText::new(vec![b0, b1, b2, b3], 612.0, 792.0, 0);
680 let tailored_page = PageText::new(vec![t0, t1, t2, t3], 612.0, 792.0, 0);
681
682 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
683 assert!(res.has_differences);
684 assert_eq!(res.pages[0].base_highlights.len(), 1);
686 assert_eq!(res.pages[0].tailored_highlights.len(), 1);
687 }
688
689 #[test]
690 fn test_punctuation_insertion_only_highlights_punctuation() {
691 let b0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
693
694 let t0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
696 let t1 = make_token(",", 85.5, 750.0, 89.0, 762.0, 0);
697
698 let base_page = PageText::new(vec![b0], 612.0, 792.0, 0);
699 let tailored_page = PageText::new(vec![t0, t1], 612.0, 792.0, 0);
700
701 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
702 assert!(res.has_differences);
703 assert!(res.pages[0].base_highlights.is_empty());
705 assert_eq!(res.pages[0].tailored_highlights.len(), 1);
707 assert_eq!(res.pages[0].tailored_highlights[0].op, DiffOpKind::Insert);
708 assert!((res.pages[0].tailored_highlights[0].bounds.x0 - 85.0).abs() < 0.1);
710 assert!((res.pages[0].tailored_highlights[0].bounds.x1 - 89.5).abs() < 0.1);
711 }
712
713 #[test]
714 fn test_punctuation_deletion_only_highlights_punctuation() {
715 let b0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
717 let b1 = make_token(",", 85.5, 750.0, 89.0, 762.0, 0);
718
719 let t0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
721
722 let base_page = PageText::new(vec![b0, b1], 612.0, 792.0, 0);
723 let tailored_page = PageText::new(vec![t0], 612.0, 792.0, 0);
724
725 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
726 assert!(res.has_differences);
727 assert_eq!(res.pages[0].base_highlights.len(), 1);
729 assert_eq!(res.pages[0].base_highlights[0].op, DiffOpKind::Delete);
730 assert!(res.pages[0].tailored_highlights.is_empty());
732 }
733
734 #[test]
735 fn test_punctuation_replacement_preserves_word() {
736 let b0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
738 let b1 = make_token(".", 85.5, 750.0, 88.0, 762.0, 0);
739
740 let t0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
742 let t1 = make_token(",", 85.5, 750.0, 89.0, 762.0, 0);
743
744 let base_page = PageText::new(vec![b0, b1], 612.0, 792.0, 0);
745 let tailored_page = PageText::new(vec![t0, t1], 612.0, 792.0, 0);
746
747 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
748 assert!(res.has_differences);
749 assert_eq!(res.pages[0].base_highlights.len(), 1);
751 assert_eq!(res.pages[0].base_highlights[0].op, DiffOpKind::Delete);
752 assert_eq!(res.pages[0].tailored_highlights.len(), 1);
754 assert_eq!(res.pages[0].tailored_highlights[0].op, DiffOpKind::Insert);
755 }
756
757 #[test]
758 fn test_hyphenated_and_bracketed_punctuation_edits() {
759 let b0 = make_token("(", 45.0, 750.0, 49.0, 762.0, 0);
761 let b1 = make_token("hello", 50.0, 750.0, 85.0, 762.0, 0);
762 let b2 = make_token(")", 86.0, 750.0, 90.0, 762.0, 0);
763
764 let t0 = make_token("hello", 50.0, 750.0, 85.0, 762.0, 0);
766
767 let base_page = PageText::new(vec![b0, b1, b2], 612.0, 792.0, 0);
768 let tailored_page = PageText::new(vec![t0], 612.0, 792.0, 0);
769
770 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
771 assert!(res.has_differences);
772 assert_eq!(res.pages[0].base_highlights.len(), 2);
774 assert!(res.pages[0].tailored_highlights.is_empty());
775 }
776
777 #[test]
778 fn test_phrase_replacement_with_punctuation_preservation() {
779 let b0 = make_token("Built", 50.0, 750.0, 85.0, 762.0, 0);
781 let b1 = make_token("scalable", 90.0, 750.0, 140.0, 762.0, 0);
782 let b2 = make_token("APIs", 145.0, 750.0, 180.0, 762.0, 0);
783 let b3 = make_token(".", 181.0, 750.0, 184.0, 762.0, 0);
784
785 let t0 = make_token("Designed", 50.0, 750.0, 105.0, 762.0, 0);
787 let t1 = make_token("distributed", 110.0, 750.0, 175.0, 762.0, 0);
788 let t2 = make_token("systems", 180.0, 750.0, 225.0, 762.0, 0);
789 let t3 = make_token(".", 226.0, 750.0, 229.0, 762.0, 0);
790
791 let base_page = PageText::new(vec![b0, b1, b2, b3], 612.0, 792.0, 0);
792 let tailored_page = PageText::new(vec![t0, t1, t2, t3], 612.0, 792.0, 0);
793
794 let res = diff_documents(&[base_page], &[tailored_page], DiffGranularity::Word);
795 assert!(res.has_differences);
796 assert_eq!(res.pages[0].base_highlights.len(), 1);
798 assert_eq!(res.pages[0].tailored_highlights.len(), 1);
800 }
801}