Skip to main content

otf_pdf_text/
lib.rs

1//! Text extraction: runs each page through the content interpreter with a
2//! device that collects glyphs, then groups them into lines and words.
3//!
4//! * Coordinates are in the page's displayed space: origin at the crop
5//!   box's lower-left corner, `/Rotate` applied, y up, in points.
6//! * Reading order is the content stream's, with glyphs that continue the
7//!   same baseline joined into one line and gaps wider than a fifth of the
8//!   font size read as spaces (like `pdftotext -raw`).
9//! * `/ActualText` replaces the glyphs it covers; invisible text (rendering
10//!   mode 3, as in OCR layers) is included.
11#![forbid(unsafe_code)]
12
13use otf_pdf_content::{Device, Glyph, GraphicsState, run_page};
14use otf_pdf_doc::{Document, Page, Rect, decode_text_string};
15use otf_pdf_font::FontCache;
16use otf_pdf_syntax::{Dict, Error, Name, Result};
17
18/// One character (or `/ActualText` replacement) as drawn.
19#[derive(Debug, Clone, PartialEq)]
20pub struct TextChar {
21    /// The text: usually one character, sometimes a ligature's letters.
22    pub text: String,
23    /// Baseline start, in displayed page space.
24    pub origin: (f64, f64),
25    /// Baseline end (where the next character would start).
26    pub end: (f64, f64),
27    /// Font size in page space.
28    pub size: f64,
29    /// The font's name.
30    pub font: String,
31    /// Whether it is drawn invisibly (rendering mode 3).
32    pub invisible: bool,
33}
34
35/// A word: characters between spaces on one line.
36#[derive(Debug, Clone, PartialEq)]
37pub struct Word {
38    /// The word's text.
39    pub text: String,
40    /// Its bounding box in displayed page space (approximated from the
41    /// font size: 0.8 em above the baseline, 0.2 em below).
42    pub bbox: Rect,
43}
44
45/// A line of text.
46#[derive(Debug, Clone, PartialEq)]
47pub struct TextLine {
48    /// The line's text, words separated by single spaces where the gaps
49    /// call for them.
50    pub text: String,
51    /// The line's bounding box.
52    pub bbox: Rect,
53    /// The characters, in order.
54    pub chars: Vec<TextChar>,
55}
56
57impl TextLine {
58    /// The line's words, split at whitespace.
59    pub fn words(&self) -> Vec<Word> {
60        let mut words = Vec::new();
61        let mut current: Vec<&TextChar> = Vec::new();
62        let flush = |current: &mut Vec<&TextChar>, words: &mut Vec<Word>| {
63            if !current.is_empty() {
64                words.push(Word {
65                    text: current.iter().map(|c| c.text.as_str()).collect(),
66                    bbox: bounds(current.iter().copied()),
67                });
68                current.clear();
69            }
70        };
71        for c in &self.chars {
72            if c.text.trim().is_empty() || c.text == " " {
73                flush(&mut current, &mut words);
74            } else {
75                current.push(c);
76            }
77        }
78        flush(&mut current, &mut words);
79        words
80    }
81}
82
83/// The text of one page.
84#[derive(Debug, Clone, PartialEq, Default)]
85pub struct PageText {
86    /// Lines in reading order.
87    pub lines: Vec<TextLine>,
88}
89
90impl PageText {
91    /// The page's text, one line per line.
92    pub fn text(&self) -> String {
93        let mut out = String::new();
94        for line in &self.lines {
95            out.push_str(&line.text);
96            out.push('\n');
97        }
98        out
99    }
100
101    /// Every word on the page, in reading order.
102    pub fn words(&self) -> Vec<Word> {
103        self.lines.iter().flat_map(TextLine::words).collect()
104    }
105}
106
107/// Extracts text from a document. Fonts are loaded once and shared across
108/// pages.
109pub struct TextExtractor<'a> {
110    doc: &'a Document,
111    fonts: FontCache,
112}
113
114impl<'a> TextExtractor<'a> {
115    /// An extractor for `doc`.
116    pub fn new(doc: &'a Document) -> Self {
117        Self {
118            doc,
119            fonts: FontCache::new(),
120        }
121    }
122
123    /// The text of page `index` (0-based), with positions.
124    pub fn page(&self, index: usize) -> Result<PageText> {
125        let pages = self.doc.pages()?;
126        let page = pages
127            .get(index)
128            .ok_or(Error::syntax(0, "page index out of range"))?;
129        let mut device = Collector::new(page);
130        run_page(self.doc.store(), page, &self.fonts, &mut device)?;
131        Ok(PageText {
132            lines: build_lines(device.chars),
133        })
134    }
135
136    /// The plain text of page `index`.
137    pub fn page_text(&self, index: usize) -> Result<String> {
138        self.page(index).map(|p| p.text())
139    }
140
141    /// The plain text of every page, pages separated by form feeds (`\x0c`)
142    /// as `pdftotext` does. A page whose content cannot be read contributes
143    /// nothing rather than failing the document.
144    pub fn text(&self) -> Result<String> {
145        let count = self.doc.page_count()?;
146        let mut out = String::new();
147        for i in 0..count {
148            match self.page_text(i) {
149                Ok(t) => out.push_str(&t),
150                Err(e @ Error::Limit(_)) => return Err(e),
151                Err(_) => {}
152            }
153            out.push('\x0c');
154        }
155        Ok(out)
156    }
157}
158
159/// Maps page space to displayed space: crop box origin, then `/Rotate`.
160#[derive(Debug, Clone, Copy)]
161struct Display {
162    crop: Rect,
163    rotate: u16,
164}
165
166impl Display {
167    fn point(&self, (x, y): (f64, f64)) -> (f64, f64) {
168        let Rect { llx, lly, urx, ury } = self.crop;
169        match self.rotate {
170            90 => (y - lly, urx - x),
171            180 => (urx - x, ury - y),
172            270 => (ury - y, x - llx),
173            _ => (x - llx, y - lly),
174        }
175    }
176}
177
178/// The device collecting characters.
179struct Collector {
180    display: Display,
181    chars: Vec<TextChar>,
182    /// Marked content: `Some(text)` for an `/ActualText` span, and whether
183    /// its character was emitted yet.
184    marked: Vec<Option<(String, bool)>>,
185}
186
187impl Collector {
188    fn new(page: &Page) -> Self {
189        Self {
190            display: Display {
191                crop: page.crop_box,
192                rotate: page.rotate,
193            },
194            chars: Vec::new(),
195            marked: Vec::new(),
196        }
197    }
198}
199
200impl Device for Collector {
201    fn draw_glyph(&mut self, glyph: &Glyph, state: &GraphicsState) {
202        // Clipped away: not visible, not extracted (a field's text past its
203        // box). Half a point of slack absorbs rounding.
204        if !state.in_clip(glyph.origin, 0.5) {
205            return;
206        }
207        let origin = self.display.point(glyph.origin);
208        let end = self.display.point(glyph.end);
209        // Inside /ActualText: the first glyph carries the replacement, the
210        // rest only extend it.
211        if let Some(Some((text, emitted))) = self.marked.iter_mut().rev().find(|m| m.is_some()) {
212            if *emitted {
213                if let Some(last) = self.chars.last_mut() {
214                    last.end = end;
215                }
216                return;
217            }
218            *emitted = true;
219            let text = text.clone();
220            self.chars.push(TextChar {
221                text,
222                origin,
223                end,
224                size: glyph.size,
225                font: glyph.font.name().to_owned(),
226                invisible: glyph.render_mode == 3,
227            });
228            return;
229        }
230        let Some(text) = glyph.unicode.clone() else {
231            return;
232        };
233        // Control characters carry no text.
234        let text: String = text
235            .chars()
236            .filter(|c| !c.is_control() || *c == '\t')
237            .collect();
238        if text.is_empty() {
239            return;
240        }
241        self.chars.push(TextChar {
242            text,
243            origin,
244            end,
245            size: glyph.size,
246            font: glyph.font.name().to_owned(),
247            invisible: glyph.render_mode == 3,
248        });
249    }
250
251    fn begin_marked_content(&mut self, _tag: &Name, properties: Option<&Dict>) {
252        let actual = properties
253            .and_then(|p| p.get(b"ActualText"))
254            .and_then(|t| t.as_string())
255            .map(decode_text_string);
256        self.marked.push(actual.map(|t| (t, false)));
257    }
258
259    fn end_marked_content(&mut self) {
260        self.marked.pop();
261    }
262}
263
264fn bounds<'c>(chars: impl Iterator<Item = &'c TextChar>) -> Rect {
265    let mut r = Rect {
266        llx: f64::INFINITY,
267        lly: f64::INFINITY,
268        urx: f64::NEG_INFINITY,
269        ury: f64::NEG_INFINITY,
270    };
271    for c in chars {
272        let (dx, dy) = direction(c);
273        // Up is the baseline direction turned left.
274        let (ux, uy) = (-dy, dx);
275        for (x, y) in [c.origin, c.end] {
276            for k in [0.8, -0.2] {
277                let px = x + ux * k * c.size;
278                let py = y + uy * k * c.size;
279                r.llx = r.llx.min(px);
280                r.lly = r.lly.min(py);
281                r.urx = r.urx.max(px);
282                r.ury = r.ury.max(py);
283            }
284        }
285    }
286    if !r.llx.is_finite() {
287        return Rect {
288            llx: 0.0,
289            lly: 0.0,
290            urx: 0.0,
291            ury: 0.0,
292        };
293    }
294    r
295}
296
297/// The unit baseline direction of a character (right if it has no extent).
298fn direction(c: &TextChar) -> (f64, f64) {
299    let (dx, dy) = (c.end.0 - c.origin.0, c.end.1 - c.origin.1);
300    let len = (dx * dx + dy * dy).sqrt();
301    if len < 1e-9 {
302        (1.0, 0.0)
303    } else {
304        (dx / len, dy / len)
305    }
306}
307
308/// Fraction of the font size a gap must exceed to read as a space.
309const SPACE_GAP: f64 = 0.2;
310
311/// Groups characters, in drawing order, into lines.
312fn build_lines(chars: Vec<TextChar>) -> Vec<TextLine> {
313    let mut lines: Vec<TextLine> = Vec::new();
314    let mut current: Vec<TextChar> = Vec::new();
315    let mut text = String::new();
316    let mut dir = (1.0, 0.0);
317
318    let finish = |current: &mut Vec<TextChar>, text: &mut String, lines: &mut Vec<TextLine>| {
319        let t = text.trim_end().to_owned();
320        if !current.is_empty() && !t.is_empty() {
321            lines.push(TextLine {
322                text: t,
323                bbox: bounds(current.iter()),
324                chars: std::mem::take(current),
325            });
326        }
327        current.clear();
328        text.clear();
329    };
330
331    for c in chars {
332        let d = direction(&c);
333        if let Some(last) = current.last() {
334            let size = c.size.max(last.size).max(1e-6);
335            let same_direction = d.0 * dir.0 + d.1 * dir.1 > 0.985;
336            let (gx, gy) = (c.origin.0 - last.end.0, c.origin.1 - last.end.1);
337            let along = gx * dir.0 + gy * dir.1;
338            let across = (-gx * dir.1 + gy * dir.0).abs();
339            let continues = same_direction && across < 0.5 * size && along > -0.5 * size;
340            if !continues {
341                finish(&mut current, &mut text, &mut lines);
342            } else if along > SPACE_GAP * size
343                && !text.ends_with(char::is_whitespace)
344                && !c.text.starts_with(char::is_whitespace)
345            {
346                text.push(' ');
347                current.push(TextChar {
348                    text: " ".into(),
349                    origin: last.end,
350                    end: c.origin,
351                    size: c.size,
352                    font: c.font.clone(),
353                    invisible: c.invisible,
354                });
355            }
356        }
357        if current.is_empty() {
358            dir = d;
359        }
360        text.push_str(&c.text);
361        current.push(c);
362    }
363    finish(&mut current, &mut text, &mut lines);
364    lines
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn ch(text: &str, x: f64, y: f64, w: f64) -> TextChar {
372        TextChar {
373            text: text.into(),
374            origin: (x, y),
375            end: (x + w, y),
376            size: 10.0,
377            font: "F".into(),
378            invisible: false,
379        }
380    }
381
382    #[test]
383    fn lines_spaces_and_words() {
384        let chars = vec![
385            ch("H", 0.0, 100.0, 6.0),
386            ch("i", 6.0, 100.0, 3.0),
387            // A gap of 5 (> 0.2 em): a space.
388            ch("y", 14.0, 100.0, 5.0),
389            ch("o", 19.0, 100.0, 5.0),
390            // Next line.
391            ch("N", 0.0, 85.0, 7.0),
392            ch(" ", 7.0, 85.0, 3.0),
393            ch("o", 10.0, 85.0, 5.0),
394        ];
395        let lines = build_lines(chars);
396        assert_eq!(lines.len(), 2);
397        assert_eq!(lines[0].text, "Hi yo");
398        assert_eq!(lines[1].text, "N o");
399        let words = lines[0].words();
400        assert_eq!(
401            words.iter().map(|w| w.text.as_str()).collect::<Vec<_>>(),
402            ["Hi", "yo"]
403        );
404        assert_eq!(words[0].bbox.llx, 0.0);
405        assert_eq!(words[0].bbox.urx, 9.0);
406        assert!((words[0].bbox.ury - 108.0).abs() < 1e-9);
407        let page = PageText { lines };
408        assert_eq!(page.text(), "Hi yo\nN o\n");
409        assert_eq!(page.words().len(), 4);
410    }
411
412    #[test]
413    fn going_backwards_starts_a_line() {
414        let lines = build_lines(vec![ch("a", 50.0, 10.0, 5.0), ch("b", 0.0, 10.0, 5.0)]);
415        assert_eq!(lines.len(), 2);
416    }
417
418    #[test]
419    fn vertical_text_is_its_own_line() {
420        let down = |t: &str, y: f64| TextChar {
421            text: t.into(),
422            origin: (0.0, y),
423            end: (0.0, y - 10.0),
424            size: 10.0,
425            font: "F".into(),
426            invisible: false,
427        };
428        let lines = build_lines(vec![
429            down("縦", 100.0),
430            down("書", 90.0),
431            ch("x", 50.0, 50.0, 5.0),
432        ]);
433        assert_eq!(lines.len(), 2);
434        assert_eq!(lines[0].text, "縦書");
435    }
436
437    #[test]
438    fn rotation_to_display_space() {
439        let crop = Rect {
440            llx: 0.0,
441            lly: 0.0,
442            urx: 100.0,
443            ury: 200.0,
444        };
445        let at = |rotate| Display { crop, rotate }.point((0.0, 200.0));
446        assert_eq!(at(0), (0.0, 200.0));
447        assert_eq!(at(90), (200.0, 100.0));
448        assert_eq!(at(180), (100.0, 0.0));
449        assert_eq!(at(270), (0.0, 0.0));
450    }
451}