Skip to main content

gaze_document/extract/
pdf.rs

1//! PDF text extraction and rasterization via [`pdfium-render`](https://crates.io/crates/pdfium-render).
2//!
3//! ## Runtime dependency
4//!
5//! `pdfium-render` dynamically loads the pdfium shared library at runtime.
6//! Adopters must have `libpdfium` reachable to the process (system library,
7//! `LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH`, or alongside the executable).
8//!
9//! Per-OS install guidance is surfaced in [`DocumentError::PdfiumNotFound`]
10//! whenever binding fails.
11//!
12//! ## Scope (v0.0.x)
13//!
14//! * Selectable text is extracted directly, per page, before OCR is attempted.
15//! * Pages with no selectable text are rasterized at 150 DPI by default.
16
17use std::io::Cursor;
18use std::path::Path;
19
20use image::ImageFormat;
21use pdfium_render::prelude::{PdfRenderConfig, Pdfium, PdfiumError};
22
23use crate::DocumentError;
24
25/// Configuration for one PDF rasterization pass.
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy)]
28pub struct PdfRasterConfig {
29    /// Target image width in pixels (height auto-scales).
30    pub width_px: u32,
31    /// Target image height in pixels (height auto-scales when 0).
32    pub height_px: u32,
33    /// Zero-based page index to rasterize.
34    pub page_index: i32,
35}
36
37impl PdfRasterConfig {
38    /// Default config: 1240×1754 (≈150 DPI A4) on page 0.
39    pub fn new() -> Self {
40        Self {
41            width_px: 1240,
42            height_px: 1754,
43            page_index: 0,
44        }
45    }
46}
47
48impl Default for PdfRasterConfig {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54/// Result of rasterizing a PDF page.
55#[non_exhaustive]
56#[derive(Debug, Clone)]
57pub struct RasterizedPage {
58    /// PNG-encoded image bytes.
59    pub png_bytes: Vec<u8>,
60    /// Page index that was rasterized.
61    pub page_index: i32,
62    /// Total page count in the source document.
63    pub page_count: i32,
64    /// Width in pixels of the rasterized page.
65    pub width_px: u32,
66    /// Height in pixels of the rasterized page.
67    pub height_px: u32,
68}
69
70impl RasterizedPage {
71    /// Build a [`RasterizedPage`] from already-encoded fields.
72    pub fn new(
73        png_bytes: Vec<u8>,
74        page_index: i32,
75        page_count: i32,
76        width_px: u32,
77        height_px: u32,
78    ) -> Self {
79        Self {
80            png_bytes,
81            page_index,
82            page_count,
83            width_px,
84            height_px,
85        }
86    }
87}
88
89/// Per-page PDF extraction payload.
90#[non_exhaustive]
91#[derive(Debug, Clone)]
92pub enum PdfPagePayload {
93    /// Page had selectable text and did not require OCR.
94    VectorText {
95        /// Extracted text in PDF text order.
96        text: String,
97        /// Zero-based page index.
98        page_index: i32,
99        /// Total page count in the source document.
100        page_count: i32,
101    },
102    /// Page had no selectable text and was rasterized for OCR.
103    Raster(RasterizedPage),
104}
105
106impl PdfPagePayload {
107    /// Zero-based page index.
108    pub fn page_index(&self) -> i32 {
109        match self {
110            Self::VectorText { page_index, .. } => *page_index,
111            Self::Raster(page) => page.page_index,
112        }
113    }
114
115    /// Total page count in the source document.
116    pub fn page_count(&self) -> i32 {
117        match self {
118            Self::VectorText { page_count, .. } => *page_count,
119            Self::Raster(page) => page.page_count,
120        }
121    }
122}
123
124/// Extract every PDF page, routing selectable-text pages directly and
125/// rasterizing image-only pages for OCR.
126///
127/// # Errors
128///
129/// Returns [`DocumentError`] when pdfium cannot open the document or a page
130/// cannot be rasterized.
131pub fn extract_pages(
132    path: &Path,
133    config: PdfRasterConfig,
134) -> Result<Vec<PdfPagePayload>, DocumentError> {
135    let bindings = Pdfium::bind_to_system_library().map_err(|err| {
136        DocumentError::PdfiumNotFound(format!("{}. {}", err, pdfium_install_hint()))
137    })?;
138    let pdfium = Pdfium::new(bindings);
139    let document = pdfium
140        .load_pdf_from_file(path, None)
141        .map_err(map_pdfium_error)?;
142    let pages = document.pages();
143    let page_count = pages.len();
144    if page_count == 0 {
145        return Err(DocumentError::PdfRasterFailed(
146            "input PDF contains zero pages".to_string(),
147        ));
148    }
149
150    let mut out = Vec::with_capacity(page_count as usize);
151    for page_index in 0..page_count {
152        let page = pages.get(page_index).map_err(map_pdfium_error)?;
153        let text = page
154            .text()
155            .ok()
156            .map(|page_text| normalize_pdf_text(&page_text.all()))
157            .unwrap_or_default();
158        if !text.trim().is_empty() {
159            out.push(PdfPagePayload::VectorText {
160                text,
161                page_index,
162                page_count,
163            });
164            continue;
165        }
166
167        out.push(PdfPagePayload::Raster(render_page(
168            &page, page_index, page_count, config,
169        )?));
170    }
171
172    Ok(out)
173}
174
175fn render_page(
176    page: &pdfium_render::prelude::PdfPage<'_>,
177    page_index: i32,
178    page_count: i32,
179    config: PdfRasterConfig,
180) -> Result<RasterizedPage, DocumentError> {
181    let mut render_config = PdfRenderConfig::new().set_target_width(config.width_px as i32);
182    if config.height_px > 0 {
183        render_config = render_config.set_maximum_height(config.height_px as i32);
184    }
185    let bitmap = page
186        .render_with_config(&render_config)
187        .map_err(map_pdfium_error)?;
188    let dynamic_image = bitmap.as_image().map_err(map_pdfium_error)?;
189    let (width, height) = (dynamic_image.width(), dynamic_image.height());
190
191    let mut buf = Cursor::new(Vec::with_capacity(64 * 1024));
192    dynamic_image
193        .write_to(&mut buf, ImageFormat::Png)
194        .map_err(|err| DocumentError::PdfRasterFailed(format!("png encode failed: {err}")))?;
195
196    Ok(RasterizedPage {
197        png_bytes: buf.into_inner(),
198        page_index,
199        page_count,
200        width_px: width,
201        height_px: height,
202    })
203}
204
205fn normalize_pdf_text(text: &str) -> String {
206    text.replace('\0', "")
207        .lines()
208        .map(str::trim_end)
209        .collect::<Vec<_>>()
210        .join("\n")
211        .trim()
212        .to_string()
213}
214
215fn map_pdfium_error(err: PdfiumError) -> DocumentError {
216    DocumentError::PdfRasterFailed(err.to_string())
217}
218
219fn pdfium_install_hint() -> String {
220    if cfg!(target_os = "macos") {
221        "Download the pdfium dynamic library from https://github.com/bblanchon/pdfium-binaries \
222         and place `libpdfium.dylib` on DYLD_LIBRARY_PATH, in /usr/local/lib, or next to your binary."
223            .to_string()
224    } else if cfg!(target_os = "linux") {
225        "Download the pdfium dynamic library from https://github.com/bblanchon/pdfium-binaries \
226         and place `libpdfium.so` on LD_LIBRARY_PATH, in /usr/local/lib, or next to your binary."
227            .to_string()
228    } else if cfg!(target_os = "windows") {
229        "Download the pdfium dynamic library from https://github.com/bblanchon/pdfium-binaries \
230         and place `pdfium.dll` on PATH or next to your executable."
231            .to_string()
232    } else {
233        "Download the pdfium dynamic library from https://github.com/bblanchon/pdfium-binaries."
234            .to_string()
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn raster_config_defaults_to_first_page_150_dpi() {
244        let cfg = PdfRasterConfig::new();
245        assert_eq!(cfg.page_index, 0);
246        assert_eq!(cfg.width_px, 1240);
247        assert_eq!(cfg.height_px, 1754);
248    }
249
250    #[test]
251    fn install_hint_is_non_empty() {
252        assert!(!pdfium_install_hint().is_empty());
253    }
254
255    #[test]
256    fn normalize_pdf_text_removes_nuls_and_outer_blank_space() {
257        assert_eq!(normalize_pdf_text(" hello\0 \n\n"), "hello");
258    }
259}