gaze_document/extract/
pdf.rs1use std::io::Cursor;
18use std::path::Path;
19
20use image::ImageFormat;
21use pdfium_render::prelude::{PdfRenderConfig, Pdfium, PdfiumError};
22
23use crate::DocumentError;
24
25#[non_exhaustive]
27#[derive(Debug, Clone, Copy)]
28pub struct PdfRasterConfig {
29 pub width_px: u32,
31 pub height_px: u32,
33 pub page_index: i32,
35}
36
37impl PdfRasterConfig {
38 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#[non_exhaustive]
56#[derive(Debug, Clone)]
57pub struct RasterizedPage {
58 pub png_bytes: Vec<u8>,
60 pub page_index: i32,
62 pub page_count: i32,
64 pub width_px: u32,
66 pub height_px: u32,
68}
69
70impl RasterizedPage {
71 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#[non_exhaustive]
91#[derive(Debug, Clone)]
92pub enum PdfPagePayload {
93 VectorText {
95 text: String,
97 page_index: i32,
99 page_count: i32,
101 },
102 Raster(RasterizedPage),
104}
105
106impl PdfPagePayload {
107 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 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
124pub 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}