firecrawl_pdfium/document.rs
1//! Documents: loading, inspection, and page access.
2
3use std::ffi::{c_ulong, CString};
4use std::path::Path;
5use std::sync::OnceLock;
6
7use crate::error::{Error, Result};
8use crate::forms::{FormEnv, FormType};
9use crate::library::Pdfium;
10use crate::page::{PageSize, PdfPage};
11use crate::sys;
12
13/// Standard PDF metadata tags accepted by [`PdfDocument::metadata`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum MetadataTag {
17 /// Document title.
18 Title,
19 /// Author.
20 Author,
21 /// Subject.
22 Subject,
23 /// Keywords.
24 Keywords,
25 /// Application that created the original document.
26 Creator,
27 /// Application that produced the PDF.
28 Producer,
29 /// Creation date (PDF date string, e.g. `D:20260810...`).
30 CreationDate,
31 /// Last-modified date (PDF date string).
32 ModDate,
33}
34
35impl MetadataTag {
36 fn as_cstr(self) -> &'static std::ffi::CStr {
37 match self {
38 MetadataTag::Title => c"Title",
39 MetadataTag::Author => c"Author",
40 MetadataTag::Subject => c"Subject",
41 MetadataTag::Keywords => c"Keywords",
42 MetadataTag::Creator => c"Creator",
43 MetadataTag::Producer => c"Producer",
44 MetadataTag::CreationDate => c"CreationDate",
45 MetadataTag::ModDate => c"ModDate",
46 }
47 }
48}
49
50/// Document permission flags from the PDF's encryption dictionary
51/// (`FPDF_GetDocPermissions`). For unencrypted documents every permission
52/// is granted (`0xFFFF_FFFF`).
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Permissions(pub u64);
55
56impl Permissions {
57 fn bit(&self, n: u32) -> bool {
58 self.0 & (1 << (n - 1)) != 0
59 }
60
61 /// Bit 3: print the document.
62 pub fn can_print(&self) -> bool {
63 self.bit(3)
64 }
65
66 /// Bit 4: modify contents.
67 pub fn can_modify(&self) -> bool {
68 self.bit(4)
69 }
70
71 /// Bit 5: copy or extract text and graphics.
72 pub fn can_copy(&self) -> bool {
73 self.bit(5)
74 }
75
76 /// Bit 6: add or modify annotations / fill form fields.
77 pub fn can_annotate(&self) -> bool {
78 self.bit(6)
79 }
80}
81
82/// An open PDF document.
83///
84/// The document **owns the PDF bytes** for its whole lifetime (PDFium
85/// requires the backing buffer to stay valid while the document is open).
86/// Dropping the document releases the PDFium handle and, if forms were
87/// enabled, the form-fill environment first (in the order PDFium requires).
88///
89/// # Thread safety
90///
91/// `PdfDocument` is `Send + Sync`: every method serializes through the
92/// process-wide FFI lock. Sharing one document across threads is safe;
93/// calls will not run in parallel.
94pub struct PdfDocument {
95 pdfium: Pdfium,
96 handle: sys::FPDF_DOCUMENT,
97 /// Backing buffer for `handle`; must outlive it. Boxed slice so the
98 /// heap address is stable regardless of moves of `PdfDocument`.
99 _bytes: Box<[u8]>,
100 forms: OnceLock<FormEnv>,
101 page_count: usize,
102}
103
104// SAFETY: `handle` (and the handles inside `forms`) are only ever passed to
105// PDFium under the process-wide FFI lock; `_bytes` is never written after
106// construction. No method provides unsynchronized interior access.
107unsafe impl Send for PdfDocument {}
108// SAFETY: all `&self` methods acquire the FFI lock before touching PDFium
109// state, so concurrent `&self` access from multiple threads is serialized.
110unsafe impl Sync for PdfDocument {}
111
112impl std::fmt::Debug for PdfDocument {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("PdfDocument")
115 .field("page_count", &self.page_count)
116 .field("forms_enabled", &self.forms.get().is_some())
117 .finish_non_exhaustive()
118 }
119}
120
121impl Pdfium {
122 /// Opens a PDF from bytes, taking ownership of them.
123 ///
124 /// `password` unlocks encrypted documents (PDFium tries UTF-8 then
125 /// Latin-1 encodings of it). Pass `None` for unencrypted documents;
126 /// a `Some` password is ignored by unencrypted documents.
127 ///
128 /// # Errors
129 ///
130 /// - [`Error::PasswordRequired`] — encrypted, no password given.
131 /// - [`Error::IncorrectPassword`] — encrypted, wrong password.
132 /// - [`Error::UnsupportedSecurity`] — unsupported encryption scheme.
133 /// - [`Error::InvalidPdf`] — not a PDF / unrecoverably corrupt.
134 pub fn load_document(
135 &self,
136 bytes: impl Into<Vec<u8>>,
137 password: Option<&str>,
138 ) -> Result<PdfDocument> {
139 let bytes: Box<[u8]> = bytes.into().into_boxed_slice();
140 let c_password =
141 match password {
142 Some(p) => Some(CString::new(p).map_err(|_| {
143 Error::InvalidConfig("password must not contain NUL bytes".into())
144 })?),
145 None => None,
146 };
147
148 let (handle, last_error) = self.ffi(|b| {
149 // SAFETY: `bytes` is a live allocation of the given length and
150 // outlives the handle (owned by the PdfDocument built below;
151 // on error the handle is never created). Password pointer is a
152 // valid NUL-terminated string or null.
153 let handle = unsafe {
154 b.FPDF_LoadMemDocument64(
155 bytes.as_ptr().cast(),
156 bytes.len(),
157 c_password.as_ref().map_or(std::ptr::null(), |p| p.as_ptr()),
158 )
159 };
160 // FPDF_GetLastError is documented as meaningful only right
161 // after a failed load; fetch it in the same critical section
162 // so another thread's call cannot clobber it.
163 let last_error = if handle.is_null() {
164 // SAFETY: no preconditions beyond initialization.
165 unsafe { b.FPDF_GetLastError() }
166 } else {
167 sys::FPDF_ERR_SUCCESS
168 };
169 (handle, last_error)
170 });
171
172 if handle.is_null() {
173 return Err(map_load_error(last_error, password.is_some()));
174 }
175
176 // SAFETY: valid document handle.
177 let raw_count = self.ffi(|b| unsafe { b.FPDF_GetPageCount(handle) });
178 let page_count = usize::try_from(raw_count).unwrap_or(0);
179
180 Ok(PdfDocument {
181 pdfium: *self,
182 handle,
183 _bytes: bytes,
184 forms: OnceLock::new(),
185 page_count,
186 })
187 }
188
189 /// Opens a PDF file from disk (reads it fully into memory first —
190 /// PDFium is fastest and simplest with in-memory documents).
191 pub fn load_document_from_file(
192 &self,
193 path: impl AsRef<Path>,
194 password: Option<&str>,
195 ) -> Result<PdfDocument> {
196 let bytes = std::fs::read(path).map_err(Error::Io)?;
197 self.load_document(bytes, password)
198 }
199}
200
201fn map_load_error(code: c_ulong, password_supplied: bool) -> Error {
202 match code {
203 sys::FPDF_ERR_PASSWORD => {
204 if password_supplied {
205 Error::IncorrectPassword
206 } else {
207 Error::PasswordRequired
208 }
209 }
210 sys::FPDF_ERR_SECURITY => Error::UnsupportedSecurity,
211 sys::FPDF_ERR_FORMAT | sys::FPDF_ERR_FILE => Error::InvalidPdf,
212 // FPDF_ERR_SUCCESS with a null handle should not happen; treat any
213 // unexpected code (incl. UNKNOWN and PAGE) as an opaque PDFium error.
214 // `c_ulong` is 32-bit on Windows; the widening cast is real there.
215 #[allow(clippy::unnecessary_cast)]
216 code => Error::Pdfium { code: code as u64 },
217 }
218}
219
220impl PdfDocument {
221 /// Number of pages.
222 pub fn page_count(&self) -> usize {
223 self.page_count
224 }
225
226 /// Opens page `index` (0-based).
227 pub fn page(&self, index: usize) -> Result<PdfPage<'_>> {
228 if index >= self.page_count {
229 return Err(Error::PageIndexOutOfBounds {
230 index,
231 count: self.page_count,
232 });
233 }
234 PdfPage::open(self, index)
235 }
236
237 /// Iterates over all pages, opening each lazily.
238 pub fn pages(&self) -> impl Iterator<Item = Result<PdfPage<'_>>> {
239 (0..self.page_count).map(move |i| self.page(i))
240 }
241
242 /// Page size in points **without loading the page** — cheap for
243 /// dimension surveys of large documents.
244 pub fn page_size(&self, index: usize) -> Result<PageSize> {
245 if index >= self.page_count {
246 return Err(Error::PageIndexOutOfBounds {
247 index,
248 count: self.page_count,
249 });
250 }
251 let mut size = sys::FS_SIZEF::default();
252 // SAFETY: valid handle, in-bounds index, valid out-pointer.
253 let ok = self
254 .ffi(|b| unsafe { b.FPDF_GetPageSizeByIndexF(self.handle, index as i32, &mut size) });
255 if ok != 0 {
256 Ok(PageSize {
257 width: size.width,
258 height: size.height,
259 })
260 } else {
261 Err(Error::PageLoadFailed { index })
262 }
263 }
264
265 /// The document's interactive form type (cheap; does not initialize
266 /// form rendering).
267 pub fn form_type(&self) -> FormType {
268 // SAFETY: valid handle.
269 FormType::from_raw(self.ffi(|b| unsafe { b.FPDF_GetFormType(self.handle) }))
270 }
271
272 /// Initializes PDFium's form-fill environment so
273 /// [`RenderConfig::form_fields`](crate::RenderConfig::form_fields)
274 /// can draw AcroForm field appearances.
275 ///
276 /// Idempotent. Pages opened **after** this call participate in form
277 /// rendering; enable forms before opening pages you intend to render.
278 pub fn enable_form_rendering(&self) -> Result<()> {
279 if self.forms.get().is_some() {
280 return Ok(());
281 }
282 let env = FormEnv::new(self.pdfium, self.handle)?;
283 // A racing second init would leak a FormEnv teardown; set() failing
284 // means another thread won — destroy ours cleanly.
285 if let Err(mut lost) = self.forms.set(env) {
286 self.pdfium.ffi(|b| lost.destroy(b));
287 }
288 Ok(())
289 }
290
291 /// Whether [`enable_form_rendering`](Self::enable_form_rendering) has
292 /// been called successfully.
293 pub fn forms_enabled(&self) -> bool {
294 self.forms.get().is_some()
295 }
296
297 /// Document permissions from the encryption dictionary. Unencrypted
298 /// documents report all permissions granted.
299 pub fn permissions(&self) -> Permissions {
300 // SAFETY: valid handle.
301 // `c_ulong` is 32-bit on Windows; the widening cast is real there.
302 #[allow(clippy::unnecessary_cast)]
303 Permissions(self.ffi(|b| unsafe { b.FPDF_GetDocPermissions(self.handle) }) as u64)
304 }
305
306 /// Security handler revision (2/3/4/5/6), or `None` for unencrypted
307 /// documents.
308 pub fn security_handler_revision(&self) -> Option<i32> {
309 // SAFETY: valid handle.
310 let rev = self.ffi(|b| unsafe { b.FPDF_GetSecurityHandlerRevision(self.handle) });
311 (rev != -1).then_some(rev)
312 }
313
314 /// PDF file version as reported by the header, times ten
315 /// (14 = PDF 1.4, 17 = PDF 1.7, 20 = PDF 2.0). `None` if unavailable.
316 pub fn pdf_version(&self) -> Option<i32> {
317 let mut version = 0;
318 // SAFETY: valid handle and out-pointer.
319 let ok = self.ffi(|b| unsafe { b.FPDF_GetFileVersion(self.handle, &mut version) });
320 (ok != 0).then_some(version)
321 }
322
323 /// A standard metadata field, or `None` when absent/empty.
324 pub fn metadata(&self, tag: MetadataTag) -> Option<String> {
325 self.ffi(|b| {
326 read_utf16le_buffer(|buffer, buflen| {
327 // SAFETY: valid handle; tag is a NUL-terminated static;
328 // buffer/buflen follow the two-call length protocol.
329 unsafe { b.FPDF_GetMetaText(self.handle, tag.as_cstr().as_ptr(), buffer, buflen) }
330 })
331 })
332 }
333
334 /// The page label for `index` (e.g. "iv", "A-2"), or `None` when the
335 /// document defines no label for it.
336 pub fn page_label(&self, index: usize) -> Option<String> {
337 if index >= self.page_count {
338 return None;
339 }
340 self.ffi(|b| {
341 read_utf16le_buffer(|buffer, buflen| {
342 // SAFETY: valid handle, in-bounds index, two-call protocol.
343 unsafe { b.FPDF_GetPageLabel(self.handle, index as i32, buffer, buflen) }
344 })
345 })
346 }
347
348 pub(crate) fn pdfium(&self) -> Pdfium {
349 self.pdfium
350 }
351
352 pub(crate) fn handle(&self) -> sys::FPDF_DOCUMENT {
353 self.handle
354 }
355
356 pub(crate) fn form_env(&self) -> Option<&FormEnv> {
357 self.forms.get()
358 }
359
360 fn ffi<R>(&self, f: impl FnOnce(&sys::Bindings) -> R) -> R {
361 self.pdfium.ffi(f)
362 }
363}
364
365impl Drop for PdfDocument {
366 fn drop(&mut self) {
367 self.pdfium.ffi(|b| {
368 // Teardown order required by PDFium: exit the form-fill
369 // environment before closing the document it wraps.
370 if let Some(env) = self.forms.get_mut() {
371 env.destroy(b);
372 }
373 // SAFETY: handle is live (created in load_document, closed
374 // only here); pages hold `&PdfDocument`, so the borrow checker
375 // guarantees none outlive this drop.
376 unsafe { b.FPDF_CloseDocument(self.handle) };
377 });
378 }
379}
380
381/// Runs PDFium's two-call UTF-16LE string protocol: first call with a null
382/// buffer returns the byte length including the NUL terminator; `0` means
383/// "no such value".
384fn read_utf16le_buffer(
385 mut call: impl FnMut(*mut std::ffi::c_void, c_ulong) -> c_ulong,
386) -> Option<String> {
387 let byte_len = call(std::ptr::null_mut(), 0);
388 if byte_len < 2 {
389 return None; // absent, or empty (just the terminator)
390 }
391 let unit_len = (byte_len as usize) / 2;
392 let mut units = vec![0u16; unit_len];
393 // Pass the actual allocated size, not the echoed length: if PDFium ever
394 // reported an odd byte_len, echoing it back would overstate the buffer
395 // by one byte.
396 let written = call(units.as_mut_ptr().cast(), (unit_len * 2) as c_ulong);
397 if written == 0 {
398 return None;
399 }
400 let written_units = (written as usize / 2).min(unit_len);
401 // Strip the trailing NUL terminator.
402 let text_units = &units[..written_units.saturating_sub(1)];
403 if text_units.is_empty() {
404 return None;
405 }
406 Some(String::from_utf16_lossy(text_units))
407}