1use 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
16const PAGE_SEPARATOR: &str = "\n\n";
18
19#[derive(Debug, Clone)]
22pub struct IndexedFragment {
23 pub page: usize,
25 pub start_char: usize,
27 pub end_char: usize,
29 pub x: f64,
31 pub y: f64,
33 pub width: f64,
35 pub height: f64,
37}
38
39impl IndexedFragment {
40 pub fn to_rectangle(&self) -> Rectangle {
42 Rectangle::from_position_and_size(self.x, self.y, self.width, self.height)
43 }
44}
45
46#[derive(Debug)]
52pub struct TextPositionIndex {
53 entries: Vec<IndexedFragment>,
55 page_offsets: Vec<usize>,
57}
58
59impl TextPositionIndex {
60 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 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 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 search_from = local_offset + frag_len;
100 }
101 }
102
103 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 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 pub fn page_offset(&self, page: usize) -> Option<usize> {
130 self.page_offsets.get(page).copied()
131 }
132
133 pub fn len(&self) -> usize {
135 self.entries.len()
136 }
137
138 pub fn is_empty(&self) -> bool {
140 self.entries.is_empty()
141 }
142
143 pub fn entries(&self) -> &[IndexedFragment] {
145 &self.entries
146 }
147}
148
149pub 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#[derive(Debug, Clone)]
163pub struct HighlightStyle {
164 pub color: Color,
166 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), opacity: 0.5,
175 }
176 }
177}
178
179impl HighlightStyle {
180 pub fn new() -> Self {
182 Self::default()
183 }
184
185 pub fn with_color(mut self, color: Color) -> Self {
187 self.color = color;
188 self
189 }
190
191 pub fn with_opacity(mut self, opacity: f64) -> Self {
193 self.opacity = opacity;
194 self
195 }
196}
197
198#[derive(Debug, thiserror::Error)]
200pub enum SourceHighlighterError {
201 #[error("text extraction failed: {0}")]
203 TextExtractionFailed(String),
204
205 #[error("page reconstruction failed: {0}")]
207 PageReconstructionFailed(String),
208
209 #[error("write failed: {0}")]
211 WriteFailed(String),
212}
213
214pub type SourceHighlighterResult<T> = Result<T, SourceHighlighterError>;
216
217pub struct SourceHighlighter;
223
224impl SourceHighlighter {
225 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 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 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 let index = TextPositionIndex::build(&extracted_pages);
262
263 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 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 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 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 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 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 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 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 let results = index.fragments_for_range(0, 5);
435 assert_eq!(results.len(), 1);
436
437 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 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 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(), 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(), 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(), 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 assert_eq!(index.page_offset(0), Some(0));
534 assert_eq!(index.page_offset(1), Some(7));
536 assert_eq!(index.page_offset(2), Some(14));
538 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), make_fragment("World", 145.0, 700.0, 55.0, 12.0),
550 ],
551 truncated: false,
552 };
553 let index = TextPositionIndex::build(&[page]);
554
555 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}