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}