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}