Skip to main content

rust_pdf_box/text/
stripper.rs

1use crate::cos::lexer::{Lexer, Token};
2use crate::cos::object::COSObject;
3use crate::error::PdfResult;
4use crate::pd::document::PDDocument;
5
6/// Text extraction engine inspired by Apache PDFBox PDFTextStripper.
7pub struct PDFTextStripper;
8
9impl PDFTextStripper {
10    /// Extracts all text from all pages of the document.
11    pub fn extract_text(doc: &PDDocument) -> PdfResult<String> {
12        let mut full_text = String::new();
13        for i in 0..doc.page_count() {
14            let page_text = Self::extract_page_text(doc, i)?;
15            if !page_text.is_empty() {
16                if !full_text.is_empty() {
17                    full_text.push_str("\n\n");
18                }
19                full_text.push_str(&page_text);
20            }
21        }
22        Ok(full_text)
23    }
24
25    /// Extracts text from a single page by 0-based index.
26    pub fn extract_page_text(doc: &PDDocument, page_index: usize) -> PdfResult<String> {
27        let page = doc.get_page(page_index)?;
28        let content_ids = page.content_stream_ids();
29
30        let mut page_text = String::new();
31
32        for id in content_ids {
33            if let Some(COSObject::Stream(stream)) = doc.cos_doc.get_object(id) {
34                if let Ok(data) = stream.decode() {
35                    let stream_text = Self::extract_text_from_stream(&data);
36                    if !stream_text.is_empty() {
37                        if !page_text.is_empty() {
38                            page_text.push('\n');
39                        }
40                        page_text.push_str(&stream_text);
41                    }
42                }
43            }
44        }
45
46        Ok(page_text)
47    }
48
49    /// Parses content stream operators to extract text strings.
50    fn extract_text_from_stream(stream_data: &[u8]) -> String {
51        let mut lexer = Lexer::new(stream_data);
52        let mut extracted = String::new();
53        let mut token_stack: Vec<Token> = Vec::new();
54        let mut in_text_object = false;
55
56        while let Ok(token) = lexer.next_token() {
57            match token {
58                Token::EOF => break,
59                Token::Keyword(ref k) => match k.as_str() {
60                    "BT" => {
61                        in_text_object = true;
62                        token_stack.clear();
63                    }
64                    "ET" => {
65                        in_text_object = false;
66                        token_stack.clear();
67                        if !extracted.ends_with('\n') && !extracted.is_empty() {
68                            extracted.push('\n');
69                        }
70                    }
71                    "T*" => {
72                        extracted.push('\n');
73                        token_stack.clear();
74                    }
75                    "Td" | "TD" => {
76                        // If vertical translation is negative, it's typically a new line
77                        if token_stack.len() >= 2 {
78                            if let Token::Real(ty) = token_stack[token_stack.len() - 1] {
79                                if ty < 0.0 && !extracted.ends_with('\n') {
80                                    extracted.push('\n');
81                                }
82                            } else if let Token::Integer(ty) = token_stack[token_stack.len() - 1] {
83                                if ty < 0 && !extracted.ends_with('\n') {
84                                    extracted.push('\n');
85                                }
86                            }
87                        }
88                        token_stack.clear();
89                    }
90                    "Tj" => {
91                        if let Some(Token::String(bytes, _)) = token_stack.pop() {
92                            let text = String::from_utf8_lossy(&bytes);
93                            extracted.push_str(&text);
94                        }
95                        token_stack.clear();
96                    }
97                    "'" => {
98                        extracted.push('\n');
99                        if let Some(Token::String(bytes, _)) = token_stack.pop() {
100                            let text = String::from_utf8_lossy(&bytes);
101                            extracted.push_str(&text);
102                        }
103                        token_stack.clear();
104                    }
105                    "\"" => {
106                        extracted.push('\n');
107                        if let Some(Token::String(bytes, _)) = token_stack.pop() {
108                            let text = String::from_utf8_lossy(&bytes);
109                            extracted.push_str(&text);
110                        }
111                        token_stack.clear();
112                    }
113                    "TJ" => {
114                        // Array of strings and kerning numbers: [(Hello) -100 (World)] TJ
115                        if let Some(Token::ArrayEnd) = token_stack.pop() {
116                            let mut elements = Vec::new();
117                            while let Some(top) = token_stack.pop() {
118                                if top == Token::ArrayStart {
119                                    break;
120                                }
121                                elements.push(top);
122                            }
123                            elements.reverse();
124
125                            for el in elements {
126                                match el {
127                                    Token::String(bytes, _) => {
128                                        let text = String::from_utf8_lossy(&bytes);
129                                        extracted.push_str(&text);
130                                    }
131                                    Token::Integer(kern) if kern < -150 => {
132                                        // Large negative kerning indicates a space
133                                        if !extracted.ends_with(' ') {
134                                            extracted.push(' ');
135                                        }
136                                    }
137                                    Token::Real(kern) if kern < -150.0 => {
138                                        if !extracted.ends_with(' ') {
139                                            extracted.push(' ');
140                                        }
141                                    }
142                                    _ => {}
143                                }
144                            }
145                        }
146                        token_stack.clear();
147                    }
148                    _ => {
149                        token_stack.clear();
150                    }
151                },
152                other => {
153                    if in_text_object {
154                        token_stack.push(other);
155                    }
156                }
157            }
158        }
159
160        extracted.trim().to_string()
161    }
162}