Skip to main content

firecrawl_pdfium/
text.rs

1//! Text extraction with per-character geometry.
2
3use crate::coords::{PagePoint, PageRect};
4use crate::error::{Error, Result};
5use crate::page::PdfPage;
6use crate::sys;
7
8/// One character as reported by PDFium's text engine, with its geometry in
9/// page space.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct PageChar {
12    /// The Unicode scalar, or `None` when PDFium reports a code point Rust
13    /// cannot represent as `char` (rare; see [`PageChar::code`]).
14    pub unicode: Option<char>,
15    /// The raw code from `FPDFText_GetUnicode`.
16    pub code: u32,
17    /// Tight glyph bounding box in page space.
18    pub bounds: PageRect,
19    /// Loose bounding box (full advance/line metrics) in page space —
20    /// usually what you want for hit-testing and highlight rectangles.
21    pub loose_bounds: PageRect,
22    /// Glyph origin (baseline start) in page space.
23    pub origin: PagePoint,
24}
25
26/// Text content of one page: the extracted string plus per-character
27/// geometry. Plain owned data — no PDFium resources, `Send + Sync`,
28/// outlives page and document.
29///
30/// PDFium's text engine emits UCS-2: characters outside the Basic
31/// Multilingual Plane are not representable in [`text`](PageText::text)
32/// (PDFium substitutes or drops them), and each [`chars`](PageText::chars)
33/// entry carries the raw code in [`PageChar::code`]. Indexing also
34/// differs between the two views — correlate through geometry rather than
35/// assuming 1:1 index equality.
36#[derive(Debug, Clone, Default)]
37pub struct PageText {
38    text: String,
39    chars: Vec<PageChar>,
40}
41
42impl PageText {
43    /// The page's text in reading order, as extracted by PDFium
44    /// (includes generated whitespace/newlines).
45    pub fn text(&self) -> &str {
46        &self.text
47    }
48
49    /// Per-character data, indexed by PDFium character index.
50    pub fn chars(&self) -> &[PageChar] {
51        &self.chars
52    }
53
54    /// Number of PDFium characters on the page.
55    pub fn len(&self) -> usize {
56        self.chars.len()
57    }
58
59    /// True when the page has no text.
60    pub fn is_empty(&self) -> bool {
61        self.chars.is_empty()
62    }
63}
64
65/// Default ceiling for [`PdfPage::text`]: one million characters. Real
66/// pages hold a few thousand; the ceiling exists because a small hostile
67/// PDF can claim an enormous character count and this crate allocates
68/// roughly 90 bytes per character during extraction.
69pub const DEFAULT_MAX_TEXT_CHARS: usize = 1_000_000;
70
71impl<'doc> PdfPage<'doc> {
72    /// Extracts the page's text with per-character geometry, limited to
73    /// [`DEFAULT_MAX_TEXT_CHARS`] characters.
74    ///
75    /// Extraction is eager: the returned [`PageText`] holds no PDFium
76    /// resources and stays valid after the page is dropped.
77    pub fn text(&self) -> Result<PageText> {
78        self.text_with_limit(DEFAULT_MAX_TEXT_CHARS)
79    }
80
81    /// Like [`text`](Self::text) with an explicit character ceiling.
82    ///
83    /// Pages reporting more than `max_chars` characters fail with
84    /// [`Error::TextTooLarge`] *before* any allocation, mirroring how
85    /// [`RenderConfig::max_output_bytes`](crate::RenderConfig::max_output_bytes)
86    /// bounds rendering.
87    pub fn text_with_limit(&self, max_chars: usize) -> Result<PageText> {
88        let index = self.index();
89        self.ffi(|b| -> Result<PageText> {
90            // SAFETY: live page handle.
91            let text_page = unsafe { b.FPDFText_LoadPage(self.handle()) };
92            if text_page.is_null() {
93                return Err(Error::TextLoadFailed { index });
94            }
95            // Ensure FPDFText_ClosePage runs on every path below.
96            let result = extract(b, text_page, max_chars);
97            // SAFETY: live text page handle, closed exactly once.
98            unsafe { b.FPDFText_ClosePage(text_page) };
99            match result {
100                Ok(text) => Ok(text),
101                Err(ExtractFail::CountFailed) => Err(Error::TextLoadFailed { index }),
102                Err(ExtractFail::TooLarge { chars }) => Err(Error::TextTooLarge {
103                    chars,
104                    limit: max_chars,
105                }),
106            }
107        })
108    }
109}
110
111enum ExtractFail {
112    /// `FPDFText_CountChars` returned -1.
113    CountFailed,
114    /// The reported character count exceeds the caller's ceiling.
115    TooLarge { chars: usize },
116}
117
118/// Reads everything out of a live text page.
119fn extract(
120    b: &sys::Bindings,
121    text_page: sys::FPDF_TEXTPAGE,
122    max_chars: usize,
123) -> std::result::Result<PageText, ExtractFail> {
124    // SAFETY (all calls below): live text page handle; indices are within
125    // [0, count); out-pointers are valid locals.
126    let count = unsafe { b.FPDFText_CountChars(text_page) };
127    if count < 0 {
128        return Err(ExtractFail::CountFailed);
129    }
130    if count == 0 {
131        return Ok(PageText::default());
132    }
133    // The count is attacker-controlled input (a hostile PDF can claim
134    // near-i32::MAX characters); enforce the ceiling before allocating.
135    if count as usize > max_chars {
136        return Err(ExtractFail::TooLarge {
137            chars: count as usize,
138        });
139    }
140
141    // Full text: `count + 1` UTF-16 units (PDFium appends a NUL and counts
142    // it in the return value).
143    let mut units = vec![0u16; count as usize + 1];
144    let written = unsafe { b.FPDFText_GetText(text_page, 0, count, units.as_mut_ptr()) };
145    let text_units = if written > 0 {
146        &units[..(written as usize - 1)]
147    } else {
148        &[][..]
149    };
150    let text = String::from_utf16_lossy(text_units);
151
152    // Reserve conservatively: the ceiling already bounds the worst case,
153    // but a lying count should not pre-allocate gigabytes either.
154    let mut chars = Vec::with_capacity((count as usize).min(65_536));
155    for i in 0..count {
156        let code = unsafe { b.FPDFText_GetUnicode(text_page, i) };
157
158        let (mut left, mut right, mut bottom, mut top) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
159        // Note PDFium's parameter order here: left, right, bottom, top.
160        let have_box = unsafe {
161            b.FPDFText_GetCharBox(text_page, i, &mut left, &mut right, &mut bottom, &mut top)
162        } != 0;
163        let bounds = if have_box {
164            PageRect::new(left, bottom, right, top)
165        } else {
166            PageRect::new(0.0, 0.0, 0.0, 0.0)
167        };
168
169        let mut loose = sys::FS_RECTF::default();
170        let have_loose = unsafe { b.FPDFText_GetLooseCharBox(text_page, i, &mut loose) } != 0;
171        let loose_bounds = if have_loose {
172            // FS_RECTF carries top-left/bottom-right corners; PageRect is
173            // (left, bottom, right, top).
174            PageRect::new(
175                loose.left as f64,
176                loose.bottom as f64,
177                loose.right as f64,
178                loose.top as f64,
179            )
180        } else {
181            bounds
182        };
183
184        let (mut ox, mut oy) = (0.0f64, 0.0f64);
185        let have_origin = unsafe { b.FPDFText_GetCharOrigin(text_page, i, &mut ox, &mut oy) } != 0;
186        let origin = if have_origin {
187            PagePoint::new(ox, oy)
188        } else {
189            PagePoint::new(bounds.left, bounds.bottom)
190        };
191
192        chars.push(PageChar {
193            unicode: char::from_u32(code),
194            code,
195            bounds,
196            loose_bounds,
197            origin,
198        });
199    }
200
201    Ok(PageText { text, chars })
202}