Skip to main content

pdf_vdiff/
diff.rs

1//! Hierarchical 2-pass sequence diff engine and highlight box unioning.
2
3use 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/// Diff granularity mode selectable via `--granularity`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
10pub enum DiffGranularity {
11    /// Diff by word tokens (Default).
12    #[default]
13    Word,
14    /// Diff by full lines.
15    Line,
16    /// Diff by individual characters/glyphs.
17    Character,
18}
19
20/// Diff result for a single side-by-side page pair.
21#[derive(Debug, Clone, PartialEq)]
22pub struct PageDiffResult {
23    /// 0-indexed page index.
24    pub page_index: usize,
25    /// Highlight spans to render on the Left (Base) pane.
26    pub base_highlights: Vec<HighlightSpan>,
27    /// Highlight spans to render on the Right (Tailored) pane.
28    pub tailored_highlights: Vec<HighlightSpan>,
29    /// True if any differences were detected on this page.
30    pub has_differences: bool,
31}
32
33/// Overall document diff result across all pages.
34#[derive(Debug, Clone, PartialEq)]
35pub struct DocumentDiffResult {
36    /// Per-page diff results.
37    pub pages: Vec<PageDiffResult>,
38    /// Total count of differences found across all pages.
39    pub total_differences: usize,
40    /// True if any differences were detected anywhere in the document.
41    pub has_differences: bool,
42}
43
44/// Line representation grouping tokens on a single visual line.
45///
46/// Note: `line_index` and `text` are retained for debugging, logging, and future structured diff output.
47#[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
57/// Helper function to extract normalized line string slices from a slice of LineGroup.
58fn 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
62/// Compares two documents page-by-page and returns structured diff highlights.
63///
64/// Note: `total_differences` represents the aggregate count of highlight bounding box spans
65/// rendered across both base (deletions/replacements) and tailored (insertions/replacements) pages.
66pub 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
94/// Compares a single page pair and produces Left (Base) and Right (Tailored) highlight spans.
95pub 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    // Fast path: if base is missing, all tailored tokens are inserted
119    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    // Fast path: if tailored is missing, all base tokens are deleted
130    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    // Token-stream diffing
141    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
149/// Direct token-stream diffing across natural reading order.
150///
151/// Compares normalized token texts directly using Myers diff.
152/// Tokens that shift across line wraps/reflows without changing their textual content
153/// are matched as equal and produce zero highlight rectangles.
154/// Only inserted tokens receive addition highlights (tailored) and deleted tokens
155/// receive deletion highlights (base).
156///
157/// Note: Replace operations emit direct token-level deletions on the Base document
158/// and insertions on the Tailored document without full-line background tint boxes,
159/// ensuring that only changed text is highlighted.
160fn 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                // Unchanged tokens: 0 highlights
184            }
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                // Treat Replace as token deletion on Base and token addition on Tailored (text-only diffing)
215                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
239/// Fallback for line-only diff granularity.
240fn 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
317/// Groups tokens by `line_index` into structured LineGroup objects.
318fn 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
365/// Computes the bounding box covering an entire line of tokens.
366fn 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
377/// Merges contiguous altered tokens (or token references) on the same line into unified highlight rectangles.
378fn 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
416/// Convenience alias for merging owned token slices.
417fn 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        // Missing base
521        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        // Missing tailored
529        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); // 1 span for line 0, 1 span for line 1
559    }
560
561    #[test]
562    fn test_paragraph_reflow_insertion_no_false_positives() {
563        // Base paragraph across 2 lines:
564        // Line 0: "The quick brown fox"
565        // Line 1: "jumps over the lazy dog"
566        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        // Tailored paragraph with "very" inserted on Line 0, causing "brown" and "fox" to wrap to Line 1:
577        // Line 0: "The very quick"
578        // Line 1: "brown fox jumps over the lazy dog"
579        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); // Inserted
581        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); // Wrapped
583        let t1_1 = make_token("fox", 90.0, 730.0, 110.0, 742.0, 1); // Wrapped
584        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        // Base should have ZERO highlights because nothing was deleted
606        assert!(
607            res.pages[0].base_highlights.is_empty(),
608            "Expected 0 base highlights, got: {:?}",
609            res.pages[0].base_highlights
610        );
611        // Tailored should have exactly 1 highlight covering only "very"
612        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        // Base: Line 0 has "Software development lifecycle management", Line 1 has "practices and tooling"
624        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); // To delete
626        let b0_2 = make_token("lifecycle", 175.0, 750.0, 225.0, 762.0, 0); // To delete
627        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        // Tailored: "development lifecycle" removed, "practices" pulled up to Line 0
633        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); // Pulled up
636        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        // Base should have exactly 1 merged highlight span covering "development lifecycle"
650        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        // Tailored should have 0 highlights
658        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        // Base: "The red brown fox"
668        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        // Tailored: "The quick blue fox"
674        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        // "red brown" is replaced by "quick blue" -> 1 merged delete span for base, 1 merged insert span for tailored
685        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        // Base: "tests"
692        let b0 = make_token("tests", 50.0, 750.0, 85.0, 762.0, 0);
693
694        // Tailored: "tests,"
695        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        // Base should have 0 highlights since "tests" is unchanged
704        assert!(res.pages[0].base_highlights.is_empty());
705        // Tailored should have exactly 1 highlight covering only the comma
706        assert_eq!(res.pages[0].tailored_highlights.len(), 1);
707        assert_eq!(res.pages[0].tailored_highlights[0].op, DiffOpKind::Insert);
708        // Verify bounding box matches comma bounds expanded with padding
709        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        // Base: "tests,"
716        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        // Tailored: "tests"
720        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        // Base should have exactly 1 highlight covering only the deleted comma
728        assert_eq!(res.pages[0].base_highlights.len(), 1);
729        assert_eq!(res.pages[0].base_highlights[0].op, DiffOpKind::Delete);
730        // Tailored should have 0 highlights
731        assert!(res.pages[0].tailored_highlights.is_empty());
732    }
733
734    #[test]
735    fn test_punctuation_replacement_preserves_word() {
736        // Base: "tests."
737        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        // Tailored: "tests,"
741        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        // Base highlights only "."
750        assert_eq!(res.pages[0].base_highlights.len(), 1);
751        assert_eq!(res.pages[0].base_highlights[0].op, DiffOpKind::Delete);
752        // Tailored highlights only ","
753        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        // Base: "(hello)"
760        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        // Tailored: "hello"
765        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        // Base highlights "(" and ")" as 2 separate deletion spans around untouched "hello"
773        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        // Base: "Built scalable APIs."
780        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        // Tailored: "Designed distributed systems."
786        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        // Base has 1 merged highlight span covering "Built scalable APIs" (excluding ".")
797        assert_eq!(res.pages[0].base_highlights.len(), 1);
798        // Tailored has 1 merged highlight span covering "Designed distributed systems" (excluding ".")
799        assert_eq!(res.pages[0].tailored_highlights.len(), 1);
800    }
801}