Skip to main content

oxidize_pdf/text/plaintext/
types.rs

1//! Data types for plain text extraction
2//!
3//! This module defines the configuration and result types used by the plain text
4//! extraction system.
5
6/// Configuration for plain text extraction
7///
8/// Controls how text is extracted and formatted when position information
9/// is not required. Thresholds are expressed in text space units and should
10/// be tuned based on your specific PDF characteristics.
11///
12/// # Default Configuration
13///
14/// ```
15/// use oxidize_pdf::text::plaintext::PlainTextConfig;
16///
17/// let config = PlainTextConfig::default();
18/// assert_eq!(config.space_threshold, 0.3);
19/// assert_eq!(config.newline_threshold, 10.0);
20/// assert!(!config.preserve_layout);
21/// ```
22#[derive(Debug, Clone, PartialEq)]
23pub struct PlainTextConfig {
24    /// Space detection threshold (multiple of average character width)
25    ///
26    /// When horizontal displacement between characters exceeds this threshold
27    /// (expressed as a multiple of the average character width), a space
28    /// character is inserted.
29    ///
30    /// - **Lower values** (0.1-0.2): More spaces inserted, good for tightly-spaced text
31    /// - **Default** (0.3): Balanced for most documents
32    /// - **Higher values** (0.4-0.5): Fewer spaces, good for wide-spaced text
33    ///
34    /// **Range**: 0.05 to 1.0 (typical)
35    pub space_threshold: f64,
36
37    /// Threshold for synthesising an implicit space from a `TJ` numeric
38    /// kerning offset, expressed as a fraction of the font size. Mirrors
39    /// [`ExtractionOptions::tj_space_threshold`](crate::text::ExtractionOptions).
40    /// A `TJ` kern advances the text matrix without rendering a glyph;
41    /// many PDFs encode inter-word gaps purely as wide negative kerns
42    /// rather than literal spaces. When the advance exceeds
43    /// `tj_space_threshold * font_size`, one `U+0020` is inserted.
44    /// Default `0.2` (200 milli-em). Separate from `space_threshold`
45    /// because the TJ kern carries no glyph-advance baseline (issue #272).
46    pub tj_space_threshold: f64,
47
48    /// Newline detection threshold (multiple of line height)
49    ///
50    /// When vertical displacement between text elements exceeds this threshold
51    /// (in text space units), a newline character is inserted.
52    ///
53    /// - **Lower values** (5.0-8.0): More line breaks, preserves paragraph structure
54    /// - **Default** (10.0): Balanced for most documents
55    /// - **Higher values** (15.0-20.0): Fewer line breaks, joins more text
56    ///
57    /// **Range**: 1.0 to 50.0 (typical)
58    pub newline_threshold: f64,
59
60    /// Preserve original layout whitespace
61    ///
62    /// When `true`, attempts to maintain the original document's whitespace
63    /// structure (indentation, spacing) by inserting appropriate spaces and
64    /// newlines based on position changes in the PDF.
65    ///
66    /// When `false`, uses minimal whitespace (single spaces between words,
67    /// single newlines between paragraphs).
68    ///
69    /// **Use `true` for**:
70    /// - Documents with tabular data
71    /// - Code listings or formatted text
72    /// - Documents where indentation matters
73    ///
74    /// **Use `false` for**:
75    /// - Plain text extraction for search indexing
76    /// - Content analysis where layout doesn't matter
77    /// - Maximum performance (less processing)
78    pub preserve_layout: bool,
79
80    /// Line break handling mode
81    ///
82    /// Controls how line breaks in the PDF are interpreted and processed.
83    /// Different modes are useful for different document types and use cases.
84    pub line_break_mode: LineBreakMode,
85}
86
87impl Default for PlainTextConfig {
88    fn default() -> Self {
89        Self {
90            space_threshold: 0.3,
91            tj_space_threshold: 0.2,
92            newline_threshold: 10.0,
93            preserve_layout: false,
94            line_break_mode: LineBreakMode::Auto,
95        }
96    }
97}
98
99impl PlainTextConfig {
100    /// Create a new configuration with default values
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use oxidize_pdf::text::plaintext::PlainTextConfig;
106    ///
107    /// let config = PlainTextConfig::new();
108    /// ```
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Create a configuration optimized for dense text (tight spacing)
114    ///
115    /// Lower thresholds detect spaces more aggressively, useful for
116    /// PDFs with minimal character spacing.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use oxidize_pdf::text::plaintext::PlainTextConfig;
122    ///
123    /// let config = PlainTextConfig::dense();
124    /// assert_eq!(config.space_threshold, 0.1);
125    /// ```
126    pub fn dense() -> Self {
127        Self {
128            space_threshold: 0.1,
129            tj_space_threshold: 0.1,
130            newline_threshold: 8.0,
131            preserve_layout: false,
132            line_break_mode: LineBreakMode::Auto,
133        }
134    }
135
136    /// Create a configuration optimized for loose text (wide spacing)
137    ///
138    /// Higher thresholds avoid false space detection in documents with
139    /// generous character spacing.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use oxidize_pdf::text::plaintext::PlainTextConfig;
145    ///
146    /// let config = PlainTextConfig::loose();
147    /// assert_eq!(config.space_threshold, 0.4);
148    /// ```
149    pub fn loose() -> Self {
150        Self {
151            space_threshold: 0.4,
152            tj_space_threshold: 0.25,
153            newline_threshold: 15.0,
154            preserve_layout: false,
155            line_break_mode: LineBreakMode::Auto,
156        }
157    }
158
159    /// Create a configuration that preserves layout structure
160    ///
161    /// Useful for documents with tabular data, code, or formatted text
162    /// where whitespace is semantically important.
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// use oxidize_pdf::text::plaintext::PlainTextConfig;
168    ///
169    /// let config = PlainTextConfig::preserve_layout();
170    /// assert!(config.preserve_layout);
171    /// ```
172    pub fn preserve_layout() -> Self {
173        Self {
174            space_threshold: 0.3,
175            tj_space_threshold: 0.2,
176            newline_threshold: 10.0,
177            preserve_layout: true,
178            line_break_mode: LineBreakMode::PreserveAll,
179        }
180    }
181}
182
183/// Line break handling mode
184///
185/// Controls how line breaks in the PDF are interpreted. PDFs often include
186/// line breaks for layout purposes that should be removed when extracting
187/// continuous text (e.g., hyphenated words at line ends).
188///
189/// # Examples
190///
191/// ```
192/// use oxidize_pdf::text::plaintext::LineBreakMode;
193///
194/// let mode = LineBreakMode::Auto;         // Detect based on context
195/// let mode = LineBreakMode::PreserveAll;  // Keep all line breaks
196/// let mode = LineBreakMode::Normalize;    // Join hyphenated words
197/// ```
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
199pub enum LineBreakMode {
200    /// Automatically detect line breaks
201    ///
202    /// Uses heuristics to determine if a line break is semantic (paragraph end)
203    /// or just for layout (line wrap). Joins lines that appear to be wrapped.
204    ///
205    /// **Best for**: General-purpose text extraction
206    Auto,
207
208    /// Preserve all line breaks from PDF
209    ///
210    /// Every line break in the PDF becomes a newline in the output.
211    /// Useful when the PDF's line breaks are semantically meaningful.
212    ///
213    /// **Best for**: Poetry, code listings, formatted text
214    PreserveAll,
215
216    /// Normalize line breaks (join hyphenated words)
217    ///
218    /// Detects hyphenated words at line ends (e.g., "docu-\nment") and joins
219    /// them into single words ("document"). Other line breaks are preserved.
220    ///
221    /// **Best for**: Continuous text extraction from books, articles
222    Normalize,
223}
224
225/// Result of plain text extraction
226///
227/// Contains the extracted text and metadata about the extraction.
228/// Unlike `ExtractedText`, this does not include position information
229/// for individual text fragments.
230///
231/// # Examples
232///
233/// ```ignore
234/// use oxidize_pdf::Document;
235/// use oxidize_pdf::text::plaintext::PlainTextExtractor;
236///
237/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
238/// let doc = Document::open("document.pdf")?;
239/// let page = doc.get_page(1)?;
240///
241/// let extractor = PlainTextExtractor::new();
242/// let result = extractor.extract(&doc, page)?;
243///
244/// println!("Extracted {} characters in {} lines",
245///     result.char_count,
246///     result.line_count
247/// );
248/// # Ok(())
249/// # }
250/// ```
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct PlainTextResult {
253    /// Extracted text content
254    ///
255    /// The complete text content from the page, with spaces and newlines
256    /// inserted according to the configured thresholds and line break mode.
257    pub text: String,
258
259    /// Number of lines in the extracted text
260    ///
261    /// Lines are counted by splitting on `\n` characters. A document with
262    /// no newlines will have a line_count of 1.
263    pub line_count: usize,
264
265    /// Number of characters in the extracted text
266    ///
267    /// Total character count including spaces and newlines.
268    pub char_count: usize,
269}
270
271impl PlainTextResult {
272    /// Create a new result from text
273    ///
274    /// Automatically calculates line_count and char_count from the text.
275    ///
276    /// # Examples
277    ///
278    /// ```
279    /// use oxidize_pdf::text::plaintext::PlainTextResult;
280    ///
281    /// let result = PlainTextResult::new("Hello\nWorld".to_string());
282    /// assert_eq!(result.line_count, 2);
283    /// assert_eq!(result.char_count, 11);
284    /// ```
285    pub fn new(text: String) -> Self {
286        let line_count = text.lines().count();
287        let char_count = text.chars().count();
288        Self {
289            text,
290            line_count,
291            char_count,
292        }
293    }
294
295    /// Create an empty result
296    ///
297    /// # Examples
298    ///
299    /// ```
300    /// use oxidize_pdf::text::plaintext::PlainTextResult;
301    ///
302    /// let result = PlainTextResult::empty();
303    /// assert_eq!(result.text, "");
304    /// assert_eq!(result.line_count, 0);
305    /// assert_eq!(result.char_count, 0);
306    /// ```
307    pub fn empty() -> Self {
308        Self {
309            text: String::new(),
310            line_count: 0,
311            char_count: 0,
312        }
313    }
314
315    /// Check if the result is empty
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use oxidize_pdf::text::plaintext::PlainTextResult;
321    ///
322    /// let result = PlainTextResult::empty();
323    /// assert!(result.is_empty());
324    ///
325    /// let result = PlainTextResult::new("text".to_string());
326    /// assert!(!result.is_empty());
327    /// ```
328    pub fn is_empty(&self) -> bool {
329        self.text.is_empty()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_config_default() {
339        let config = PlainTextConfig::default();
340        assert_eq!(config.space_threshold, 0.3);
341        assert_eq!(config.newline_threshold, 10.0);
342        assert!(!config.preserve_layout);
343        assert_eq!(config.line_break_mode, LineBreakMode::Auto);
344    }
345
346    #[test]
347    fn test_config_new() {
348        let config = PlainTextConfig::new();
349        assert_eq!(config, PlainTextConfig::default());
350    }
351
352    #[test]
353    fn test_config_dense() {
354        let config = PlainTextConfig::dense();
355        assert_eq!(config.space_threshold, 0.1);
356        assert_eq!(config.newline_threshold, 8.0);
357        assert!(!config.preserve_layout);
358    }
359
360    #[test]
361    fn test_config_loose() {
362        let config = PlainTextConfig::loose();
363        assert_eq!(config.space_threshold, 0.4);
364        assert_eq!(config.newline_threshold, 15.0);
365        assert!(!config.preserve_layout);
366    }
367
368    #[test]
369    fn test_config_preserve_layout() {
370        let config = PlainTextConfig::preserve_layout();
371        assert!(config.preserve_layout);
372        assert_eq!(config.line_break_mode, LineBreakMode::PreserveAll);
373    }
374
375    #[test]
376    fn test_line_break_mode_equality() {
377        assert_eq!(LineBreakMode::Auto, LineBreakMode::Auto);
378        assert_ne!(LineBreakMode::Auto, LineBreakMode::PreserveAll);
379    }
380
381    #[test]
382    fn test_plain_text_result_new() {
383        let result = PlainTextResult::new("Hello\nWorld".to_string());
384        assert_eq!(result.text, "Hello\nWorld");
385        assert_eq!(result.line_count, 2);
386        assert_eq!(result.char_count, 11);
387    }
388
389    #[test]
390    fn test_plain_text_result_empty() {
391        let result = PlainTextResult::empty();
392        assert_eq!(result.text, "");
393        assert_eq!(result.line_count, 0);
394        assert_eq!(result.char_count, 0);
395        assert!(result.is_empty());
396    }
397
398    #[test]
399    fn test_plain_text_result_is_empty() {
400        let empty = PlainTextResult::empty();
401        assert!(empty.is_empty());
402
403        let not_empty = PlainTextResult::new("text".to_string());
404        assert!(!not_empty.is_empty());
405    }
406
407    #[test]
408    fn test_plain_text_result_line_count() {
409        let single = PlainTextResult::new("single line".to_string());
410        assert_eq!(single.line_count, 1);
411
412        let multiple = PlainTextResult::new("line1\nline2\nline3".to_string());
413        assert_eq!(multiple.line_count, 3);
414    }
415}