Skip to main content

oxidize_pdf/streaming/
text_streamer.rs

1//! Text streaming for incremental text extraction
2//!
3//! Extracts text from PDF content streams incrementally, processing
4//! text operations as they are encountered.
5
6use crate::error::Result;
7use crate::parser::content::{ContentOperation, ContentParser};
8use std::collections::VecDeque;
9
10/// A chunk of extracted text with position information
11#[derive(Debug, Clone)]
12pub struct TextChunk {
13    /// The extracted text
14    pub text: String,
15    /// X position on the page
16    pub x: f64,
17    /// Y position on the page
18    pub y: f64,
19    /// Font size
20    pub font_size: f64,
21    /// Font name (if known)
22    pub font_name: Option<String>,
23}
24
25/// Options for text streaming
26#[derive(Debug, Clone)]
27pub struct TextStreamOptions {
28    /// Minimum text size to include
29    pub min_font_size: f64,
30    /// Maximum buffer size for text chunks
31    pub max_buffer_size: usize,
32    /// Whether to preserve formatting
33    pub preserve_formatting: bool,
34    /// Whether to sort by position
35    pub sort_by_position: bool,
36}
37
38impl Default for TextStreamOptions {
39    fn default() -> Self {
40        Self {
41            min_font_size: 0.0,
42            max_buffer_size: 1024 * 1024, // 1MB
43            preserve_formatting: true,
44            sort_by_position: true,
45        }
46    }
47}
48
49/// Streams text from PDF content
50pub struct TextStreamer {
51    options: TextStreamOptions,
52    buffer: VecDeque<TextChunk>,
53    current_font: Option<String>,
54    current_font_size: f64,
55    current_x: f64,
56    current_y: f64,
57    /// Text leading, in unscaled text-space units; consumed by `T*`, `'`, `"`
58    /// and set by `TL`/`TD`.
59    current_leading: f64,
60}
61
62impl TextStreamer {
63    /// Create a new text streamer
64    pub fn new(options: TextStreamOptions) -> Self {
65        Self {
66            options,
67            buffer: VecDeque::new(),
68            current_font: None,
69            current_font_size: 12.0,
70            current_x: 0.0,
71            current_y: 0.0,
72            current_leading: 0.0,
73        }
74    }
75
76    /// Emit one text-showing operator's bytes as a chunk at the current
77    /// position, honoring the minimum-font-size filter.
78    fn emit_text(&self, bytes: &[u8], chunks: &mut Vec<TextChunk>) {
79        if self.current_font_size < self.options.min_font_size {
80            return;
81        }
82        chunks.push(TextChunk {
83            text: String::from_utf8_lossy(bytes).to_string(),
84            x: self.current_x,
85            y: self.current_y,
86            font_size: self.current_font_size,
87            font_name: self.current_font.clone(),
88        });
89    }
90
91    /// Process a content stream chunk
92    pub fn process_chunk(&mut self, data: &[u8]) -> Result<Vec<TextChunk>> {
93        let operations = ContentParser::parse(data)
94            .map_err(|e| crate::error::PdfError::ParseError(e.to_string()))?;
95
96        let mut chunks = Vec::new();
97
98        for op in operations {
99            match op {
100                ContentOperation::SetFont(name, size) => {
101                    self.current_font = Some(name);
102                    self.current_font_size = size as f64;
103                }
104                // Td: move to the start of the next line, offset from the
105                // current line origin (§9.4.2).
106                ContentOperation::MoveText(x, y) => {
107                    self.current_x += x as f64;
108                    self.current_y += y as f64;
109                }
110                // TD: like Td, and also set the leading to -ty (§9.4.2). Without
111                // this and the operators below, every line placed by anything
112                // other than Td landed on the previous line's baseline (#453).
113                ContentOperation::MoveTextSetLeading(x, y) => {
114                    self.current_leading = -(y as f64);
115                    self.current_x += x as f64;
116                    self.current_y += y as f64;
117                }
118                // TL: set the leading consumed by T*, ', and ".
119                ContentOperation::SetLeading(leading) => {
120                    self.current_leading = leading as f64;
121                }
122                // T*: move down one leading to the next line (§9.4.2).
123                ContentOperation::NextLine => {
124                    self.current_y -= self.current_leading;
125                }
126                // Tm: set the text (line) matrix absolutely; this streamer tracks
127                // only translation, which is its origin (§9.4.2).
128                ContentOperation::SetTextMatrix(_a, _b, _c, _d, e, f) => {
129                    self.current_x = e as f64;
130                    self.current_y = f as f64;
131                }
132                ContentOperation::ShowText(bytes) => {
133                    self.emit_text(&bytes, &mut chunks);
134                }
135                // TJ: an array of strings and numeric position adjustments. The
136                // adjustments nudge glyphs horizontally within the line; this
137                // streamer does not measure glyph advances, so it concatenates
138                // the strings at the line position rather than dropping them.
139                ContentOperation::ShowTextArray(elements) => {
140                    let mut text = Vec::new();
141                    for el in elements {
142                        if let crate::parser::content::TextElement::Text(bytes) = el {
143                            text.extend_from_slice(&bytes);
144                        }
145                    }
146                    self.emit_text(&text, &mut chunks);
147                }
148                // ': move to the next line, then show (§9.4.3).
149                ContentOperation::NextLineShowText(bytes) => {
150                    self.current_y -= self.current_leading;
151                    self.emit_text(&bytes, &mut chunks);
152                }
153                // ": set word/char spacing, move to the next line, then show. The
154                // spacings affect glyph advance, which this streamer does not
155                // track; the line move and the text must not be lost (§9.4.3).
156                ContentOperation::SetSpacingNextLineShowText(_aw, _ac, bytes) => {
157                    self.current_y -= self.current_leading;
158                    self.emit_text(&bytes, &mut chunks);
159                }
160                ContentOperation::BeginText => {
161                    self.current_x = 0.0;
162                    self.current_y = 0.0;
163                }
164                _ => {} // Non-text operators do not affect extraction.
165            }
166        }
167
168        // Add to buffer if needed
169        for chunk in &chunks {
170            self.buffer.push_back(chunk.clone());
171        }
172
173        // Check buffer size
174        self.check_buffer_size();
175
176        Ok(chunks)
177    }
178
179    /// Get all buffered text chunks
180    pub fn get_buffered_chunks(&self) -> Vec<TextChunk> {
181        self.buffer.iter().cloned().collect()
182    }
183
184    /// Clear the buffer
185    pub fn clear_buffer(&mut self) {
186        self.buffer.clear();
187    }
188
189    /// Extract text as a single string
190    pub fn extract_text(&self) -> String {
191        let mut chunks = self.get_buffered_chunks();
192
193        if self.options.sort_by_position {
194            // Sort by Y position (top to bottom), then X (left to right)
195            chunks.sort_by(|a, b| b.y.total_cmp(&a.y).then(a.x.total_cmp(&b.x)));
196        }
197
198        chunks
199            .into_iter()
200            .map(|chunk| chunk.text)
201            .collect::<Vec<_>>()
202            .join(" ")
203    }
204
205    fn check_buffer_size(&mut self) {
206        let total_size: usize = self.buffer.iter().map(|chunk| chunk.text.len()).sum();
207
208        // Remove oldest chunks if buffer is too large
209        while total_size > self.options.max_buffer_size && !self.buffer.is_empty() {
210            self.buffer.pop_front();
211        }
212    }
213}
214
215/// Stream text from multiple content streams
216pub fn stream_text<F>(content_streams: Vec<Vec<u8>>, mut callback: F) -> Result<()>
217where
218    F: FnMut(TextChunk) -> Result<()>,
219{
220    let mut streamer = TextStreamer::new(TextStreamOptions::default());
221
222    for stream in content_streams {
223        let chunks = streamer.process_chunk(&stream)?;
224        for chunk in chunks {
225            callback(chunk)?;
226        }
227    }
228
229    Ok(())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_text_chunk() {
238        let chunk = TextChunk {
239            text: "Hello".to_string(),
240            x: 100.0,
241            y: 700.0,
242            font_size: 12.0,
243            font_name: Some("Helvetica".to_string()),
244        };
245
246        assert_eq!(chunk.text, "Hello");
247        assert_eq!(chunk.x, 100.0);
248        assert_eq!(chunk.y, 700.0);
249        assert_eq!(chunk.font_size, 12.0);
250        assert_eq!(chunk.font_name, Some("Helvetica".to_string()));
251    }
252
253    #[test]
254    fn test_text_stream_options_default() {
255        let options = TextStreamOptions::default();
256        assert_eq!(options.min_font_size, 0.0);
257        assert_eq!(options.max_buffer_size, 1024 * 1024);
258        assert!(options.preserve_formatting);
259        assert!(options.sort_by_position);
260    }
261
262    #[test]
263    fn test_text_streamer_creation() {
264        let options = TextStreamOptions::default();
265        let streamer = TextStreamer::new(options);
266
267        assert!(streamer.buffer.is_empty());
268        assert_eq!(streamer.current_font_size, 12.0);
269        assert_eq!(streamer.current_x, 0.0);
270        assert_eq!(streamer.current_y, 0.0);
271    }
272
273    #[test]
274    fn test_process_chunk_text() {
275        let mut streamer = TextStreamer::new(TextStreamOptions::default());
276
277        // Simple text showing operation
278        let content = b"BT /F1 14 Tf 100 700 Td (Hello World) Tj ET";
279        let chunks = streamer.process_chunk(content).unwrap();
280
281        assert!(!chunks.is_empty());
282        assert_eq!(chunks[0].text, "Hello World");
283        assert_eq!(chunks[0].font_size, 14.0);
284    }
285
286    #[test]
287    fn test_min_font_size_filter() {
288        let mut options = TextStreamOptions::default();
289        options.min_font_size = 10.0;
290        let mut streamer = TextStreamer::new(options);
291
292        // Text with small font (8pt) - should be filtered out
293        let content = b"BT /F1 8 Tf 100 700 Td (Small Text) Tj ET";
294        let chunks = streamer.process_chunk(content).unwrap();
295        assert!(chunks.is_empty());
296
297        // Text with large font (12pt) - should be included
298        let content = b"BT /F1 12 Tf 100 650 Td (Large Text) Tj ET";
299        let chunks = streamer.process_chunk(content).unwrap();
300        assert_eq!(chunks.len(), 1);
301        assert_eq!(chunks[0].text, "Large Text");
302    }
303
304    #[test]
305    fn test_extract_text_sorted() {
306        let mut streamer = TextStreamer::new(TextStreamOptions::default());
307
308        // Add text in random order
309        streamer.buffer.push_back(TextChunk {
310            text: "Bottom".to_string(),
311            x: 100.0,
312            y: 100.0,
313            font_size: 12.0,
314            font_name: None,
315        });
316
317        streamer.buffer.push_back(TextChunk {
318            text: "Top".to_string(),
319            x: 100.0,
320            y: 700.0,
321            font_size: 12.0,
322            font_name: None,
323        });
324
325        streamer.buffer.push_back(TextChunk {
326            text: "Middle".to_string(),
327            x: 100.0,
328            y: 400.0,
329            font_size: 12.0,
330            font_name: None,
331        });
332
333        let text = streamer.extract_text();
334        assert_eq!(text, "Top Middle Bottom");
335    }
336
337    #[test]
338    fn test_buffer_management() {
339        let mut options = TextStreamOptions::default();
340        options.max_buffer_size = 10; // Very small buffer
341        let mut streamer = TextStreamer::new(options);
342
343        // Add chunks that exceed buffer size
344        for i in 0..5 {
345            streamer.buffer.push_back(TextChunk {
346                text: format!("Text{i}"),
347                x: 0.0,
348                y: 0.0,
349                font_size: 12.0,
350                font_name: None,
351            });
352        }
353
354        streamer.check_buffer_size();
355
356        // Buffer should be limited
357        assert!(streamer.buffer.len() < 5);
358    }
359
360    #[test]
361    fn test_stream_text_function() {
362        let content1 = b"BT /F1 12 Tf 100 700 Td (Page 1) Tj ET".to_vec();
363        let content2 = b"BT /F1 12 Tf 100 650 Td (Page 2) Tj ET".to_vec();
364        let streams = vec![content1, content2];
365
366        let mut collected = Vec::new();
367        stream_text(streams, |chunk| {
368            collected.push(chunk.text);
369            Ok(())
370        })
371        .unwrap();
372
373        assert_eq!(collected.len(), 2);
374        assert_eq!(collected[0], "Page 1");
375        assert_eq!(collected[1], "Page 2");
376    }
377
378    #[test]
379    fn test_text_chunk_debug_clone() {
380        let chunk = TextChunk {
381            text: "Test".to_string(),
382            x: 50.0,
383            y: 100.0,
384            font_size: 10.0,
385            font_name: Some("Arial".to_string()),
386        };
387
388        let debug_str = format!("{chunk:?}");
389        assert!(debug_str.contains("TextChunk"));
390        assert!(debug_str.contains("Test"));
391
392        let cloned = chunk.clone();
393        assert_eq!(cloned.text, chunk.text);
394        assert_eq!(cloned.x, chunk.x);
395        assert_eq!(cloned.y, chunk.y);
396        assert_eq!(cloned.font_size, chunk.font_size);
397        assert_eq!(cloned.font_name, chunk.font_name);
398    }
399
400    #[test]
401    fn test_text_stream_options_custom() {
402        let options = TextStreamOptions {
403            min_font_size: 8.0,
404            max_buffer_size: 2048,
405            preserve_formatting: false,
406            sort_by_position: false,
407        };
408
409        assert_eq!(options.min_font_size, 8.0);
410        assert_eq!(options.max_buffer_size, 2048);
411        assert!(!options.preserve_formatting);
412        assert!(!options.sort_by_position);
413    }
414
415    #[test]
416    fn test_text_stream_options_debug_clone() {
417        let options = TextStreamOptions::default();
418
419        let debug_str = format!("{options:?}");
420        assert!(debug_str.contains("TextStreamOptions"));
421
422        let cloned = options.clone();
423        assert_eq!(cloned.min_font_size, options.min_font_size);
424        assert_eq!(cloned.max_buffer_size, options.max_buffer_size);
425        assert_eq!(cloned.preserve_formatting, options.preserve_formatting);
426        assert_eq!(cloned.sort_by_position, options.sort_by_position);
427    }
428
429    #[test]
430    fn test_text_streamer_process_empty_chunk() {
431        let mut streamer = TextStreamer::new(TextStreamOptions::default());
432        let chunks = streamer.process_chunk(b"").unwrap();
433        assert!(chunks.is_empty());
434    }
435
436    #[test]
437    fn test_text_streamer_process_invalid_content() {
438        let mut streamer = TextStreamer::new(TextStreamOptions::default());
439        // Invalid PDF content should be handled gracefully
440        let content = b"Not valid PDF content";
441        let result = streamer.process_chunk(content);
442        // Should either succeed with no chunks or return an error
443        match result {
444            Ok(chunks) => assert!(chunks.is_empty()),
445            Err(_) => {} // Error is also acceptable
446        }
447    }
448
449    #[test]
450    fn test_text_streamer_font_tracking() {
451        let mut streamer = TextStreamer::new(TextStreamOptions::default());
452
453        // Set font operation
454        let content = b"BT /Helvetica-Bold 16 Tf ET";
455        let _ = streamer.process_chunk(content).unwrap();
456
457        assert_eq!(streamer.current_font, Some("Helvetica-Bold".to_string()));
458        assert_eq!(streamer.current_font_size, 16.0);
459    }
460
461    #[test]
462    fn test_text_streamer_position_tracking() {
463        let mut streamer = TextStreamer::new(TextStreamOptions::default());
464
465        // Move text position
466        let content = b"BT 50 100 Td ET";
467        let _ = streamer.process_chunk(content).unwrap();
468
469        assert_eq!(streamer.current_x, 50.0);
470        assert_eq!(streamer.current_y, 100.0);
471    }
472
473    #[test]
474    fn test_text_streamer_begin_text_resets_position() {
475        let mut streamer = TextStreamer::new(TextStreamOptions::default());
476
477        // Set position
478        streamer.current_x = 100.0;
479        streamer.current_y = 200.0;
480
481        // BeginText should reset position
482        let content = b"BT ET";
483        let _ = streamer.process_chunk(content).unwrap();
484
485        assert_eq!(streamer.current_x, 0.0);
486        assert_eq!(streamer.current_y, 0.0);
487    }
488
489    #[test]
490    fn test_text_streamer_clear_buffer() {
491        let mut streamer = TextStreamer::new(TextStreamOptions::default());
492
493        // Add some chunks
494        streamer.buffer.push_back(TextChunk {
495            text: "Chunk1".to_string(),
496            x: 0.0,
497            y: 0.0,
498            font_size: 12.0,
499            font_name: None,
500        });
501        streamer.buffer.push_back(TextChunk {
502            text: "Chunk2".to_string(),
503            x: 0.0,
504            y: 0.0,
505            font_size: 12.0,
506            font_name: None,
507        });
508
509        assert_eq!(streamer.buffer.len(), 2);
510
511        streamer.clear_buffer();
512        assert!(streamer.buffer.is_empty());
513    }
514
515    #[test]
516    fn test_text_streamer_get_buffered_chunks() {
517        let mut streamer = TextStreamer::new(TextStreamOptions::default());
518
519        let chunk1 = TextChunk {
520            text: "First".to_string(),
521            x: 10.0,
522            y: 20.0,
523            font_size: 14.0,
524            font_name: Some("Times".to_string()),
525        };
526        let chunk2 = TextChunk {
527            text: "Second".to_string(),
528            x: 30.0,
529            y: 40.0,
530            font_size: 16.0,
531            font_name: Some("Arial".to_string()),
532        };
533
534        streamer.buffer.push_back(chunk1);
535        streamer.buffer.push_back(chunk2);
536
537        let chunks = streamer.get_buffered_chunks();
538        assert_eq!(chunks.len(), 2);
539        assert_eq!(chunks[0].text, "First");
540        assert_eq!(chunks[1].text, "Second");
541    }
542
543    #[test]
544    fn test_extract_text_no_sorting() {
545        let mut options = TextStreamOptions::default();
546        options.sort_by_position = false;
547        let mut streamer = TextStreamer::new(options);
548
549        // Add text in specific order
550        streamer.buffer.push_back(TextChunk {
551            text: "First".to_string(),
552            x: 200.0,
553            y: 100.0,
554            font_size: 12.0,
555            font_name: None,
556        });
557        streamer.buffer.push_back(TextChunk {
558            text: "Second".to_string(),
559            x: 100.0,
560            y: 200.0,
561            font_size: 12.0,
562            font_name: None,
563        });
564
565        let text = streamer.extract_text();
566        assert_eq!(text, "First Second"); // Should maintain insertion order
567    }
568
569    #[test]
570    fn test_extract_text_horizontal_sorting() {
571        let mut streamer = TextStreamer::new(TextStreamOptions::default());
572
573        // Add text on same line, different X positions
574        streamer.buffer.push_back(TextChunk {
575            text: "Right".to_string(),
576            x: 300.0,
577            y: 500.0,
578            font_size: 12.0,
579            font_name: None,
580        });
581        streamer.buffer.push_back(TextChunk {
582            text: "Left".to_string(),
583            x: 100.0,
584            y: 500.0,
585            font_size: 12.0,
586            font_name: None,
587        });
588        streamer.buffer.push_back(TextChunk {
589            text: "Middle".to_string(),
590            x: 200.0,
591            y: 500.0,
592            font_size: 12.0,
593            font_name: None,
594        });
595
596        let text = streamer.extract_text();
597        assert_eq!(text, "Left Middle Right");
598    }
599
600    #[test]
601    fn test_check_buffer_size_edge_cases() {
602        let mut options = TextStreamOptions::default();
603        options.max_buffer_size = 20;
604        let mut streamer = TextStreamer::new(options);
605
606        // Add chunk that exactly fills buffer
607        streamer.buffer.push_back(TextChunk {
608            text: "a".repeat(20),
609            x: 0.0,
610            y: 0.0,
611            font_size: 12.0,
612            font_name: None,
613        });
614
615        streamer.check_buffer_size();
616        assert_eq!(streamer.buffer.len(), 1); // Should keep the chunk
617
618        // Add another chunk to exceed limit
619        streamer.buffer.push_back(TextChunk {
620            text: "b".to_string(),
621            x: 0.0,
622            y: 0.0,
623            font_size: 12.0,
624            font_name: None,
625        });
626
627        streamer.check_buffer_size();
628        // Should have removed the first chunk
629        assert!(streamer.buffer.len() <= 1);
630    }
631
632    #[test]
633    fn test_stream_text_with_error_callback() {
634        let content = b"BT /F1 12 Tf 100 700 Td (Test) Tj ET".to_vec();
635        let streams = vec![content];
636
637        let result = stream_text(streams, |_chunk| {
638            Err(crate::error::PdfError::ParseError("Test error".to_string()))
639        });
640
641        assert!(result.is_err());
642    }
643
644    #[test]
645    fn test_stream_text_empty_streams() {
646        let streams: Vec<Vec<u8>> = vec![];
647
648        let mut collected = Vec::new();
649        stream_text(streams, |chunk| {
650            collected.push(chunk);
651            Ok(())
652        })
653        .unwrap();
654
655        assert!(collected.is_empty());
656    }
657
658    #[test]
659    fn test_text_chunk_without_font_name() {
660        let chunk = TextChunk {
661            text: "No Font".to_string(),
662            x: 0.0,
663            y: 0.0,
664            font_size: 12.0,
665            font_name: None,
666        };
667
668        assert_eq!(chunk.font_name, None);
669    }
670
671    #[test]
672    fn test_process_chunk_multiple_operations() {
673        let mut streamer = TextStreamer::new(TextStreamOptions::default());
674
675        // Content with multiple text operations
676        let content = b"BT /F1 10 Tf 100 700 Td (First) Tj 50 0 Td (Second) Tj ET";
677        let chunks = streamer.process_chunk(content).unwrap();
678
679        assert_eq!(chunks.len(), 2);
680        assert_eq!(chunks[0].text, "First");
681        assert_eq!(chunks[1].text, "Second");
682        assert_eq!(chunks[0].x, 100.0);
683        assert_eq!(chunks[1].x, 150.0); // 100 + 50
684    }
685
686    #[test]
687    fn test_buffer_size_calculation() {
688        let mut options = TextStreamOptions::default();
689        options.max_buffer_size = 100;
690        let mut streamer = TextStreamer::new(options);
691
692        // Add chunks with known sizes
693        for _i in 0..10 {
694            streamer.buffer.push_back(TextChunk {
695                text: "1234567890".to_string(), // 10 bytes each
696                x: 0.0,
697                y: 0.0,
698                font_size: 12.0,
699                font_name: None,
700            });
701        }
702
703        // Total size is 100 bytes
704        streamer.check_buffer_size();
705
706        // Add one more to exceed
707        streamer.buffer.push_back(TextChunk {
708            text: "x".to_string(),
709            x: 0.0,
710            y: 0.0,
711            font_size: 12.0,
712            font_name: None,
713        });
714
715        streamer.check_buffer_size();
716
717        // Should have removed oldest chunks
718        let total_size: usize = streamer.buffer.iter().map(|c| c.text.len()).sum();
719        assert!(total_size <= 100);
720    }
721
722    #[test]
723    fn test_text_chunk_extreme_positions() {
724        let chunk = TextChunk {
725            text: "Extreme".to_string(),
726            x: f64::MAX,
727            y: f64::MIN,
728            font_size: 0.1,
729            font_name: Some("TinyFont".to_string()),
730        };
731
732        assert_eq!(chunk.x, f64::MAX);
733        assert_eq!(chunk.y, f64::MIN);
734        assert_eq!(chunk.font_size, 0.1);
735    }
736
737    #[test]
738    fn test_text_streamer_accumulated_position() {
739        let mut streamer = TextStreamer::new(TextStreamOptions::default());
740
741        // Multiple move operations should accumulate
742        let content = b"BT 10 20 Td 5 10 Td 15 -5 Td ET";
743        let _ = streamer.process_chunk(content).unwrap();
744
745        assert_eq!(streamer.current_x, 30.0); // 10 + 5 + 15
746        assert_eq!(streamer.current_y, 25.0); // 20 + 10 + (-5)
747    }
748
749    #[test]
750    fn test_process_chunk_with_multiple_font_changes() {
751        let mut streamer = TextStreamer::new(TextStreamOptions::default());
752
753        let content = b"BT /F1 10 Tf (Small) Tj /F2 24 Tf (Large) Tj /F3 16 Tf (Medium) Tj ET";
754        let chunks = streamer.process_chunk(content).unwrap();
755
756        assert_eq!(chunks.len(), 3);
757        assert_eq!(chunks[0].font_size, 10.0);
758        assert_eq!(chunks[1].font_size, 24.0);
759        assert_eq!(chunks[2].font_size, 16.0);
760    }
761
762    #[test]
763    fn test_empty_text_operations() {
764        let mut streamer = TextStreamer::new(TextStreamOptions::default());
765
766        // Empty text operations
767        let content = b"BT /F1 12 Tf () Tj ( ) Tj ET";
768        let chunks = streamer.process_chunk(content).unwrap();
769
770        assert_eq!(chunks.len(), 2);
771        assert!(chunks[0].text.is_empty());
772        assert_eq!(chunks[1].text, " ");
773    }
774
775    #[test]
776    fn test_text_with_special_characters() {
777        let mut streamer = TextStreamer::new(TextStreamOptions::default());
778
779        let content = b"BT /F1 12 Tf (\xC3\xA9\xC3\xA0\xC3\xB1) Tj ET"; // UTF-8: éàñ
780        let chunks = streamer.process_chunk(content).unwrap();
781
782        assert!(!chunks.is_empty());
783        // The text should contain the special characters (lossy conversion)
784        assert!(!chunks[0].text.is_empty());
785    }
786
787    #[test]
788    fn test_sorting_with_equal_positions() {
789        let mut streamer = TextStreamer::new(TextStreamOptions::default());
790
791        // Add chunks with same position
792        for i in 0..3 {
793            streamer.buffer.push_back(TextChunk {
794                text: format!("Text{i}"),
795                x: 100.0,
796                y: 100.0,
797                font_size: 12.0,
798                font_name: None,
799            });
800        }
801
802        let text = streamer.extract_text();
803        // Should maintain order when positions are equal
804        assert!(text.contains("Text0"));
805        assert!(text.contains("Text1"));
806        assert!(text.contains("Text2"));
807    }
808
809    #[test]
810    fn test_max_buffer_size_zero() {
811        let mut options = TextStreamOptions::default();
812        options.max_buffer_size = 0;
813        let mut streamer = TextStreamer::new(options);
814
815        streamer.buffer.push_back(TextChunk {
816            text: "Should be removed".to_string(),
817            x: 0.0,
818            y: 0.0,
819            font_size: 12.0,
820            font_name: None,
821        });
822
823        streamer.check_buffer_size();
824        assert!(streamer.buffer.is_empty());
825    }
826
827    #[test]
828    fn test_font_name_with_spaces() {
829        let mut streamer = TextStreamer::new(TextStreamOptions::default());
830
831        let content = b"BT /Times New Roman 14 Tf ET";
832        let result = streamer.process_chunk(content);
833
834        // Best-effort recovery (issue #319): "New"/"Roman" are unknown
835        // operators and the resulting `Tf` is missing its font-name operand.
836        // The malformed directives are skipped instead of aborting the chunk,
837        // so processing succeeds...
838        assert!(result.is_ok());
839
840        // ...but the malformed font directive must NOT take effect: the font
841        // and size stay at their defaults.
842        assert_eq!(streamer.current_font, None);
843        assert_eq!(streamer.current_font_size, 12.0);
844    }
845
846    #[test]
847    fn test_stream_text_with_mixed_content() {
848        let content1 = b"BT /F1 8 Tf (Small) Tj ET".to_vec();
849        let content2 = b"Invalid content".to_vec();
850        let content3 = b"BT /F2 16 Tf (Large) Tj ET".to_vec();
851        let streams = vec![content1, content2, content3];
852
853        let mut collected = Vec::new();
854        let result = stream_text(streams, |chunk| {
855            collected.push(chunk.text);
856            Ok(())
857        });
858
859        // Should handle mixed valid/invalid content
860        assert!(result.is_ok() || result.is_err());
861        // Check that collected is valid (len() is always >= 0 for Vec)
862    }
863
864    #[test]
865    fn test_preserve_formatting_option() {
866        let mut options = TextStreamOptions::default();
867        options.preserve_formatting = false;
868        let streamer = TextStreamer::new(options.clone());
869
870        assert!(!streamer.options.preserve_formatting);
871        assert_eq!(streamer.options.min_font_size, options.min_font_size);
872    }
873
874    #[test]
875    fn test_very_large_font_size() {
876        let mut streamer = TextStreamer::new(TextStreamOptions::default());
877
878        let content = b"BT /F1 9999 Tf (Huge) Tj ET";
879        let chunks = streamer.process_chunk(content).unwrap();
880
881        assert!(!chunks.is_empty());
882        assert_eq!(chunks[0].font_size, 9999.0);
883        assert_eq!(chunks[0].text, "Huge");
884    }
885
886    #[test]
887    fn test_negative_font_size() {
888        let mut options = TextStreamOptions::default();
889        options.min_font_size = -10.0; // Allow negative sizes
890        let mut streamer = TextStreamer::new(options);
891
892        streamer.current_font_size = -5.0;
893        let content = b"BT (Negative) Tj ET";
894        let chunks = streamer.process_chunk(content).unwrap();
895
896        assert!(!chunks.is_empty());
897        assert_eq!(chunks[0].font_size, -5.0);
898    }
899
900    #[test]
901    fn test_text_position_nan_handling() {
902        let mut streamer = TextStreamer::new(TextStreamOptions::default());
903
904        // Create chunks with NaN positions
905        let chunk1 = TextChunk {
906            text: "NaN X".to_string(),
907            x: f64::NAN,
908            y: 100.0,
909            font_size: 12.0,
910            font_name: None,
911        };
912        let chunk2 = TextChunk {
913            text: "NaN Y".to_string(),
914            x: 100.0,
915            y: f64::NAN,
916            font_size: 12.0,
917            font_name: None,
918        };
919
920        streamer.buffer.push_back(chunk1);
921        streamer.buffer.push_back(chunk2);
922
923        // extract_text should handle NaN gracefully
924        let text = streamer.extract_text();
925        assert!(text.contains("NaN"));
926    }
927
928    #[test]
929    fn test_buffer_with_different_font_names() {
930        let mut streamer = TextStreamer::new(TextStreamOptions::default());
931
932        let fonts = ["Arial", "Times", "Courier", "Helvetica"];
933        for (i, font) in fonts.iter().enumerate() {
934            streamer.buffer.push_back(TextChunk {
935                text: format!("Font{i}"),
936                x: 0.0,
937                y: 0.0,
938                font_size: 12.0,
939                font_name: Some((*font).to_string()),
940            });
941        }
942
943        let chunks = streamer.get_buffered_chunks();
944        assert_eq!(chunks.len(), 4);
945        for (i, chunk) in chunks.iter().enumerate() {
946            assert_eq!(chunk.font_name, Some(fonts[i].to_string()));
947        }
948    }
949
950    #[test]
951    fn test_process_chunk_error_propagation() {
952        let mut streamer = TextStreamer::new(TextStreamOptions::default());
953
954        // This will cause a parse error
955        let content = b"\xFF\xFE\xFD\xFC"; // Invalid UTF-8
956        let result = streamer.process_chunk(content);
957
958        // Should handle the error gracefully
959        assert!(result.is_ok() || result.is_err());
960    }
961
962    #[test]
963    fn test_extract_text_empty_buffer() {
964        let streamer = TextStreamer::new(TextStreamOptions::default());
965        let text = streamer.extract_text();
966        assert!(text.is_empty());
967    }
968
969    #[test]
970    fn test_extract_text_single_chunk() {
971        let mut streamer = TextStreamer::new(TextStreamOptions::default());
972
973        streamer.buffer.push_back(TextChunk {
974            text: "Single".to_string(),
975            x: 0.0,
976            y: 0.0,
977            font_size: 12.0,
978            font_name: None,
979        });
980
981        let text = streamer.extract_text();
982        assert_eq!(text, "Single");
983    }
984
985    #[test]
986    fn test_check_buffer_size_empty() {
987        let mut streamer = TextStreamer::new(TextStreamOptions::default());
988        streamer.check_buffer_size(); // Should not panic on empty buffer
989        assert!(streamer.buffer.is_empty());
990    }
991
992    #[test]
993    fn test_complex_content_operations() {
994        let mut streamer = TextStreamer::new(TextStreamOptions::default());
995
996        // Complex PDF content with mixed operations
997        let content = b"BT /F1 12 Tf 0 0 Td (Start) Tj ET q Q BT 50 50 Td (End) Tj ET";
998        let chunks = streamer.process_chunk(content).unwrap();
999
1000        assert_eq!(chunks.len(), 2);
1001        assert_eq!(chunks[0].text, "Start");
1002        assert_eq!(chunks[1].text, "End");
1003        assert_eq!(chunks[0].x, 0.0);
1004        assert_eq!(chunks[1].x, 50.0);
1005    }
1006
1007    #[test]
1008    fn test_stream_text_callback_state() {
1009        let content = b"BT /F1 12 Tf (Test) Tj ET".to_vec();
1010        let streams = vec![content; 3]; // Same content 3 times
1011
1012        let mut count = 0;
1013        stream_text(streams, |_chunk| {
1014            count += 1;
1015            Ok(())
1016        })
1017        .unwrap();
1018
1019        assert_eq!(count, 3);
1020    }
1021}