Skip to main content

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            // `/Font` and each of its entries may be a reference or a direct
493            // dictionary; reading only the references left the cache empty on
494            // pages with inline fonts, losing `/ToUnicode`.
495            for (font_name, entry) in
496                crate::text::extraction_cmap::resolve_font_entries(resources, document)
497            {
498                let font_dict = match entry {
499                    crate::text::extraction_cmap::FontEntry::Indirect(num, gen) => {
500                        match document.get_object(num, gen) {
501                            Ok(PdfObject::Dictionary(dict)) => dict,
502                            _ => continue,
503                        }
504                    }
505                    crate::text::extraction_cmap::FontEntry::Inline(dict) => dict,
506                };
507
508                // Create a CMap extractor to use its font extraction logic
509                let mut cmap_extractor: CMapTextExtractor<R> = CMapTextExtractor::new();
510                if let Ok(font_info) = cmap_extractor.extract_font_info(&font_dict, document) {
511                    self.font_cache.insert(font_name, font_info);
512                }
513            }
514        }
515
516        Ok(())
517    }
518
519    /// Decode text using CMap if available
520    fn decode_text<R: Read + Seek>(
521        &self,
522        text_bytes: &[u8],
523        state: &TextState,
524    ) -> ParseResult<String> {
525        // Try CMap-based decoding first (free function — no allocation)
526        if let Some(ref font_name) = state.font_name {
527            if let Some(font_info) = self.font_cache.get(font_name) {
528                if let Ok(decoded) =
529                    crate::text::extraction_cmap::decode_text_with_font(text_bytes, font_info)
530                {
531                    return Ok(decoded);
532                }
533            }
534        }
535
536        // Fallback to encoding-based decoding (avoid allocation with case-insensitive check)
537        let encoding = if let Some(ref font_name) = state.font_name {
538            // Check for encoding type without allocating lowercase string
539            let font_lower = font_name.as_bytes();
540            if font_lower
541                .iter()
542                .any(|&b| b.to_ascii_lowercase() == b'r' && font_name.contains("roman"))
543            {
544                TextEncoding::MacRomanEncoding
545            } else if font_name.contains("WinAnsi") || font_name.contains("winansi") {
546                TextEncoding::WinAnsiEncoding
547            } else if font_name.contains("Standard") || font_name.contains("standard") {
548                TextEncoding::StandardEncoding
549            } else if font_name.contains("PdfDoc") || font_name.contains("pdfdoc") {
550                TextEncoding::PdfDocEncoding
551            } else if font_name.starts_with("Times")
552                || font_name.starts_with("Helvetica")
553                || font_name.starts_with("Courier")
554            {
555                TextEncoding::WinAnsiEncoding
556            } else {
557                TextEncoding::PdfDocEncoding
558            }
559        } else {
560            TextEncoding::WinAnsiEncoding
561        };
562
563        Ok(encoding.decode(text_bytes))
564    }
565
566    /// Apply line break mode processing
567    /// Move the text line matrix down by one leading and return the new pen
568    /// origin in user space. Shared by `T*`, `'` and `"`, which differ only in
569    /// what they do after the line move.
570    fn advance_to_next_line(state: &mut TextState) -> (f64, f64) {
571        let new_matrix = multiply_matrix(
572            &[1.0, 0.0, 0.0, 1.0, 0.0, -state.leading],
573            &state.text_line_matrix,
574        );
575        state.text_matrix = new_matrix;
576        state.text_line_matrix = new_matrix;
577        transform_point(0.0, 0.0, &state.text_matrix)
578    }
579
580    /// Append text that the operator itself placed on a new line. Unlike the
581    /// `Tj`/`TJ` path there is no threshold to consult: `'` and `"` moved the
582    /// line, so the break is a fact, not an inference.
583    fn push_on_new_line(acc: &mut String, decoded: &str) {
584        if !acc.is_empty() {
585            acc.push('\n');
586        }
587        acc.push_str(decoded);
588    }
589
590    fn apply_line_break_mode(&self, text: &str) -> String {
591        match self.config.line_break_mode {
592            LineBreakMode::Auto => self.auto_line_breaks(text),
593            LineBreakMode::PreserveAll => text.to_string(),
594            LineBreakMode::Normalize => self.normalize_line_breaks(text),
595        }
596    }
597
598    /// Auto-detect line breaks (heuristic)
599    fn auto_line_breaks(&self, text: &str) -> String {
600        let lines: Vec<&str> = text.lines().collect();
601        let mut result = String::with_capacity(text.len());
602
603        for (i, line) in lines.iter().enumerate() {
604            let trimmed = line.trim_end();
605
606            if trimmed.is_empty() {
607                result.push('\n');
608                continue;
609            }
610
611            result.push_str(line);
612
613            if i < lines.len() - 1 {
614                let next_line = lines[i + 1].trim_start();
615
616                let ends_with_punct = trimmed.ends_with('.')
617                    || trimmed.ends_with('!')
618                    || trimmed.ends_with('?')
619                    || trimmed.ends_with(':');
620
621                let next_is_empty = next_line.is_empty();
622
623                if ends_with_punct || next_is_empty {
624                    result.push('\n');
625                } else {
626                    result.push(' ');
627                }
628            }
629        }
630
631        result
632    }
633
634    /// Normalize line breaks (join hyphenated words)
635    fn normalize_line_breaks(&self, text: &str) -> String {
636        let lines: Vec<&str> = text.lines().collect();
637        let mut result = String::with_capacity(text.len());
638
639        for (i, line) in lines.iter().enumerate() {
640            let trimmed = line.trim_end();
641
642            if trimmed.is_empty() {
643                result.push('\n');
644                continue;
645            }
646
647            if trimmed.ends_with('-') && i < lines.len() - 1 {
648                let next_line = lines[i + 1].trim_start();
649                if !next_line.is_empty() {
650                    result.push_str(&trimmed[..trimmed.len() - 1]);
651                    continue;
652                }
653            }
654
655            result.push_str(line);
656
657            if i < lines.len() - 1 {
658                result.push('\n');
659            }
660        }
661
662        result
663    }
664
665    /// Get the current configuration
666    ///
667    /// # Examples
668    ///
669    /// ```
670    /// use oxidize_pdf::text::plaintext::{PlainTextExtractor, PlainTextConfig};
671    ///
672    /// let config = PlainTextConfig::dense();
673    /// let extractor = PlainTextExtractor::with_config(config.clone());
674    /// assert_eq!(extractor.config().space_threshold, 0.1);
675    /// ```
676    pub fn config(&self) -> &PlainTextConfig {
677        &self.config
678    }
679}
680
681/// Check if a matrix is the identity matrix
682#[inline]
683fn is_identity(matrix: &[f64; 6]) -> bool {
684    matrix[0] == 1.0
685        && matrix[1] == 0.0
686        && matrix[2] == 0.0
687        && matrix[3] == 1.0
688        && matrix[4] == 0.0
689        && matrix[5] == 0.0
690}
691
692/// Multiply two 2D transformation matrices (optimized for identity)
693#[inline]
694fn multiply_matrix(m1: &[f64; 6], m2: &[f64; 6]) -> [f64; 6] {
695    // Fast path: if m1 is identity, return m2
696    if is_identity(m1) {
697        return *m2;
698    }
699    // Fast path: if m2 is identity, return m1
700    if is_identity(m2) {
701        return *m1;
702    }
703
704    // Full matrix multiplication
705    [
706        m1[0] * m2[0] + m1[1] * m2[2],
707        m1[0] * m2[1] + m1[1] * m2[3],
708        m1[2] * m2[0] + m1[3] * m2[2],
709        m1[2] * m2[1] + m1[3] * m2[3],
710        m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
711        m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
712    ]
713}
714
715/// Transform a point using a transformation matrix
716#[inline]
717fn transform_point(x: f64, y: f64, matrix: &[f64; 6]) -> (f64, f64) {
718    let new_x = matrix[0] * x + matrix[2] * y + matrix[4];
719    let new_y = matrix[1] * x + matrix[3] * y + matrix[5];
720    (new_x, new_y)
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn test_new() {
729        let extractor = PlainTextExtractor::new();
730        assert_eq!(extractor.config.space_threshold, 0.3);
731    }
732
733    #[test]
734    fn test_with_config() {
735        let config = PlainTextConfig::dense();
736        let extractor = PlainTextExtractor::with_config(config.clone());
737        assert_eq!(extractor.config, config);
738    }
739
740    #[test]
741    fn test_default() {
742        let extractor = PlainTextExtractor::default();
743        assert_eq!(extractor.config, PlainTextConfig::default());
744    }
745
746    #[test]
747    fn test_normalize_line_breaks_hyphenated() {
748        let extractor = PlainTextExtractor::new();
749        let text = "This is a docu-\nment with hyphen-\nated words.";
750        let normalized = extractor.normalize_line_breaks(text);
751        assert_eq!(normalized, "This is a document with hyphenated words.");
752    }
753
754    #[test]
755    fn test_normalize_line_breaks_no_hyphen() {
756        let extractor = PlainTextExtractor::new();
757        let text = "This is a normal\ntext without\nhyphens.";
758        let normalized = extractor.normalize_line_breaks(text);
759        assert_eq!(normalized, "This is a normal\ntext without\nhyphens.");
760    }
761
762    #[test]
763    fn test_auto_line_breaks_punctuation() {
764        let extractor = PlainTextExtractor::new();
765        let text = "First sentence.\nSecond sentence.\nThird sentence.";
766        let processed = extractor.auto_line_breaks(text);
767        assert_eq!(
768            processed,
769            "First sentence.\nSecond sentence.\nThird sentence."
770        );
771    }
772
773    #[test]
774    fn test_auto_line_breaks_wrapped() {
775        let extractor = PlainTextExtractor::new();
776        let text = "This is a long line that\nwas wrapped in the PDF\nfor layout purposes";
777        let processed = extractor.auto_line_breaks(text);
778        assert!(processed.contains("long line that was"));
779        assert!(processed.contains("wrapped in the PDF for"));
780    }
781
782    #[test]
783    fn test_auto_line_breaks_empty_lines() {
784        let extractor = PlainTextExtractor::new();
785        let text = "Paragraph one.\n\nParagraph two.\n\nParagraph three.";
786        let processed = extractor.auto_line_breaks(text);
787        assert!(processed.contains("\n\n"));
788    }
789
790    #[test]
791    fn test_apply_line_break_mode_preserve_all() {
792        let extractor = PlainTextExtractor::with_config(PlainTextConfig {
793            line_break_mode: LineBreakMode::PreserveAll,
794            ..Default::default()
795        });
796        let text = "Line 1\nLine 2\nLine 3";
797        let processed = extractor.apply_line_break_mode(text);
798        assert_eq!(processed, text);
799    }
800
801    #[test]
802    fn test_apply_line_break_mode_normalize() {
803        let extractor = PlainTextExtractor::with_config(PlainTextConfig {
804            line_break_mode: LineBreakMode::Normalize,
805            ..Default::default()
806        });
807        let text = "docu-\nment";
808        let processed = extractor.apply_line_break_mode(text);
809        assert_eq!(processed, "document");
810    }
811
812    #[test]
813    fn test_apply_line_break_mode_auto() {
814        let extractor = PlainTextExtractor::with_config(PlainTextConfig {
815            line_break_mode: LineBreakMode::Auto,
816            ..Default::default()
817        });
818        let text = "First sentence.\nSecond part";
819        let processed = extractor.apply_line_break_mode(text);
820        assert!(processed.contains("First sentence.\nSecond"));
821    }
822
823    #[test]
824    fn test_config_getter() {
825        let config = PlainTextConfig::loose();
826        let extractor = PlainTextExtractor::with_config(config.clone());
827        assert_eq!(extractor.config(), &config);
828    }
829
830    #[test]
831    fn test_multiply_matrix() {
832        let m1 = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
833        let m2 = [1.0, 0.0, 0.0, 1.0, 5.0, 15.0];
834        let result = multiply_matrix(&m1, &m2);
835        assert_eq!(result, [1.0, 0.0, 0.0, 1.0, 15.0, 35.0]);
836    }
837
838    #[test]
839    fn test_transform_point() {
840        let matrix = [1.0, 0.0, 0.0, 1.0, 10.0, 20.0];
841        let (x, y) = transform_point(5.0, 10.0, &matrix);
842        assert_eq!(x, 15.0);
843        assert_eq!(y, 30.0);
844    }
845}