oxidize_pdf/text/plaintext/extractor.rs
1//! Plain text extractor implementation with simplified API
2//!
3//! This module provides simplified text extraction that returns clean strings
4//! instead of position-annotated fragments.
5
6use super::types::{LineBreakMode, PlainTextConfig, PlainTextResult};
7use crate::parser::content::{ContentOperation, ContentParser, TextElement};
8use crate::parser::document::PdfDocument;
9use crate::parser::objects::PdfObject;
10use crate::parser::page_tree::ParsedPage;
11use crate::parser::ParseResult;
12use crate::text::encoding::TextEncoding;
13use crate::text::extraction_cmap::{CMapTextExtractor, FontInfo};
14use crate::text::graphics_state_stack::GraphicsStateStack;
15use std::collections::HashMap;
16use std::io::{Read, Seek};
17
18/// Identity transformation matrix
19const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
20
21/// Text state for PDF text rendering
22#[derive(Debug, Clone)]
23struct TextState {
24 text_matrix: [f64; 6],
25 text_line_matrix: [f64; 6],
26 leading: f64,
27 font_size: f64,
28 font_name: Option<String>,
29 /// Stack for `q`/`Q`. This extractor tracks no CTM, so the only graphics
30 /// state it can lose is the text state (issue #452).
31 ///
32 /// Bounded: see [`GraphicsStateStack`] for the depth cap and for why the
33 /// pushes it refuses have to be counted (issue #455).
34 saved_states: GraphicsStateStack<SavedTextState>,
35}
36
37impl TextState {
38 /// `q` (§8.4.4): snapshot the text state.
39 ///
40 /// The snapshot is built lazily: past the depth cap it is never built at
41 /// all, so a `q` flood does not pay for the font-name clone of an entry
42 /// the stack is about to refuse (issue #455).
43 fn save_graphics_state(&mut self) {
44 self.saved_states.push_with(|| SavedTextState {
45 leading: self.leading,
46 font_size: self.font_size,
47 font_name: self.font_name.clone(),
48 });
49 }
50}
51
52/// The text state parameters this extractor tracks, saved by `q` and restored
53/// by `Q`.
54///
55/// Text state is graphics state per ISO 32000-1 §9.3 and Table 52, so a leading
56/// or font set inside a `q … Q` block dies with the block. Before #452 this
57/// extractor had no `q`/`Q` handling at all and every such value leaked out.
58///
59/// The text matrices are absent on purpose: they are text OBJECT state, set by
60/// `BT` and discarded by `ET` (§9.4.1), and `Q` must not touch them.
61#[derive(Debug, Clone)]
62struct SavedTextState {
63 leading: f64,
64 font_size: f64,
65 font_name: Option<String>,
66}
67
68impl Default for TextState {
69 fn default() -> Self {
70 Self {
71 text_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
72 text_line_matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
73 leading: 0.0,
74 font_size: 0.0,
75 font_name: None,
76 saved_states: GraphicsStateStack::default(),
77 }
78 }
79}
80
81/// Plain text extractor with simplified API
82///
83/// Extracts text from PDF pages without maintaining position information,
84/// providing a simpler API by returning `String` and `Vec<String>` instead
85/// of `Vec<TextFragment>`.
86///
87/// # Architecture
88///
89/// This extractor uses the same content stream parser as `TextExtractor`,
90/// but discards position metadata to provide a simpler output format. It
91/// tracks minimal position data (x, y coordinates) to determine spacing
92/// and line breaks, then returns clean text strings.
93///
94/// # Performance Characteristics
95///
96/// - **Memory**: O(1) position tracking vs O(n) fragments
97/// - **CPU**: No fragment sorting, no width calculations
98/// - **Performance**: Comparable to `TextExtractor` (same parser)
99///
100/// # Thread Safety
101///
102/// `PlainTextExtractor` is thread-safe and can be reused across multiple
103/// pages and documents. Create once, use many times.
104///
105/// # Examples
106///
107/// ## Basic Usage
108///
109/// ```no_run
110/// use oxidize_pdf::parser::PdfReader;
111/// use oxidize_pdf::text::plaintext::PlainTextExtractor;
112///
113/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
114/// let doc = PdfReader::open_document("document.pdf")?;
115///
116/// let mut extractor = PlainTextExtractor::new();
117/// let result = extractor.extract(&doc, 0)?;
118///
119/// println!("{}", result.text);
120/// # Ok(())
121/// # }
122/// ```
123///
124/// ## Custom Configuration
125///
126/// ```no_run
127/// use oxidize_pdf::parser::PdfReader;
128/// use oxidize_pdf::text::plaintext::{PlainTextExtractor, PlainTextConfig};
129///
130/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
131/// let doc = PdfReader::open_document("document.pdf")?;
132///
133/// let config = PlainTextConfig {
134/// space_threshold: 0.3,
135/// newline_threshold: 12.0,
136/// preserve_layout: true,
137/// line_break_mode: oxidize_pdf::text::plaintext::LineBreakMode::Normalize,
138/// ..Default::default()
139/// };
140///
141/// let mut extractor = PlainTextExtractor::with_config(config);
142/// let result = extractor.extract(&doc, 0)?;
143/// # Ok(())
144/// # }
145/// ```
146pub struct PlainTextExtractor {
147 /// Configuration for extraction
148 config: PlainTextConfig,
149 /// Font cache for decoding text
150 font_cache: HashMap<String, FontInfo>,
151}
152
153impl Default for PlainTextExtractor {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159impl PlainTextExtractor {
160 /// Create a new extractor with default configuration
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// use oxidize_pdf::text::plaintext::PlainTextExtractor;
166 ///
167 /// let extractor = PlainTextExtractor::new();
168 /// ```
169 pub fn new() -> Self {
170 Self {
171 config: PlainTextConfig::default(),
172 font_cache: HashMap::new(),
173 }
174 }
175
176 /// Create a new extractor with custom configuration
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use oxidize_pdf::text::plaintext::{PlainTextExtractor, PlainTextConfig};
182 ///
183 /// let config = PlainTextConfig::dense();
184 /// let extractor = PlainTextExtractor::with_config(config);
185 /// ```
186 pub fn with_config(config: PlainTextConfig) -> Self {
187 Self {
188 config,
189 font_cache: HashMap::new(),
190 }
191 }
192
193 /// Extract plain text from a PDF page
194 ///
195 /// Returns text with spaces and newlines inserted according to the
196 /// configured thresholds. Position information is not included in
197 /// the result.
198 ///
199 /// # Output
200 ///
201 /// Returns a `PlainTextResult` containing the extracted text as a `String`,
202 /// along with character count and line count metadata. This is simpler than
203 /// `TextExtractor` which returns `Vec<TextFragment>` with position data.
204 ///
205 /// # Examples
206 ///
207 /// ```no_run
208 /// use oxidize_pdf::parser::PdfReader;
209 /// use oxidize_pdf::text::plaintext::PlainTextExtractor;
210 ///
211 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
212 /// let doc = PdfReader::open_document("document.pdf")?;
213 ///
214 /// let mut extractor = PlainTextExtractor::new();
215 /// let result = extractor.extract(&doc, 0)?; // page index 0 = first page
216 ///
217 /// println!("Extracted {} characters", result.char_count);
218 /// # Ok(())
219 /// # }
220 /// ```
221 pub fn extract<R: Read + Seek>(
222 &mut self,
223 document: &PdfDocument<R>,
224 page_index: u32,
225 ) -> ParseResult<PlainTextResult> {
226 // Get the page
227 let page = document.get_page(page_index)?;
228
229 // Extract font resources
230 self.extract_font_resources(&page, document)?;
231
232 // Get content streams
233 let streams = page.content_streams_with_document(document)?;
234
235 // Pre-allocate String capacity to avoid reallocations
236 let mut extracted_text = String::with_capacity(4096);
237 let mut state = TextState::default();
238 let mut in_text_object = false;
239 let mut last_x = 0.0;
240 let mut last_y = 0.0;
241
242 // Process each content stream
243 for stream_data in streams {
244 let operations = match ContentParser::parse_content(&stream_data) {
245 Ok(ops) => ops,
246 Err(e) => {
247 tracing::debug!("Warning: Failed to parse content stream, skipping: {}", e);
248 continue;
249 }
250 };
251
252 for op in operations {
253 match op {
254 ContentOperation::BeginText => {
255 in_text_object = true;
256 state.text_matrix = IDENTITY;
257 state.text_line_matrix = IDENTITY;
258 }
259
260 ContentOperation::EndText => {
261 in_text_object = false;
262 }
263
264 ContentOperation::SetTextMatrix(a, b, c, d, e, f) => {
265 state.text_matrix =
266 [a as f64, b as f64, c as f64, d as f64, e as f64, f as f64];
267 state.text_line_matrix =
268 [a as f64, b as f64, c as f64, d as f64, e as f64, f as f64];
269 }
270
271 ContentOperation::MoveText(tx, ty) => {
272 let new_matrix = multiply_matrix(
273 &[1.0, 0.0, 0.0, 1.0, tx as f64, ty as f64],
274 &state.text_line_matrix,
275 );
276 state.text_matrix = new_matrix;
277 state.text_line_matrix = new_matrix;
278 }
279
280 // `tx ty TD` (ISO 32000-1 §9.4.2) is `-ty TL` followed by
281 // `tx ty Td`: it moves to the next line AND sets the
282 // leading. Same defect as issue #451 in `TextExtractor`,
283 // living independently in this second public path: the
284 // operator fell through the catch-all below, so the line
285 // break did not exist here either (`dx = dy = 0` at the
286 // spacing decision) and every later `T*` advanced by a
287 // stale leading.
288 ContentOperation::MoveTextSetLeading(tx, ty) => {
289 state.leading = -(ty as f64);
290 let new_matrix = multiply_matrix(
291 &[1.0, 0.0, 0.0, 1.0, tx as f64, ty as f64],
292 &state.text_line_matrix,
293 );
294 state.text_matrix = new_matrix;
295 state.text_line_matrix = new_matrix;
296 }
297
298 ContentOperation::NextLine => {
299 Self::advance_to_next_line(&mut state);
300 }
301
302 // `string '` is `T*` followed by `Tj` (ISO 32000-1 §9.4.3,
303 // Table 109). The operator had no arm here, so the string
304 // was never emitted: not a missing separator like the `TD`
305 // gap above, but silent loss of the content itself.
306 ContentOperation::NextLineShowText(text) => {
307 if in_text_object {
308 let decoded = self.decode_text::<R>(&text, &state)?;
309 let (x, y) = Self::advance_to_next_line(&mut state);
310 Self::push_on_new_line(&mut extracted_text, &decoded);
311 last_x = x;
312 last_y = y;
313 }
314 }
315
316 // `aw ac string "` is `aw Tw`, `ac Tc`, then `string '`.
317 // The spacing operands are consumed and deliberately not
318 // stored: this extractor decides separators from pen
319 // positions, never from accumulated glyph advances, so
320 // there is nothing here for them to affect. `TextExtractor`
321 // does track them.
322 ContentOperation::SetSpacingNextLineShowText(
323 _word_space,
324 _char_space,
325 text,
326 ) => {
327 if in_text_object {
328 let decoded = self.decode_text::<R>(&text, &state)?;
329 let (x, y) = Self::advance_to_next_line(&mut state);
330 Self::push_on_new_line(&mut extracted_text, &decoded);
331 last_x = x;
332 last_y = y;
333 }
334 }
335
336 ContentOperation::ShowText(text) => {
337 if in_text_object {
338 let decoded = self.decode_text::<R>(&text, &state)?;
339
340 // Calculate position (only x, y - no width/height needed)
341 let (x, y) = transform_point(0.0, 0.0, &state.text_matrix);
342
343 // Add spacing based on position change
344 if !extracted_text.is_empty() {
345 let dx = x - last_x;
346 let dy = (y - last_y).abs();
347
348 if dy > self.config.newline_threshold {
349 extracted_text.push('\n');
350 } else if dx > self.config.space_threshold * state.font_size {
351 extracted_text.push(' ');
352 }
353 }
354
355 extracted_text.push_str(&decoded);
356 last_x = x;
357 last_y = y;
358 }
359 }
360
361 ContentOperation::ShowTextArray(array) => {
362 if in_text_object {
363 // Inter-operator spacing once, at the start of the
364 // array, mirroring the single-`Tj` path.
365 let (x, y) = transform_point(0.0, 0.0, &state.text_matrix);
366 if !extracted_text.is_empty() {
367 let dx = x - last_x;
368 let dy = (y - last_y).abs();
369 if dy > self.config.newline_threshold {
370 extracted_text.push('\n');
371 } else if dx > self.config.space_threshold * state.font_size {
372 extracted_text.push(' ');
373 }
374 }
375
376 for item in array {
377 match item {
378 TextElement::Text(bytes) => {
379 let decoded = self.decode_text::<R>(&bytes, &state)?;
380 extracted_text.push_str(&decoded);
381 }
382 TextElement::Spacing(adjustment) => {
383 // Negative adjustment shifts the pen
384 // forward. A wide forward advance is an
385 // implicit word break (issue #272): emit
386 // one space unless the previous char is
387 // already a space.
388 let tx = -(adjustment as f64) / 1000.0 * state.font_size;
389 if tx > self.config.tj_space_threshold * state.font_size
390 && !extracted_text.is_empty()
391 && !extracted_text.ends_with(' ')
392 {
393 extracted_text.push(' ');
394 }
395 state.text_matrix = multiply_matrix(
396 &[1.0, 0.0, 0.0, 1.0, tx, 0.0],
397 &state.text_matrix,
398 );
399 }
400 }
401 }
402
403 last_x = transform_point(0.0, 0.0, &state.text_matrix).0;
404 last_y = y;
405 }
406 }
407
408 ContentOperation::SetFont(name, size) => {
409 state.font_name = Some(name);
410 state.font_size = size as f64;
411 }
412
413 ContentOperation::SetLeading(leading) => {
414 state.leading = leading as f64;
415 }
416
417 // Text state is graphics state (ISO 32000-1 §9.3, Table
418 // 52), so a leading or font set inside a `q … Q` block must
419 // not survive it (issue #452). The text matrices are not
420 // saved: they are text object state, owned by `BT`/`ET`.
421 ContentOperation::SaveGraphicsState => {
422 state.save_graphics_state();
423 }
424
425 ContentOperation::RestoreGraphicsState => {
426 // An unbalanced `Q` is ignored rather than fatal, to
427 // stay robust on malformed documents.
428 if let Some(saved) = state.saved_states.pop() {
429 state.leading = saved.leading;
430 state.font_size = saved.font_size;
431 state.font_name = saved.font_name;
432 }
433 }
434
435 _ => {
436 // Ignore other operations (no graphics state needed for text extraction)
437 }
438 }
439 }
440 }
441
442 // Apply line break mode processing
443 let processed_text = self.apply_line_break_mode(&extracted_text);
444
445 Ok(PlainTextResult::new(processed_text))
446 }
447
448 /// Extract text as individual lines
449 ///
450 /// Returns a vector of strings, one for each line detected in the page.
451 /// Useful for grep-like operations or line-based processing.
452 ///
453 /// # Examples
454 ///
455 /// ```no_run
456 /// use oxidize_pdf::parser::PdfReader;
457 /// use oxidize_pdf::text::plaintext::PlainTextExtractor;
458 ///
459 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
460 /// let doc = PdfReader::open_document("document.pdf")?;
461 ///
462 /// let mut extractor = PlainTextExtractor::new();
463 /// let lines = extractor.extract_lines(&doc, 0)?;
464 ///
465 /// for (i, line) in lines.iter().enumerate() {
466 /// println!("{}: {}", i + 1, line);
467 /// }
468 /// # Ok(())
469 /// # }
470 /// ```
471 pub fn extract_lines<R: Read + Seek>(
472 &mut self,
473 document: &PdfDocument<R>,
474 page_index: u32,
475 ) -> ParseResult<Vec<String>> {
476 let result = self.extract(document, page_index)?;
477
478 Ok(result.text.lines().map(|line| line.to_string()).collect())
479 }
480
481 /// Extract font resources from the page
482 fn extract_font_resources<R: Read + Seek>(
483 &mut self,
484 page: &ParsedPage,
485 document: &PdfDocument<R>,
486 ) -> ParseResult<()> {
487 // Cache fonts persistently across pages (improves multi-page extraction)
488 // Font cache is only cleared when extractor is recreated
489
490 // Get page resources
491 if let Some(resources) = page.get_resources() {
492 if let Some(PdfObject::Dictionary(font_dict)) = resources.get("Font") {
493 // Extract each font
494 for (font_name, font_obj) in font_dict.0.iter() {
495 if let Some(font_ref) = font_obj.as_reference() {
496 if let Ok(PdfObject::Dictionary(font_dict)) =
497 document.get_object(font_ref.0, font_ref.1)
498 {
499 // Create a CMap extractor to use its font extraction logic
500 let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
501
502 if let Ok(font_info) =
503 cmap_extractor.extract_font_info(&font_dict, document)
504 {
505 self.font_cache.insert(font_name.0.clone(), font_info);
506 }
507 }
508 }
509 }
510 }
511 }
512
513 Ok(())
514 }
515
516 /// Decode text using CMap if available
517 fn decode_text<R: Read + Seek>(
518 &self,
519 text_bytes: &[u8],
520 state: &TextState,
521 ) -> ParseResult<String> {
522 // Try CMap-based decoding first (free function — no allocation)
523 if let Some(ref font_name) = state.font_name {
524 if let Some(font_info) = self.font_cache.get(font_name) {
525 if let Ok(decoded) =
526 crate::text::extraction_cmap::decode_text_with_font(text_bytes, font_info)
527 {
528 return Ok(decoded);
529 }
530 }
531 }
532
533 // Fallback to encoding-based decoding (avoid allocation with case-insensitive check)
534 let encoding = if let Some(ref font_name) = state.font_name {
535 // Check for encoding type without allocating lowercase string
536 let font_lower = font_name.as_bytes();
537 if font_lower
538 .iter()
539 .any(|&b| b.to_ascii_lowercase() == b'r' && font_name.contains("roman"))
540 {
541 TextEncoding::MacRomanEncoding
542 } else if font_name.contains("WinAnsi") || font_name.contains("winansi") {
543 TextEncoding::WinAnsiEncoding
544 } else if font_name.contains("Standard") || font_name.contains("standard") {
545 TextEncoding::StandardEncoding
546 } else if font_name.contains("PdfDoc") || font_name.contains("pdfdoc") {
547 TextEncoding::PdfDocEncoding
548 } else if font_name.starts_with("Times")
549 || font_name.starts_with("Helvetica")
550 || font_name.starts_with("Courier")
551 {
552 TextEncoding::WinAnsiEncoding
553 } else {
554 TextEncoding::PdfDocEncoding
555 }
556 } else {
557 TextEncoding::WinAnsiEncoding
558 };
559
560 Ok(encoding.decode(text_bytes))
561 }
562
563 /// Apply line break mode processing
564 /// Move the text line matrix down by one leading and return the new pen
565 /// origin in user space. Shared by `T*`, `'` and `"`, which differ only in
566 /// what they do after the line move.
567 fn advance_to_next_line(state: &mut TextState) -> (f64, f64) {
568 let new_matrix = multiply_matrix(
569 &[1.0, 0.0, 0.0, 1.0, 0.0, -state.leading],
570 &state.text_line_matrix,
571 );
572 state.text_matrix = new_matrix;
573 state.text_line_matrix = new_matrix;
574 transform_point(0.0, 0.0, &state.text_matrix)
575 }
576
577 /// Append text that the operator itself placed on a new line. Unlike the
578 /// `Tj`/`TJ` path there is no threshold to consult: `'` and `"` moved the
579 /// line, so the break is a fact, not an inference.
580 fn push_on_new_line(acc: &mut String, decoded: &str) {
581 if !acc.is_empty() {
582 acc.push('\n');
583 }
584 acc.push_str(decoded);
585 }
586
587 fn apply_line_break_mode(&self, text: &str) -> String {
588 match self.config.line_break_mode {
589 LineBreakMode::Auto => self.auto_line_breaks(text),
590 LineBreakMode::PreserveAll => text.to_string(),
591 LineBreakMode::Normalize => self.normalize_line_breaks(text),
592 }
593 }
594
595 /// Auto-detect line breaks (heuristic)
596 fn auto_line_breaks(&self, text: &str) -> String {
597 let lines: Vec<&str> = text.lines().collect();
598 let mut result = String::with_capacity(text.len());
599
600 for (i, line) in lines.iter().enumerate() {
601 let trimmed = line.trim_end();
602
603 if trimmed.is_empty() {
604 result.push('\n');
605 continue;
606 }
607
608 result.push_str(line);
609
610 if i < lines.len() - 1 {
611 let next_line = lines[i + 1].trim_start();
612
613 let ends_with_punct = trimmed.ends_with('.')
614 || trimmed.ends_with('!')
615 || trimmed.ends_with('?')
616 || trimmed.ends_with(':');
617
618 let next_is_empty = next_line.is_empty();
619
620 if ends_with_punct || next_is_empty {
621 result.push('\n');
622 } else {
623 result.push(' ');
624 }
625 }
626 }
627
628 result
629 }
630
631 /// Normalize line breaks (join hyphenated words)
632 fn normalize_line_breaks(&self, text: &str) -> String {
633 let lines: Vec<&str> = text.lines().collect();
634 let mut result = String::with_capacity(text.len());
635
636 for (i, line) in lines.iter().enumerate() {
637 let trimmed = line.trim_end();
638
639 if trimmed.is_empty() {
640 result.push('\n');
641 continue;
642 }
643
644 if trimmed.ends_with('-') && i < lines.len() - 1 {
645 let next_line = lines[i + 1].trim_start();
646 if !next_line.is_empty() {
647 result.push_str(&trimmed[..trimmed.len() - 1]);
648 continue;
649 }
650 }
651
652 result.push_str(line);
653
654 if i < lines.len() - 1 {
655 result.push('\n');
656 }
657 }
658
659 result
660 }
661
662 /// Get the current configuration
663 ///
664 /// # Examples
665 ///
666 /// ```
667 /// use oxidize_pdf::text::plaintext::{PlainTextExtractor, PlainTextConfig};
668 ///
669 /// let config = PlainTextConfig::dense();
670 /// let extractor = PlainTextExtractor::with_config(config.clone());
671 /// assert_eq!(extractor.config().space_threshold, 0.1);
672 /// ```
673 pub fn config(&self) -> &PlainTextConfig {
674 &self.config
675 }
676}
677
678/// Check if a matrix is the identity matrix
679#[inline]
680fn is_identity(matrix: &[f64; 6]) -> bool {
681 matrix[0] == 1.0
682 && matrix[1] == 0.0
683 && matrix[2] == 0.0
684 && matrix[3] == 1.0
685 && matrix[4] == 0.0
686 && matrix[5] == 0.0
687}
688
689/// Multiply two 2D transformation matrices (optimized for identity)
690#[inline]
691fn multiply_matrix(m1: &[f64; 6], m2: &[f64; 6]) -> [f64; 6] {
692 // Fast path: if m1 is identity, return m2
693 if is_identity(m1) {
694 return *m2;
695 }
696 // Fast path: if m2 is identity, return m1
697 if is_identity(m2) {
698 return *m1;
699 }
700
701 // Full matrix multiplication
702 [
703 m1[0] * m2[0] + m1[1] * m2[2],
704 m1[0] * m2[1] + m1[1] * m2[3],
705 m1[2] * m2[0] + m1[3] * m2[2],
706 m1[2] * m2[1] + m1[3] * m2[3],
707 m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
708 m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
709 ]
710}
711
712/// Transform a point using a transformation matrix
713#[inline]
714fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
715 let new_x = matrix[0] * x + matrix[2] * y + matrix[4];
716 let new_y = matrix[1] * x + matrix[3] * y + matrix[5];
717 (new_x, new_y)
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn test_new() {
726 let extractor = PlainTextExtractor::new();
727 assert_eq!(extractor.config.space_threshold, 0.3);
728 }
729
730 #[test]
731 fn test_with_config() {
732 let config = PlainTextConfig::dense();
733 let extractor = PlainTextExtractor::with_config(config.clone());
734 assert_eq!(extractor.config, config);
735 }
736
737 #[test]
738 fn test_default() {
739 let extractor = PlainTextExtractor::default();
740 assert_eq!(extractor.config, PlainTextConfig::default());
741 }
742
743 #[test]
744 fn test_normalize_line_breaks_hyphenated() {
745 let extractor = PlainTextExtractor::new();
746 let text = "This is a docu-\nment with hyphen-\nated words.";
747 let normalized = extractor.normalize_line_breaks(text);
748 assert_eq!(normalized, "This is a document with hyphenated words.");
749 }
750
751 #[test]
752 fn test_normalize_line_breaks_no_hyphen() {
753 let extractor = PlainTextExtractor::new();
754 let text = "This is a normal\ntext without\nhyphens.";
755 let normalized = extractor.normalize_line_breaks(text);
756 assert_eq!(normalized, "This is a normal\ntext without\nhyphens.");
757 }
758
759 #[test]
760 fn test_auto_line_breaks_punctuation() {
761 let extractor = PlainTextExtractor::new();
762 let text = "First sentence.\nSecond sentence.\nThird sentence.";
763 let processed = extractor.auto_line_breaks(text);
764 assert_eq!(
765 processed,
766 "First sentence.\nSecond sentence.\nThird sentence."
767 );
768 }
769
770 #[test]
771 fn test_auto_line_breaks_wrapped() {
772 let extractor = PlainTextExtractor::new();
773 let text = "This is a long line that\nwas wrapped in the PDF\nfor layout purposes";
774 let processed = extractor.auto_line_breaks(text);
775 assert!(processed.contains("long line that was"));
776 assert!(processed.contains("wrapped in the PDF for"));
777 }
778
779 #[test]
780 fn test_auto_line_breaks_empty_lines() {
781 let extractor = PlainTextExtractor::new();
782 let text = "Paragraph one.\n\nParagraph two.\n\nParagraph three.";
783 let processed = extractor.auto_line_breaks(text);
784 assert!(processed.contains("\n\n"));
785 }
786
787 #[test]
788 fn test_apply_line_break_mode_preserve_all() {
789 let extractor = PlainTextExtractor::with_config(PlainTextConfig {
790 line_break_mode: LineBreakMode::PreserveAll,
791 ..Default::default()
792 });
793 let text = "Line 1\nLine 2\nLine 3";
794 let processed = extractor.apply_line_break_mode(text);
795 assert_eq!(processed, text);
796 }
797
798 #[test]
799 fn test_apply_line_break_mode_normalize() {
800 let extractor = PlainTextExtractor::with_config(PlainTextConfig {
801 line_break_mode: LineBreakMode::Normalize,
802 ..Default::default()
803 });
804 let text = "docu-\nment";
805 let processed = extractor.apply_line_break_mode(text);
806 assert_eq!(processed, "document");
807 }
808
809 #[test]
810 fn test_apply_line_break_mode_auto() {
811 let extractor = PlainTextExtractor::with_config(PlainTextConfig {
812 line_break_mode: LineBreakMode::Auto,
813 ..Default::default()
814 });
815 let text = "First sentence.\nSecond part";
816 let processed = extractor.apply_line_break_mode(text);
817 assert!(processed.contains("First sentence.\nSecond"));
818 }
819
820 #[test]
821 fn test_config_getter() {
822 let config = PlainTextConfig::loose();
823 let extractor = PlainTextExtractor::with_config(config.clone());
824 assert_eq!(extractor.config(), &config);
825 }
826
827 #[test]
828 fn test_multiply_matrix() {
829 let m1 = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
830 let m2 = [1.0, 0.0, 0.0, 1.0, 5.0, 15.0];
831 let result = multiply_matrix(&m1, &m2);
832 assert_eq!(result, [1.0, 0.0, 0.0, 1.0, 15.0, 35.0]);
833 }
834
835 #[test]
836 fn test_transform_point() {
837 let matrix = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
838 let (x, y) = transform_point(5.0, 10.0, &matrix);
839 assert_eq!(x, 15.0);
840 assert_eq!(y, 30.0);
841 }
842}