Skip to main content

oxidize_pdf/operations/
source_highlighter.rs

1//! Source highlighter for RAG-aligned PDF editing
2//!
3//! Provides `TextPositionIndex` to map character offsets from `DocumentChunk`
4//! to PDF coordinates, and `SourceHighlighter` to highlight retrieved chunks
5//! in the original PDF.
6
7use std::collections::HashMap;
8use std::io::Cursor;
9
10use crate::ai::chunking::DocumentChunk;
11use crate::annotations::MarkupAnnotation;
12use crate::geometry::{Point, Rectangle};
13use crate::graphics::Color;
14use crate::text::extraction::{ExtractedText, ExtractionOptions, TextFragment};
15
16/// PAGE_SEPARATOR matches the chunker's concatenation: pages joined with "\n\n"
17const PAGE_SEPARATOR: &str = "\n\n";
18
19/// Entry in the position index mapping a text fragment to its char offset
20/// in the full concatenated document text.
21#[derive(Debug, Clone)]
22pub struct IndexedFragment {
23    /// 0-indexed page number
24    pub page: usize,
25    /// Character offset where this fragment starts in the full document text
26    pub start_char: usize,
27    /// Character offset where this fragment ends (exclusive) in the full document text
28    pub end_char: usize,
29    /// X coordinate in PDF page coordinates
30    pub x: f64,
31    /// Y coordinate in PDF page coordinates
32    pub y: f64,
33    /// Width of the text fragment
34    pub width: f64,
35    /// Height of the text fragment
36    pub height: f64,
37}
38
39impl IndexedFragment {
40    /// Convert this fragment's position to a `Rectangle` suitable for annotations.
41    pub fn to_rectangle(&self) -> Rectangle {
42        Rectangle::from_position_and_size(self.x, self.y, self.width, self.height)
43    }
44}
45
46/// Maps character offsets in concatenated document text to PDF coordinates.
47///
48/// Built from per-page `ExtractedText` (with `preserve_layout: true`),
49/// this index allows mapping a character range (like those in `DocumentChunk`)
50/// back to the physical locations on PDF pages.
51#[derive(Debug)]
52pub struct TextPositionIndex {
53    /// Indexed fragments sorted by start_char
54    entries: Vec<IndexedFragment>,
55    /// Character offset where each page starts in the concatenated text
56    page_offsets: Vec<usize>,
57}
58
59impl TextPositionIndex {
60    /// Build an index from per-page extracted text.
61    ///
62    /// Pages are concatenated with `"\n\n"` separators (matching the chunker).
63    /// For each page's `TextFragment`, we find its position in the page text
64    /// and compute the global character offset.
65    pub fn build(pages: &[ExtractedText]) -> Self {
66        let mut entries = Vec::new();
67        let mut page_offsets = Vec::new();
68        let mut global_offset: usize = 0;
69
70        for (page_idx, page) in pages.iter().enumerate() {
71            page_offsets.push(global_offset);
72
73            // Track position within the page text for incremental search
74            let page_text = &page.text;
75            let mut search_from: usize = 0;
76
77            for fragment in &page.fragments {
78                if fragment.text.is_empty() {
79                    continue;
80                }
81
82                // Find this fragment's text within the page text, starting from
83                // where the last fragment ended (incremental search)
84                if let Some(pos_in_page) = page_text[search_from..].find(&fragment.text) {
85                    let local_offset = search_from + pos_in_page;
86                    let frag_len = fragment.text.len();
87
88                    entries.push(IndexedFragment {
89                        page: page_idx,
90                        start_char: global_offset + local_offset,
91                        end_char: global_offset + local_offset + frag_len,
92                        x: fragment.x,
93                        y: fragment.y,
94                        width: fragment.width,
95                        height: fragment.height,
96                    });
97
98                    // Advance search position past this fragment
99                    search_from = local_offset + frag_len;
100                }
101            }
102
103            // Advance global offset: page text length + separator
104            global_offset += page_text.len();
105            if page_idx < pages.len() - 1 {
106                global_offset += PAGE_SEPARATOR.len();
107            }
108        }
109
110        Self {
111            entries,
112            page_offsets,
113        }
114    }
115
116    /// Find all fragments whose character range overlaps with `[start, end)`.
117    pub fn fragments_for_range(&self, start: usize, end: usize) -> Vec<&IndexedFragment> {
118        if start >= end {
119            return Vec::new();
120        }
121
122        self.entries
123            .iter()
124            .filter(|e| e.start_char < end && e.end_char > start)
125            .collect()
126    }
127
128    /// Get the character offset where a given page starts.
129    pub fn page_offset(&self, page: usize) -> Option<usize> {
130        self.page_offsets.get(page).copied()
131    }
132
133    /// Total number of indexed fragments.
134    pub fn len(&self) -> usize {
135        self.entries.len()
136    }
137
138    /// Whether the index is empty.
139    pub fn is_empty(&self) -> bool {
140        self.entries.is_empty()
141    }
142
143    /// All indexed entries (for inspection/testing).
144    pub fn entries(&self) -> &[IndexedFragment] {
145        &self.entries
146    }
147}
148
149/// Convert a `TextFragment` position to a `Rectangle` for highlight annotations.
150pub fn fragment_to_highlight_rect(frag: &TextFragment) -> Rectangle {
151    Rectangle::new(
152        Point::new(frag.x, frag.y),
153        Point::new(frag.x + frag.width, frag.y + frag.height),
154    )
155}
156
157// =============================================================================
158// SourceHighlighter API
159// =============================================================================
160
161/// Style configuration for highlight annotations.
162#[derive(Debug, Clone)]
163pub struct HighlightStyle {
164    /// Color of the highlight (default: yellow)
165    pub color: Color,
166    /// Opacity of the highlight (0.0 = transparent, 1.0 = opaque; default: 0.5)
167    pub opacity: f64,
168}
169
170impl Default for HighlightStyle {
171    fn default() -> Self {
172        Self {
173            color: Color::Rgb(1.0, 1.0, 0.0), // Yellow
174            opacity: 0.5,
175        }
176    }
177}
178
179impl HighlightStyle {
180    /// Create a new HighlightStyle with default values.
181    pub fn new() -> Self {
182        Self::default()
183    }
184
185    /// Set the highlight color.
186    pub fn with_color(mut self, color: Color) -> Self {
187        self.color = color;
188        self
189    }
190
191    /// Set the highlight opacity.
192    pub fn with_opacity(mut self, opacity: f64) -> Self {
193        self.opacity = opacity;
194        self
195    }
196}
197
198/// Errors that can occur during source highlighting.
199#[derive(Debug, thiserror::Error)]
200pub enum SourceHighlighterError {
201    /// Failed to extract text from the PDF
202    #[error("text extraction failed: {0}")]
203    TextExtractionFailed(String),
204
205    /// Failed to reconstruct a page from the parsed PDF
206    #[error("page reconstruction failed: {0}")]
207    PageReconstructionFailed(String),
208
209    /// Failed to write the output PDF
210    #[error("write failed: {0}")]
211    WriteFailed(String),
212}
213
214/// Result type for source highlighter operations.
215pub type SourceHighlighterResult<T> = Result<T, SourceHighlighterError>;
216
217/// Highlights text regions in a PDF corresponding to retrieved RAG chunks.
218///
219/// Given PDF bytes and a set of `DocumentChunk`s (from the chunker), this
220/// produces a new PDF with highlight annotations over the text regions
221/// that correspond to each chunk.
222pub struct SourceHighlighter;
223
224impl SourceHighlighter {
225    /// Highlight the given chunks in the PDF, returning the modified PDF bytes.
226    ///
227    /// # Arguments
228    ///
229    /// * `pdf_bytes` - The original PDF file bytes
230    /// * `chunks` - Chunks to highlight (with position metadata from the chunker)
231    /// * `style` - Visual style for the highlights
232    ///
233    /// # Returns
234    ///
235    /// The modified PDF bytes with highlight annotations added.
236    pub fn highlight_chunks(
237        pdf_bytes: &[u8],
238        chunks: &[&DocumentChunk],
239        style: HighlightStyle,
240    ) -> SourceHighlighterResult<Vec<u8>> {
241        if chunks.is_empty() {
242            return Ok(pdf_bytes.to_vec());
243        }
244
245        // 1. Parse the PDF
246        let cursor = Cursor::new(pdf_bytes);
247        let reader = crate::parser::PdfReader::new(cursor)
248            .map_err(|e| SourceHighlighterError::TextExtractionFailed(e.to_string()))?;
249        let document = reader.into_document();
250
251        // 2. Extract text with position information
252        let options = ExtractionOptions {
253            preserve_layout: true,
254            ..Default::default()
255        };
256        let extracted_pages = document
257            .extract_text_with_options(options)
258            .map_err(|e| SourceHighlighterError::TextExtractionFailed(e.to_string()))?;
259
260        // 3. Build the position index
261        let index = TextPositionIndex::build(&extracted_pages);
262
263        // 4. For each chunk, find matching fragments and group by page
264        let mut annotations_by_page: HashMap<usize, Vec<Rectangle>> = HashMap::new();
265
266        for chunk in chunks {
267            let start = chunk.metadata.position.start_char;
268            let end = chunk.metadata.position.end_char;
269
270            for frag in index.fragments_for_range(start, end) {
271                annotations_by_page
272                    .entry(frag.page)
273                    .or_default()
274                    .push(frag.to_rectangle());
275            }
276        }
277
278        // 5. Reconstruct the document with annotations
279        let page_count = document
280            .page_count()
281            .map_err(|e| SourceHighlighterError::PageReconstructionFailed(e.to_string()))?;
282
283        let mut output_doc = crate::document::Document::new();
284
285        for page_idx in 0..page_count {
286            let parsed_page = document
287                .get_page(page_idx)
288                .map_err(|e| SourceHighlighterError::PageReconstructionFailed(e.to_string()))?;
289
290            let mut page = crate::page::Page::from_parsed_with_content(&parsed_page, &document)
291                .map_err(|e| SourceHighlighterError::PageReconstructionFailed(e.to_string()))?;
292
293            // Add highlight annotations for this page
294            if let Some(rects) = annotations_by_page.get(&(page_idx as usize)) {
295                for rect in rects {
296                    let highlight =
297                        MarkupAnnotation::highlight(*rect).with_color(style.color.clone());
298                    page.add_annotation(highlight.to_annotation());
299                }
300            }
301
302            output_doc.add_page(page);
303        }
304
305        // 6. Write to bytes
306        output_doc
307            .to_bytes()
308            .map_err(|e| SourceHighlighterError::WriteFailed(e.to_string()))
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    /// Helper: create a TextFragment with given text and position
317    fn make_fragment(text: &str, x: f64, y: f64, width: f64, height: f64) -> TextFragment {
318        TextFragment {
319            text: text.to_string(),
320            x,
321            y,
322            width,
323            height,
324            font_size: 12.0,
325            font_name: None,
326            is_bold: false,
327            is_italic: false,
328            color: None,
329            space_decisions: Vec::new(),
330            mcid: None,
331            struct_tag: None,
332        }
333    }
334
335    /// Helper: create ExtractedText from fragments, building text by joining fragment texts
336    fn make_extracted(fragments: Vec<TextFragment>) -> ExtractedText {
337        let text = fragments
338            .iter()
339            .map(|f| f.text.as_str())
340            .collect::<Vec<_>>()
341            .join(" ");
342        ExtractedText {
343            text,
344            fragments,
345            truncated: false,
346        }
347    }
348
349    #[test]
350    fn test_index_single_fragment() {
351        let page = ExtractedText {
352            text: "Hello".to_string(),
353            fragments: vec![make_fragment("Hello", 100.0, 700.0, 50.0, 12.0)],
354            truncated: false,
355        };
356        let index = TextPositionIndex::build(&[page]);
357
358        assert_eq!(index.len(), 1);
359        let results = index.fragments_for_range(0, 5);
360        assert_eq!(results.len(), 1);
361        assert_eq!(results[0].start_char, 0);
362        assert_eq!(results[0].end_char, 5);
363        assert!((results[0].x - 100.0).abs() < 0.01);
364    }
365
366    #[test]
367    fn test_index_multiple_fragments() {
368        let page = ExtractedText {
369            text: "Hello World Test".to_string(),
370            fragments: vec![
371                make_fragment("Hello", 100.0, 700.0, 50.0, 12.0),
372                make_fragment("World", 160.0, 700.0, 55.0, 12.0),
373                make_fragment("Test", 225.0, 700.0, 40.0, 12.0),
374            ],
375            truncated: false,
376        };
377        let index = TextPositionIndex::build(&[page]);
378
379        assert_eq!(index.len(), 3);
380
381        // Query that overlaps only "World" (chars 6-11)
382        let results = index.fragments_for_range(6, 11);
383        assert_eq!(results.len(), 1);
384        assert!((results[0].x - 160.0).abs() < 0.01);
385    }
386
387    #[test]
388    fn test_index_cross_page() {
389        let page1 = ExtractedText {
390            text: "Page one".to_string(),
391            fragments: vec![make_fragment("Page one", 72.0, 700.0, 80.0, 12.0)],
392            truncated: false,
393        };
394        let page2 = ExtractedText {
395            text: "Page two".to_string(),
396            fragments: vec![make_fragment("Page two", 72.0, 700.0, 80.0, 12.0)],
397            truncated: false,
398        };
399        let index = TextPositionIndex::build(&[page1, page2]);
400
401        assert_eq!(index.len(), 2);
402
403        // Page 1: "Page one" (0..8), separator "\n\n" (8..10), Page 2: "Page two" (10..18)
404        // Query that spans both pages
405        let results = index.fragments_for_range(5, 15);
406        assert_eq!(results.len(), 2);
407        assert_eq!(results[0].page, 0);
408        assert_eq!(results[1].page, 1);
409    }
410
411    #[test]
412    fn test_index_empty_range() {
413        let page = ExtractedText {
414            text: "Hello".to_string(),
415            fragments: vec![make_fragment("Hello", 100.0, 700.0, 50.0, 12.0)],
416            truncated: false,
417        };
418        let index = TextPositionIndex::build(&[page]);
419
420        let results = index.fragments_for_range(2, 2);
421        assert!(results.is_empty(), "Empty range should return no results");
422    }
423
424    #[test]
425    fn test_index_exact_boundary() {
426        let page = ExtractedText {
427            text: "Hello".to_string(),
428            fragments: vec![make_fragment("Hello", 100.0, 700.0, 50.0, 12.0)],
429            truncated: false,
430        };
431        let index = TextPositionIndex::build(&[page]);
432
433        // Exact boundaries [0, 5) should include the fragment
434        let results = index.fragments_for_range(0, 5);
435        assert_eq!(results.len(), 1);
436
437        // Range [5, 10) should NOT include it (fragment ends at 5)
438        let results = index.fragments_for_range(5, 10);
439        assert!(results.is_empty());
440    }
441
442    #[test]
443    fn test_index_no_overlap() {
444        let page = ExtractedText {
445            text: "Hello".to_string(),
446            fragments: vec![make_fragment("Hello", 100.0, 700.0, 50.0, 12.0)],
447            truncated: false,
448        };
449        let index = TextPositionIndex::build(&[page]);
450
451        let results = index.fragments_for_range(100, 200);
452        assert!(
453            results.is_empty(),
454            "Query far beyond text should return nothing"
455        );
456    }
457
458    #[test]
459    fn test_fragment_to_highlight_rect_conversion() {
460        let frag = make_fragment("Test", 100.0, 500.0, 200.0, 15.0);
461        let rect = fragment_to_highlight_rect(&frag);
462
463        assert!((rect.lower_left.x - 100.0).abs() < 0.01);
464        assert!((rect.lower_left.y - 500.0).abs() < 0.01);
465        assert!((rect.upper_right.x - 300.0).abs() < 0.01);
466        assert!((rect.upper_right.y - 515.0).abs() < 0.01);
467    }
468
469    #[test]
470    fn test_index_build_from_extracted() {
471        let fragments = vec![
472            make_fragment("Alpha", 72.0, 750.0, 45.0, 12.0),
473            make_fragment("Beta", 130.0, 750.0, 35.0, 12.0),
474            make_fragment("Gamma", 180.0, 750.0, 50.0, 12.0),
475        ];
476        let page = make_extracted(fragments);
477        let index = TextPositionIndex::build(&[page]);
478
479        assert_eq!(index.len(), 3);
480        // All entries should have page 0
481        for entry in index.entries() {
482            assert_eq!(entry.page, 0);
483        }
484    }
485
486    #[test]
487    fn test_index_fragments_grouped_by_page() {
488        let page1 = ExtractedText {
489            text: "AAA".to_string(),
490            fragments: vec![make_fragment("AAA", 72.0, 700.0, 30.0, 12.0)],
491            truncated: false,
492        };
493        let page2 = ExtractedText {
494            text: "BBB CCC".to_string(),
495            fragments: vec![
496                make_fragment("BBB", 72.0, 700.0, 30.0, 12.0),
497                make_fragment("CCC", 110.0, 700.0, 30.0, 12.0),
498            ],
499            truncated: false,
500        };
501        let index = TextPositionIndex::build(&[page1, page2]);
502
503        // Query all
504        let all = index.fragments_for_range(0, 100);
505        assert_eq!(all.len(), 3);
506
507        let page0_frags: Vec<_> = all.iter().filter(|f| f.page == 0).collect();
508        let page1_frags: Vec<_> = all.iter().filter(|f| f.page == 1).collect();
509        assert_eq!(page0_frags.len(), 1);
510        assert_eq!(page1_frags.len(), 2);
511    }
512
513    #[test]
514    fn test_index_page_offsets() {
515        let page1 = ExtractedText {
516            text: "ABCDE".to_string(), // 5 chars
517            fragments: vec![make_fragment("ABCDE", 72.0, 700.0, 50.0, 12.0)],
518            truncated: false,
519        };
520        let page2 = ExtractedText {
521            text: "FGHIJ".to_string(), // 5 chars
522            fragments: vec![make_fragment("FGHIJ", 72.0, 700.0, 50.0, 12.0)],
523            truncated: false,
524        };
525        let page3 = ExtractedText {
526            text: "KLMNO".to_string(), // 5 chars
527            fragments: vec![make_fragment("KLMNO", 72.0, 700.0, 50.0, 12.0)],
528            truncated: false,
529        };
530        let index = TextPositionIndex::build(&[page1, page2, page3]);
531
532        // Page 0 starts at 0
533        assert_eq!(index.page_offset(0), Some(0));
534        // Page 1 starts at 5 + 2 ("\n\n") = 7
535        assert_eq!(index.page_offset(1), Some(7));
536        // Page 2 starts at 7 + 5 + 2 = 14
537        assert_eq!(index.page_offset(2), Some(14));
538        // Page 3 doesn't exist
539        assert_eq!(index.page_offset(3), None);
540    }
541
542    #[test]
543    fn test_index_whitespace_only_fragments() {
544        let page = ExtractedText {
545            text: "Hello World".to_string(),
546            fragments: vec![
547                make_fragment("Hello", 72.0, 700.0, 50.0, 12.0),
548                make_fragment("", 130.0, 700.0, 10.0, 12.0), // empty fragment
549                make_fragment("World", 145.0, 700.0, 55.0, 12.0),
550            ],
551            truncated: false,
552        };
553        let index = TextPositionIndex::build(&[page]);
554
555        // Empty fragment should be skipped
556        assert_eq!(index.len(), 2);
557        let results = index.fragments_for_range(0, 20);
558        assert_eq!(results.len(), 2);
559    }
560
561    #[test]
562    fn test_index_preserves_order() {
563        let page = ExtractedText {
564            text: "AAA BBB CCC DDD".to_string(),
565            fragments: vec![
566                make_fragment("AAA", 72.0, 700.0, 30.0, 12.0),
567                make_fragment("BBB", 110.0, 700.0, 30.0, 12.0),
568                make_fragment("CCC", 150.0, 700.0, 30.0, 12.0),
569                make_fragment("DDD", 190.0, 700.0, 30.0, 12.0),
570            ],
571            truncated: false,
572        };
573        let index = TextPositionIndex::build(&[page]);
574
575        let entries = index.entries();
576        for i in 1..entries.len() {
577            assert!(
578                entries[i].start_char >= entries[i - 1].start_char,
579                "Entries should be ordered by start_char"
580            );
581        }
582    }
583}