Congratsbot / check-author (push) Skipped
Congratsbot / congrats (push) Waiting for needed jobs
deploy_nightly_docs / deploy_docs (push) Skipped
run_tests / orchestrate (push) Skipped
run_tests / check_style (push) Skipped
run_tests / clippy_windows (push) Skipped
run_tests / clippy_linux (push) Skipped
run_tests / clippy_mac (push) Skipped
run_tests / clippy_mac_x86_64 (push) Skipped
run_tests / run_tests_windows (push) Skipped
run_tests / run_tests_linux (push) Skipped
run_tests / run_tests_mac (push) Skipped
run_tests / miri_scheduler (push) Skipped
run_tests / doctests (push) Skipped
run_tests / check_workspace_binaries (push) Skipped
run_tests / build_visual_tests_binary (push) Skipped
run_tests / check_wasm (push) Skipped
run_tests / check_dependencies (push) Skipped
run_tests / check_docs (push) Skipped
run_tests / check_licenses (push) Skipped
run_tests / check_scripts (push) Skipped
run_tests / check_remote_server_linux_x86_64 (push) Skipped
run_tests / check_remote_server_mac_aarch64 (push) Skipped
run_tests / check_remote_server_windows_x86_64 (push) Skipped
run_tests / check_postgres_and_protobuf_migrations (push) Skipped
run_tests / extension_tests (push) Skipped
run_tests / tests_pass (push) Skipped
The annotation and form-filling surface is being dropped, so remove it end to end rather than leave it half-maintained. pdf_viewer loses the editing APIs and everything only they used: add_ink_annotation, add_highlight_annotation, delete_annotation, apply_form_edits, the form widget and annotation extraction, the PdfFormWidget/PdfFormEdit/PdfAnnotation types, the document serialization helper, and the pdfium guards and constants those functions were the sole users of. Rendering, text, links, outline and metadata are untouched; annotation rendering stays on because it paints markup already embedded in viewed documents. image_viewer loses the tool palette, form widget overlays, inline form editors, pen canvas, option menus, the annotate toolbar button, the Undo Edit and Delete Annotation actions and their key bindings, plus the associated state and handlers. Text selection, find, links, thumbnails, outline, properties, rotation, presentation and Save a Copy remain.
1413 lines
53 KiB
Rust
1413 lines
53 KiB
Rust
//! High-level Pdfium engine abstraction and dynamic library loader.
|
|
//!
|
|
//! Provides thread-safe document rendering via Google Pdfium C++ engine,
|
|
//! with automated fallback and library path discovery.
|
|
|
|
use anyhow::{Context as _, Result};
|
|
use parking_lot::Mutex;
|
|
use pdfium_render::prelude::*;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use crate::cache::RenderedPage;
|
|
use crate::document::{PageDimensions, PdfDocument};
|
|
use crate::rasterizer::{
|
|
LuminosityToneMapper, PageRasterizer, RasterizerOptions, combine_rotation_degrees,
|
|
normalize_rotation_degrees,
|
|
};
|
|
|
|
/// Internal wrapper granting thread-safe synchronization to Pdfium handle.
|
|
struct SerializedPdfium(Option<Pdfium>);
|
|
|
|
// SAFETY: All calls to the internal Pdfium C++ FFI handle are strictly guarded
|
|
// by a Mutual Exclusion (Mutex) lock, guaranteeing serialized single-thread
|
|
// execution across asynchronous worker threads without race conditions.
|
|
unsafe impl Send for SerializedPdfium {}
|
|
|
|
// SAFETY: All calls to the internal Pdfium C++ FFI handle are strictly guarded
|
|
// by a Mutual Exclusion (Mutex) lock, guaranteeing synchronized multi-thread access.
|
|
unsafe impl Sync for SerializedPdfium {}
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
static GLOBAL_PDFIUM: OnceLock<Arc<Mutex<SerializedPdfium>>> = OnceLock::new();
|
|
|
|
/// Maximum dimension (width or height) allowed when rasterizing a PDF page,
|
|
/// preventing out-of-memory denial of service attacks from malicious documents.
|
|
///
|
|
/// 4096 keeps the worst-case frame at ~67MB RGBA: beyond roughly 4x oversampling of
|
|
/// a 1080p viewport, bigger bitmaps only burn render time and memory without visible
|
|
/// sharpness gains (tile-based rendering would be the real answer at extreme zoom).
|
|
pub const MAX_PAGE_DIMENSION: f32 = 4096.0;
|
|
|
|
/// Computes target pixel dimensions preserving the page aspect ratio,
|
|
/// scaling down uniformly if either dimension exceeds `MAX_PAGE_DIMENSION`.
|
|
pub(crate) fn compute_target_dimensions(
|
|
page_width_pts: f32,
|
|
page_height_pts: f32,
|
|
dpi: f32,
|
|
zoom: f32,
|
|
) -> (i32, i32) {
|
|
let scale_factor = (dpi / 72.0) * zoom;
|
|
let raw_w = (page_width_pts * scale_factor).max(1.0);
|
|
let raw_h = (page_height_pts * scale_factor).max(1.0);
|
|
|
|
let clamp_scale = if raw_w > MAX_PAGE_DIMENSION || raw_h > MAX_PAGE_DIMENSION {
|
|
(MAX_PAGE_DIMENSION / raw_w).min(MAX_PAGE_DIMENSION / raw_h)
|
|
} else {
|
|
1.0
|
|
};
|
|
|
|
let target_w = (raw_w * clamp_scale).round().clamp(1.0, MAX_PAGE_DIMENSION) as i32;
|
|
let target_h = (raw_h * clamp_scale).round().clamp(1.0, MAX_PAGE_DIMENSION) as i32;
|
|
|
|
(target_w, target_h)
|
|
}
|
|
|
|
/// Converts a pdfium page rotation into its clockwise degree value.
|
|
fn rotation_as_degrees(rotation: PdfPageRenderRotation) -> u16 {
|
|
match rotation {
|
|
PdfPageRenderRotation::None => 0,
|
|
PdfPageRenderRotation::Degrees90 => 90,
|
|
PdfPageRenderRotation::Degrees180 => 180,
|
|
PdfPageRenderRotation::Degrees270 => 270,
|
|
}
|
|
}
|
|
|
|
/// Converts a clockwise degree value into the pdfium page rotation to apply.
|
|
fn rotation_from_degrees(degrees: u16) -> PdfPageRenderRotation {
|
|
match normalize_rotation_degrees(degrees) {
|
|
90 => PdfPageRenderRotation::Degrees90,
|
|
180 => PdfPageRenderRotation::Degrees180,
|
|
270 => PdfPageRenderRotation::Degrees270,
|
|
_ => PdfPageRenderRotation::None,
|
|
}
|
|
}
|
|
|
|
/// Applies the viewer's clockwise rotation on top of the page's own intrinsic `/Rotate`
|
|
/// entry, returning the rotation the page carried beforehand.
|
|
///
|
|
/// Pdfium applies the page rotation both when reporting page dimensions and when
|
|
/// rasterizing, so setting the combined rotation before rendering rotates the pixels and
|
|
/// swaps the reported frame dimensions for 90/270 degree turns. Callers should pass the
|
|
/// returned value to [`restore_page_rotation`] once rendering is complete so a reused
|
|
/// document is left the way it was found.
|
|
fn apply_page_rotation(page: &mut PdfPage<'_>, request_degrees: u16) -> u16 {
|
|
let intrinsic_degrees = page.rotation().map(rotation_as_degrees).unwrap_or(0);
|
|
let effective_degrees = combine_rotation_degrees(intrinsic_degrees, request_degrees);
|
|
|
|
if effective_degrees != intrinsic_degrees {
|
|
page.set_rotation(rotation_from_degrees(effective_degrees));
|
|
}
|
|
|
|
intrinsic_degrees
|
|
}
|
|
|
|
/// Restores the intrinsic page rotation returned by [`apply_page_rotation`].
|
|
fn restore_page_rotation(page: &mut PdfPage<'_>, intrinsic_degrees: u16) {
|
|
page.set_rotation(rotation_from_degrees(intrinsic_degrees));
|
|
}
|
|
|
|
/// Thread-safe wrapper around a Pdfium instance.
|
|
#[derive(Clone)]
|
|
pub struct PdfiumEngine {
|
|
inner: Arc<Mutex<SerializedPdfium>>,
|
|
}
|
|
|
|
impl Default for PdfiumEngine {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl PdfiumEngine {
|
|
/// Creates a new engine handle, sharing the process-level singleton instance.
|
|
pub fn new() -> Self {
|
|
let inner = GLOBAL_PDFIUM
|
|
.get_or_init(|| Arc::new(Mutex::new(SerializedPdfium(Self::init_pdfium()))))
|
|
.clone();
|
|
|
|
Self { inner }
|
|
}
|
|
|
|
/// Attempts to bind to dynamic `libpdfium.so`, `libpdfium.dylib`, or `pdfium.dll`.
|
|
fn init_pdfium() -> Option<Pdfium> {
|
|
// 1. Try explicit environment variable PDFIUM_LIB_PATH
|
|
if let Ok(env_path) = std::env::var("PDFIUM_LIB_PATH") {
|
|
let path = Path::new(&env_path);
|
|
if path.exists() {
|
|
if let Ok(bindings) = Pdfium::bind_to_library(path) {
|
|
log::info!(
|
|
"Successfully bound to Pdfium from environment at {}",
|
|
env_path
|
|
);
|
|
return Some(Pdfium::new(bindings));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Try standard system library search path
|
|
if let Ok(bindings) = Pdfium::bind_to_system_library() {
|
|
log::info!("Successfully initialized Pdfium from system dynamic library");
|
|
return Some(Pdfium::new(bindings));
|
|
}
|
|
|
|
// 3. Try common Linux/macOS shared object locations
|
|
let mut common_paths: Vec<PathBuf> = vec![
|
|
PathBuf::from("lib/libpdfium.so"),
|
|
PathBuf::from("./lib/libpdfium.so"),
|
|
PathBuf::from("../lib/libpdfium.so"),
|
|
PathBuf::from("../../lib/libpdfium.so"),
|
|
];
|
|
|
|
if let Ok(home_dir) = std::env::var("HOME") {
|
|
if !home_dir.trim().is_empty() {
|
|
common_paths.push(PathBuf::from(format!("{home_dir}/.local/lib/libpdfium.so")));
|
|
common_paths.push(PathBuf::from(format!(
|
|
"{home_dir}/.local/lib/libpdfium.dylib"
|
|
)));
|
|
}
|
|
}
|
|
|
|
common_paths.extend([
|
|
PathBuf::from("/usr/lib/libpdfium.so"),
|
|
PathBuf::from("/usr/lib64/libpdfium.so"),
|
|
PathBuf::from("/usr/local/lib/libpdfium.so"),
|
|
PathBuf::from("/opt/homebrew/lib/libpdfium.dylib"),
|
|
PathBuf::from("/usr/local/lib/libpdfium.dylib"),
|
|
]);
|
|
|
|
// Resolve relative to the running executable: bundled layouts place the engine
|
|
// in `<app>/lib/` next to `libexec/`, and a desktop launch gives no guarantee
|
|
// about the working directory.
|
|
if let Ok(executable_path) = std::env::current_exe() {
|
|
if let Some(executable_dir) = executable_path.parent() {
|
|
common_paths.push(executable_dir.join("../Frameworks/libpdfium.dylib"));
|
|
for name in ["libpdfium.so", "libpdfium.dylib", "pdfium.dll"] {
|
|
common_paths.push(executable_dir.join("../lib").join(name));
|
|
common_paths.push(executable_dir.join(name));
|
|
}
|
|
}
|
|
}
|
|
|
|
for path in &common_paths {
|
|
if path.exists() {
|
|
if let Ok(bindings) = Pdfium::bind_to_library(path) {
|
|
log::info!("Successfully bound to Pdfium at {}", path.display());
|
|
return Some(Pdfium::new(bindings));
|
|
}
|
|
}
|
|
}
|
|
|
|
log::warn!(
|
|
"Dynamic libpdfium not found in standard system paths; engine will use synthetic fallback"
|
|
);
|
|
None
|
|
}
|
|
|
|
/// True if native Pdfium C++ bindings are actively loaded.
|
|
pub fn is_native_available(&self) -> bool {
|
|
self.inner.lock().0.is_some()
|
|
}
|
|
|
|
/// Loads a PDF from disk path and returns document metadata.
|
|
pub fn load_document_from_path(&self, path: &Path) -> Result<PdfDocument> {
|
|
let bytes = std::fs::read(path)
|
|
.with_context(|| format!("Failed to read PDF file at {:?}", path))?;
|
|
self.load_document_from_bytes(&bytes, Some(path.to_path_buf()))
|
|
}
|
|
|
|
/// Loads a PDF from in-memory byte slice.
|
|
pub fn load_document_from_bytes(
|
|
&self,
|
|
bytes: &[u8],
|
|
path: Option<PathBuf>,
|
|
) -> Result<PdfDocument> {
|
|
let guard = self.inner.lock();
|
|
if let Some(ref pdfium) = guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Pdfium failed to parse PDF document byte slice")?;
|
|
|
|
let mut pages = Vec::new();
|
|
for page in doc.pages().iter() {
|
|
let width = page.width().value;
|
|
let height = page.height().value;
|
|
pages.push(PageDimensions::new(width, height));
|
|
}
|
|
|
|
let title = doc
|
|
.metadata()
|
|
.get(PdfDocumentMetadataTagType::Title)
|
|
.map(|s| s.value().to_string());
|
|
|
|
Ok(PdfDocument::new(path, pages, title))
|
|
} else {
|
|
// Fallback scanner if native library is absent
|
|
PdfDocument::from_bytes(bytes, path)
|
|
}
|
|
}
|
|
|
|
/// Rasterizes a specific page from file bytes into an RGBA frame buffer.
|
|
pub fn render_page_from_bytes(
|
|
&self,
|
|
bytes: &[u8],
|
|
page_index: usize,
|
|
options: RasterizerOptions,
|
|
) -> Result<RenderedPage> {
|
|
let guard = self.inner.lock();
|
|
if let Some(ref pdfium) = guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF in rasterizer")?;
|
|
|
|
let mut page = doc
|
|
.pages()
|
|
.get(page_index as u16)
|
|
.context("Requested page index out of bounds")?;
|
|
|
|
// Rotate the page itself before measuring it: pdfium reports and renders the
|
|
// rotated frame, so the target pixel size below is already swapped for 90/270.
|
|
let intrinsic_rotation = apply_page_rotation(&mut page, options.rotation_degrees);
|
|
|
|
let (target_width, target_height) = compute_target_dimensions(
|
|
page.width().value,
|
|
page.height().value,
|
|
options.target_dpi,
|
|
options.zoom_factor,
|
|
);
|
|
|
|
let render_config = PdfRenderConfig::new()
|
|
.set_target_width(target_width)
|
|
.set_target_height(target_height)
|
|
.render_form_data(true)
|
|
.render_annotations(true);
|
|
|
|
let mut rgba_buffer = {
|
|
let bitmap = page
|
|
.render_with_config(&render_config)
|
|
.context("Pdfium page render call failed")?;
|
|
|
|
bitmap.as_image().to_rgba8().into_raw()
|
|
};
|
|
|
|
restore_page_rotation(&mut page, intrinsic_rotation);
|
|
|
|
if options.dark_mode {
|
|
LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold);
|
|
}
|
|
|
|
Ok(RenderedPage::new(
|
|
page_index,
|
|
target_width as u32,
|
|
target_height as u32,
|
|
options.zoom_factor,
|
|
options.dark_mode,
|
|
rgba_buffer,
|
|
))
|
|
} else {
|
|
// Fallback mock rendering when native engine binary is not linked
|
|
let dim = PageDimensions::new(612.0, 792.0);
|
|
crate::rasterizer::PageRasterizer::render_mock_page(page_index, dim, options)
|
|
}
|
|
}
|
|
/// Rasterizes all pages from file bytes into a continuous vertical layout,
|
|
/// stitching them together with a configurable page spacing and margin.
|
|
pub fn render_document_from_bytes(
|
|
&self,
|
|
bytes: &[u8],
|
|
options: RasterizerOptions,
|
|
page_gap: u32,
|
|
) -> Result<RenderedPage> {
|
|
let guard = self.inner.lock();
|
|
if let Some(ref pdfium) = guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF in document rasterizer")?;
|
|
|
|
let total_pages = doc.pages().len() as usize;
|
|
if total_pages == 0 {
|
|
anyhow::bail!("PDF document contains no pages");
|
|
}
|
|
|
|
let mut rendered_pages = Vec::with_capacity(total_pages);
|
|
for (idx, mut page) in doc.pages().iter().enumerate() {
|
|
let intrinsic_rotation = apply_page_rotation(&mut page, options.rotation_degrees);
|
|
|
|
let (target_width, target_height) = compute_target_dimensions(
|
|
page.width().value,
|
|
page.height().value,
|
|
options.target_dpi,
|
|
options.zoom_factor,
|
|
);
|
|
|
|
let render_config = PdfRenderConfig::new()
|
|
.set_target_width(target_width)
|
|
.set_target_height(target_height)
|
|
.render_form_data(true)
|
|
.render_annotations(true);
|
|
|
|
let mut rgba_buffer = {
|
|
let bitmap = page
|
|
.render_with_config(&render_config)
|
|
.with_context(|| format!("Pdfium failed to render page {}", idx))?;
|
|
|
|
bitmap.as_image().to_rgba8().into_raw()
|
|
};
|
|
|
|
restore_page_rotation(&mut page, intrinsic_rotation);
|
|
|
|
if options.dark_mode {
|
|
LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold);
|
|
}
|
|
|
|
rendered_pages.push((target_width as u32, target_height as u32, rgba_buffer));
|
|
}
|
|
|
|
let max_width = rendered_pages.iter().map(|(w, _, _)| *w).max().unwrap_or(1);
|
|
let total_height: u32 = rendered_pages.iter().map(|(_, h, _)| *h).sum::<u32>()
|
|
+ ((total_pages as u32 - 1) * page_gap);
|
|
|
|
let stride = (max_width as usize) * 4;
|
|
let total_bytes = stride * (total_height as usize);
|
|
|
|
let bg_color = if options.dark_mode {
|
|
[24u8, 24, 37, 255]
|
|
} else {
|
|
[230u8, 233, 239, 255]
|
|
};
|
|
|
|
let mut composite = vec![0u8; total_bytes];
|
|
for chunk in composite.as_chunks_mut::<4>().0 {
|
|
*chunk = bg_color;
|
|
}
|
|
|
|
let mut y_offset = 0u32;
|
|
for (w, h, page_buf) in rendered_pages {
|
|
let x_offset = ((max_width - w) / 2) as usize;
|
|
let page_stride = (w as usize) * 4;
|
|
|
|
for y in 0..h {
|
|
let src_start = (y as usize) * page_stride;
|
|
let src_end = src_start + page_stride;
|
|
let src_slice = &page_buf[src_start..src_end];
|
|
|
|
let dst_y = (y_offset + y) as usize;
|
|
let dst_start = (dst_y * (max_width as usize) + x_offset) * 4;
|
|
let dst_end = dst_start + page_stride;
|
|
|
|
composite[dst_start..dst_end].copy_from_slice(src_slice);
|
|
}
|
|
|
|
y_offset += h + page_gap;
|
|
}
|
|
|
|
Ok(RenderedPage::new(
|
|
0,
|
|
max_width,
|
|
total_height,
|
|
options.zoom_factor,
|
|
options.dark_mode,
|
|
composite,
|
|
))
|
|
} else {
|
|
let doc = PdfDocument::from_bytes(bytes, None)?;
|
|
let total_pages = doc.total_pages();
|
|
if total_pages == 0 {
|
|
anyhow::bail!("PDF document contains no pages");
|
|
}
|
|
if total_pages == 1 {
|
|
let dim = doc
|
|
.page_size(0)
|
|
.unwrap_or(PageDimensions::new(612.0, 792.0));
|
|
return PageRasterizer::render_mock_page(0, dim, options);
|
|
}
|
|
|
|
let mut rendered_pages = Vec::with_capacity(total_pages);
|
|
for i in 0..total_pages {
|
|
let dim = doc
|
|
.page_size(i)
|
|
.unwrap_or(PageDimensions::new(612.0, 792.0));
|
|
let page = PageRasterizer::render_mock_page(i, dim, options)?;
|
|
rendered_pages.push((page.width, page.height, page.rgba_buffer.as_ref().clone()));
|
|
}
|
|
|
|
let max_width = rendered_pages.iter().map(|(w, _, _)| *w).max().unwrap_or(1);
|
|
let total_height: u32 = rendered_pages.iter().map(|(_, h, _)| *h).sum::<u32>()
|
|
+ ((total_pages as u32 - 1) * page_gap);
|
|
|
|
let stride = (max_width as usize) * 4;
|
|
let total_bytes = stride * (total_height as usize);
|
|
|
|
let bg_color = if options.dark_mode {
|
|
[24u8, 24, 37, 255]
|
|
} else {
|
|
[230u8, 233, 239, 255]
|
|
};
|
|
|
|
let mut composite = vec![0u8; total_bytes];
|
|
for chunk in composite.as_chunks_mut::<4>().0 {
|
|
*chunk = bg_color;
|
|
}
|
|
|
|
let mut y_offset = 0u32;
|
|
for (w, h, page_buf) in rendered_pages {
|
|
let x_offset = ((max_width - w) / 2) as usize;
|
|
let page_stride = (w as usize) * 4;
|
|
|
|
for y in 0..h {
|
|
let src_start = (y as usize) * page_stride;
|
|
let src_end = src_start + page_stride;
|
|
let src_slice = &page_buf[src_start..src_end];
|
|
|
|
let dst_y = (y_offset + y) as usize;
|
|
let dst_start = (dst_y * (max_width as usize) + x_offset) * 4;
|
|
let dst_end = dst_start + page_stride;
|
|
|
|
composite[dst_start..dst_end].copy_from_slice(src_slice);
|
|
}
|
|
|
|
y_offset += h + page_gap;
|
|
}
|
|
|
|
Ok(RenderedPage::new(
|
|
0,
|
|
max_width,
|
|
total_height,
|
|
options.zoom_factor,
|
|
options.dark_mode,
|
|
composite,
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Extracts text from a specific page (0-indexed) or the entire document if page_index is None.
|
|
pub fn extract_text_from_bytes(
|
|
&self,
|
|
bytes: &[u8],
|
|
page_index: Option<usize>,
|
|
) -> Result<String> {
|
|
let guard = self.inner.lock();
|
|
if let Some(ref pdfium) = guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF for text extraction")?;
|
|
|
|
if let Some(idx) = page_index {
|
|
let page = doc
|
|
.pages()
|
|
.get(idx as u16)
|
|
.context("Requested page index out of bounds")?;
|
|
let text_page = page.text().context("Failed to load page text")?;
|
|
Ok(text_page.all())
|
|
} else {
|
|
let mut full_text = String::new();
|
|
for (idx, page) in doc.pages().iter().enumerate() {
|
|
if let Ok(text_page) = page.text() {
|
|
let page_text = text_page.all();
|
|
if !page_text.is_empty() {
|
|
if idx > 0 {
|
|
full_text.push_str("\n\n--- Page ");
|
|
full_text.push_str(&(idx + 1).to_string());
|
|
full_text.push_str(" ---\n\n");
|
|
}
|
|
full_text.push_str(&page_text);
|
|
}
|
|
}
|
|
}
|
|
Ok(full_text)
|
|
}
|
|
} else {
|
|
Ok(String::new())
|
|
}
|
|
}
|
|
|
|
/// Extracts every character on a zero-indexed page, in pdfium text index order, each with
|
|
/// its box in normalized page coordinates (see [`normalize_coordinates`]).
|
|
///
|
|
/// The result is index-aligned with pdfium's per-page text indices, so a text index range
|
|
/// `start..=end` maps directly onto a slice range of the returned vector: whitespace is
|
|
/// included, characters whose Unicode value is unresolvable are emitted as
|
|
/// [`UNKNOWN_TEXT_CHAR`], and characters without usable geometry are emitted as zero-sized
|
|
/// boxes rather than skipped.
|
|
pub fn extract_char_boxes_from_bytes(
|
|
&self,
|
|
bytes: &[u8],
|
|
page_index: usize,
|
|
) -> Result<Vec<PdfCharBox>> {
|
|
let guard = self.inner.lock();
|
|
if let Some(pdfium) = &guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF for char box extraction")?;
|
|
|
|
let pages = doc.pages();
|
|
let page_count = pages.len() as usize;
|
|
if page_index >= page_count {
|
|
anyhow::bail!(
|
|
"Requested page index {page_index} out of bounds for document with {page_count} pages"
|
|
);
|
|
}
|
|
|
|
let page = pages
|
|
.get(page_index as u16)
|
|
.context("Requested page index out of bounds")?;
|
|
|
|
let page_w = page.width().value;
|
|
let page_h = page.height().value;
|
|
|
|
let text_page = page
|
|
.text()
|
|
.context("Failed to load page text for char box extraction")?;
|
|
|
|
// `PdfPageText::chars()` enumerates every index in `0..FPDFText_CountChars(page)`, which
|
|
// is pdfium's own text index order. Iterating segments instead (`seg.chars()`) routes
|
|
// through `chars_inside_rect`, which filters characters by the segment rectangle and so
|
|
// can drop indices: it is not a complete, index-ordered view of the page.
|
|
let chars = text_page.chars();
|
|
let mut boxes = Vec::with_capacity(chars.len());
|
|
|
|
for ch in chars.iter() {
|
|
let character = ch.unicode_char().unwrap_or(UNKNOWN_TEXT_CHAR);
|
|
|
|
let (x, y, width, height) = match ch.loose_bounds().or_else(|_| ch.tight_bounds()) {
|
|
Ok(bounds) => {
|
|
let left = bounds.left().value.min(bounds.right().value);
|
|
let right = bounds.left().value.max(bounds.right().value);
|
|
let bottom = bounds.bottom().value.min(bounds.top().value);
|
|
let top = bounds.bottom().value.max(bounds.top().value);
|
|
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h)
|
|
}
|
|
// Preserve the slot so index alignment survives geometry-less characters.
|
|
Err(_) => (0.0, 0.0, 0.0, 0.0),
|
|
};
|
|
|
|
boxes.push(PdfCharBox {
|
|
character,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
});
|
|
}
|
|
|
|
Ok(boxes)
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct PdfLinkAnnotation {
|
|
/// Target URL for external links; empty for internal (in-document) links.
|
|
pub url: String,
|
|
/// Zero-based target page for internal links; `None` for external links.
|
|
pub dest_page: Option<usize>,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub width: f32,
|
|
pub height: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct PdfTextSegment {
|
|
pub text: String,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub width: f32,
|
|
pub height: f32,
|
|
}
|
|
|
|
/// Placeholder substituted for a page text index whose Unicode scalar value pdfium cannot
|
|
/// resolve. The entry is still emitted so that returned boxes stay index-aligned with
|
|
/// pdfium's page text indices, which callers use as selection range coordinates.
|
|
const UNKNOWN_TEXT_CHAR: char = '\u{FFFD}';
|
|
|
|
/// A single character on a PDF page together with its box in normalized page coordinates.
|
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct PdfCharBox {
|
|
pub character: char,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub width: f32,
|
|
pub height: f32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct PdfPageDetails {
|
|
pub page_index: usize,
|
|
pub width_pt: f32,
|
|
pub height_pt: f32,
|
|
pub text: String,
|
|
pub links: Vec<PdfLinkAnnotation>,
|
|
pub text_segments: Vec<PdfTextSegment>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct PdfDocumentDetails {
|
|
pub total_pages: usize,
|
|
pub full_text: String,
|
|
pub pages: Vec<PdfPageDetails>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PdfPageRenderResult {
|
|
pub page_index: usize,
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub rgba_buffer: Vec<u8>,
|
|
pub text: String,
|
|
pub text_segments: Vec<PdfTextSegment>,
|
|
pub links: Vec<PdfLinkAnnotation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct PdfDocumentRenderResult {
|
|
pub total_pages: usize,
|
|
pub full_text: String,
|
|
pub pages: Vec<PdfPageRenderResult>,
|
|
}
|
|
|
|
/// A single entry in a PDF document's outline (table of contents).
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct PdfOutlineItem {
|
|
/// Title of the bookmark as stored in the document.
|
|
pub title: String,
|
|
/// Zero-based index of the destination page, or `None` when the destination
|
|
/// cannot be resolved.
|
|
pub dest_page: Option<usize>,
|
|
/// Nested child entries, in document order.
|
|
pub children: Vec<PdfOutlineItem>,
|
|
}
|
|
|
|
/// The document properties surfaced by the document properties panel.
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct PdfMetadata {
|
|
pub title: Option<String>,
|
|
pub author: Option<String>,
|
|
pub subject: Option<String>,
|
|
pub keywords: Option<String>,
|
|
pub creator: Option<String>,
|
|
pub producer: Option<String>,
|
|
pub creation_date: Option<String>,
|
|
pub modification_date: Option<String>,
|
|
/// Total number of pages in the document.
|
|
pub page_count: usize,
|
|
}
|
|
|
|
/// Maximum outline nesting depth that will be traversed; guards against cyclic
|
|
/// or pathologically deep bookmark trees in malformed documents.
|
|
const MAX_OUTLINE_DEPTH: usize = 64;
|
|
|
|
/// Resolves the zero-based destination page index of a bookmark, falling back
|
|
/// to the bookmark's local navigation action when it carries no direct
|
|
/// destination of its own.
|
|
fn bookmark_destination_page(bookmark: &pdfium_render::prelude::PdfBookmark<'_>) -> Option<usize> {
|
|
if let Some(destination) = bookmark.destination() {
|
|
if let Ok(page_index) = destination.page_index() {
|
|
return Some(page_index as usize);
|
|
}
|
|
}
|
|
|
|
if let Some(action) = bookmark.action() {
|
|
if let Some(local_action) = action.as_local_destination_action() {
|
|
if let Ok(destination) = local_action.destination() {
|
|
if let Ok(page_index) = destination.page_index() {
|
|
return Some(page_index as usize);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Recursively converts a pdfium bookmark node into an owned [`PdfOutlineItem`] tree.
|
|
fn collect_outline_items(
|
|
bookmark: &pdfium_render::prelude::PdfBookmark<'_>,
|
|
depth: usize,
|
|
) -> PdfOutlineItem {
|
|
let children = if depth < MAX_OUTLINE_DEPTH {
|
|
bookmark
|
|
.iter_direct_children()
|
|
.map(|child| collect_outline_items(&child, depth + 1))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
PdfOutlineItem {
|
|
title: bookmark.title().unwrap_or_default(),
|
|
dest_page: bookmark_destination_page(bookmark),
|
|
children,
|
|
}
|
|
}
|
|
|
|
/// Returns a metadata tag value with surrounding whitespace trimmed, mapping
|
|
/// empty or absent tags to `None`.
|
|
fn metadata_tag_value(
|
|
metadata: &pdfium_render::prelude::PdfMetadata<'_>,
|
|
tag: PdfDocumentMetadataTagType,
|
|
) -> Option<String> {
|
|
if let Some(metadata_tag) = metadata.get(tag) {
|
|
let value = metadata_tag.value().trim();
|
|
if !value.is_empty() {
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[inline]
|
|
fn normalize_coordinates(
|
|
left: f32,
|
|
right: f32,
|
|
bottom: f32,
|
|
top: f32,
|
|
page_w: f32,
|
|
page_h: f32,
|
|
) -> (f32, f32, f32, f32) {
|
|
if page_w <= 0.0 || page_h <= 0.0 {
|
|
return (0.0, 0.0, 0.0, 0.0);
|
|
}
|
|
let norm_x = (left / page_w).clamp(0.0, 1.0);
|
|
let norm_y = ((page_h - top) / page_h).clamp(0.0, 1.0);
|
|
let norm_w = ((right - left) / page_w).clamp(0.0, 1.0);
|
|
let norm_h = ((top - bottom) / page_h).clamp(0.0, 1.0);
|
|
(norm_x, norm_y, norm_w, norm_h)
|
|
}
|
|
|
|
fn extract_page_text_and_links(
|
|
page: &pdfium_render::prelude::PdfPage,
|
|
idx: usize,
|
|
full_text: &mut String,
|
|
) -> (String, Vec<PdfTextSegment>, Vec<PdfLinkAnnotation>) {
|
|
let page_w = page.width().value;
|
|
let page_h = page.height().value;
|
|
let mut page_text = String::new();
|
|
let mut segments = Vec::new();
|
|
let mut links = Vec::new();
|
|
|
|
// 1. Text and Text Segments
|
|
if let Ok(text_page) = page.text() {
|
|
page_text = text_page.all();
|
|
if !page_text.is_empty() {
|
|
if idx > 0 {
|
|
full_text.push_str("\n\n--- Page ");
|
|
full_text.push_str(&(idx + 1).to_string());
|
|
full_text.push_str(" ---\n\n");
|
|
}
|
|
full_text.push_str(&page_text);
|
|
}
|
|
|
|
let seg_coll = text_page.segments();
|
|
for seg in seg_coll.iter() {
|
|
let seg_str = seg.text();
|
|
let seg_trimmed = seg_str.trim();
|
|
if seg_trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if !seg_trimmed.contains(' ') {
|
|
let bounds = seg.bounds();
|
|
let left = bounds.left().value.min(bounds.right().value);
|
|
let right = bounds.left().value.max(bounds.right().value);
|
|
let bottom = bounds.bottom().value.min(bounds.top().value);
|
|
let top = bounds.bottom().value.max(bounds.top().value);
|
|
|
|
let (norm_x, norm_y, norm_w, norm_h) =
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h);
|
|
|
|
if norm_w > 0.0 && norm_h > 0.0 {
|
|
segments.push(PdfTextSegment {
|
|
text: seg_trimmed.to_string(),
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Ok(chars) = seg.chars() {
|
|
let mut word_text = String::new();
|
|
let mut min_left = f32::MAX;
|
|
let mut max_right = f32::MIN;
|
|
let mut min_bottom = f32::MAX;
|
|
let mut max_top = f32::MIN;
|
|
let mut has_word_char = false;
|
|
|
|
for ch in chars.iter() {
|
|
let u_char = ch.unicode_char();
|
|
let is_ws = u_char.map(|c| c.is_whitespace()).unwrap_or(false);
|
|
|
|
if is_ws {
|
|
if has_word_char && !word_text.is_empty() {
|
|
let (norm_x, norm_y, norm_w, norm_h) = normalize_coordinates(
|
|
min_left, max_right, min_bottom, max_top, page_w, page_h,
|
|
);
|
|
|
|
if norm_w > 0.0 && norm_h > 0.0 {
|
|
segments.push(PdfTextSegment {
|
|
text: std::mem::take(&mut word_text),
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
word_text.clear();
|
|
min_left = f32::MAX;
|
|
max_right = f32::MIN;
|
|
min_bottom = f32::MAX;
|
|
max_top = f32::MIN;
|
|
has_word_char = false;
|
|
}
|
|
} else if let Some(c) = u_char {
|
|
word_text.push(c);
|
|
if let Ok(bounds) = ch.loose_bounds().or_else(|_| ch.tight_bounds()) {
|
|
let l = bounds.left().value.min(bounds.right().value);
|
|
let r = bounds.left().value.max(bounds.right().value);
|
|
let b = bounds.bottom().value.min(bounds.top().value);
|
|
let t = bounds.bottom().value.max(bounds.top().value);
|
|
|
|
min_left = min_left.min(l);
|
|
max_right = max_right.max(r);
|
|
min_bottom = min_bottom.min(b);
|
|
max_top = max_top.max(t);
|
|
has_word_char = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if has_word_char && !word_text.is_empty() {
|
|
let (norm_x, norm_y, norm_w, norm_h) = normalize_coordinates(
|
|
min_left, max_right, min_bottom, max_top, page_w, page_h,
|
|
);
|
|
|
|
if norm_w > 0.0 && norm_h > 0.0 {
|
|
segments.push(PdfTextSegment {
|
|
text: word_text,
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
let bounds = seg.bounds();
|
|
let left = bounds.left().value.min(bounds.right().value);
|
|
let right = bounds.left().value.max(bounds.right().value);
|
|
let bottom = bounds.bottom().value.min(bounds.top().value);
|
|
let top = bounds.bottom().value.max(bounds.top().value);
|
|
|
|
let (norm_x, norm_y, norm_w, norm_h) =
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h);
|
|
|
|
segments.push(PdfTextSegment {
|
|
text: seg_trimmed.to_string(),
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Links from page.links()
|
|
for link in page.links().iter() {
|
|
if let Some(PdfAction::Uri(uri_action)) = link.action() {
|
|
if let Ok(url) = uri_action.uri() {
|
|
let url_clean = url.trim().to_string();
|
|
if !url_clean.is_empty() {
|
|
if let Ok(rect) = link.rect() {
|
|
let left = rect.left().value.min(rect.right().value);
|
|
let right = rect.left().value.max(rect.right().value);
|
|
let bottom = rect.bottom().value.min(rect.top().value);
|
|
let top = rect.bottom().value.max(rect.top().value);
|
|
|
|
let (norm_x, norm_y, norm_w, norm_h) =
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h);
|
|
|
|
links.push(PdfLinkAnnotation {
|
|
url: url_clean,
|
|
dest_page: None,
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(PdfAction::LocalDestination(dest_action)) = link.action() {
|
|
if let Ok(dest_page) = dest_action
|
|
.destination()
|
|
.and_then(|destination| destination.page_index())
|
|
{
|
|
if let Ok(rect) = link.rect() {
|
|
let left = rect.left().value.min(rect.right().value);
|
|
let right = rect.left().value.max(rect.right().value);
|
|
let bottom = rect.bottom().value.min(rect.top().value);
|
|
let top = rect.bottom().value.max(rect.top().value);
|
|
|
|
let (norm_x, norm_y, norm_w, norm_h) =
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h);
|
|
|
|
links.push(PdfLinkAnnotation {
|
|
url: String::new(),
|
|
dest_page: Some(dest_page as usize),
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Links from page.annotations()
|
|
for annot in page.annotations().iter() {
|
|
if let Some(link_annot) = annot.as_link_annotation() {
|
|
if let Ok(link) = link_annot.link() {
|
|
if let Some(PdfAction::Uri(uri_action)) = link.action() {
|
|
if let Ok(url) = uri_action.uri() {
|
|
let url_clean = url.trim().to_string();
|
|
if !url_clean.is_empty() {
|
|
if let Ok(rect) = link.rect() {
|
|
let left = rect.left().value.min(rect.right().value);
|
|
let right = rect.left().value.max(rect.right().value);
|
|
let bottom = rect.bottom().value.min(rect.top().value);
|
|
let top = rect.bottom().value.max(rect.top().value);
|
|
|
|
let (norm_x, norm_y, norm_w, norm_h) =
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h);
|
|
|
|
let exists = links.iter().any(|l| {
|
|
l.url == url_clean
|
|
&& (l.x - norm_x).abs() < 0.01
|
|
&& (l.y - norm_y).abs() < 0.01
|
|
});
|
|
if !exists {
|
|
links.push(PdfLinkAnnotation {
|
|
url: url_clean,
|
|
dest_page: None,
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(link_annot) = annot.as_link_annotation() {
|
|
if let Ok(link) = link_annot.link() {
|
|
if let Some(PdfAction::LocalDestination(dest_action)) = link.action() {
|
|
if let Ok(dest_page) = dest_action
|
|
.destination()
|
|
.and_then(|destination| destination.page_index())
|
|
{
|
|
if let Ok(rect) = link.rect() {
|
|
let left = rect.left().value.min(rect.right().value);
|
|
let right = rect.left().value.max(rect.right().value);
|
|
let bottom = rect.bottom().value.min(rect.top().value);
|
|
let top = rect.bottom().value.max(rect.top().value);
|
|
|
|
let (norm_x, norm_y, norm_w, norm_h) =
|
|
normalize_coordinates(left, right, bottom, top, page_w, page_h);
|
|
|
|
let dest_page = dest_page as usize;
|
|
let exists = links.iter().any(|l| {
|
|
l.dest_page == Some(dest_page)
|
|
&& (l.x - norm_x).abs() < 0.01
|
|
&& (l.y - norm_y).abs() < 0.01
|
|
});
|
|
if !exists {
|
|
links.push(PdfLinkAnnotation {
|
|
url: String::new(),
|
|
dest_page: Some(dest_page),
|
|
x: norm_x,
|
|
y: norm_y,
|
|
width: norm_w,
|
|
height: norm_h,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Text URLs from segments (heuristic for raw text URLs)
|
|
for seg in &segments {
|
|
let text = seg.text.trim();
|
|
if text.starts_with("http://") || text.starts_with("https://") || text.starts_with("www.") {
|
|
let url = if text.starts_with("www.") {
|
|
format!("https://{}", text)
|
|
} else {
|
|
text.to_string()
|
|
};
|
|
let exists = links
|
|
.iter()
|
|
.any(|l| (l.x - seg.x).abs() < 0.02 && (l.y - seg.y).abs() < 0.02);
|
|
if !exists {
|
|
links.push(PdfLinkAnnotation {
|
|
url,
|
|
dest_page: None,
|
|
x: seg.x,
|
|
y: seg.y,
|
|
width: seg.width,
|
|
height: seg.height,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
(page_text, segments, links)
|
|
}
|
|
|
|
impl PdfiumEngine {
|
|
/// Extracts full document details including text, text segments with bounding boxes, and links.
|
|
pub fn extract_document_details(&self, bytes: &[u8]) -> Result<PdfDocumentDetails> {
|
|
let guard = self.inner.lock();
|
|
if let Some(ref pdfium) = guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF for metadata extraction")?;
|
|
|
|
let mut pages_details = Vec::new();
|
|
let mut full_text = String::new();
|
|
|
|
for (idx, page) in doc.pages().iter().enumerate() {
|
|
let page_w = page.width().value;
|
|
let page_h = page.height().value;
|
|
let (page_text, segments, links) =
|
|
extract_page_text_and_links(&page, idx, &mut full_text);
|
|
|
|
pages_details.push(PdfPageDetails {
|
|
page_index: idx,
|
|
width_pt: page_w,
|
|
height_pt: page_h,
|
|
text: page_text,
|
|
links,
|
|
text_segments: segments,
|
|
});
|
|
}
|
|
|
|
Ok(PdfDocumentDetails {
|
|
total_pages: pages_details.len(),
|
|
full_text,
|
|
pages: pages_details,
|
|
})
|
|
} else {
|
|
// Fallback mock document when the native engine binary is unavailable: one
|
|
// US-Letter page so PDFs still open and render via the mock rasterizer.
|
|
Ok(PdfDocumentDetails {
|
|
total_pages: 1,
|
|
full_text: String::new(),
|
|
pages: vec![PdfPageDetails {
|
|
page_index: 0,
|
|
width_pt: 612.0,
|
|
height_pt: 792.0,
|
|
text: String::new(),
|
|
links: Vec::new(),
|
|
text_segments: Vec::new(),
|
|
}],
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Extracts the document outline (table of contents) as a tree of bookmark
|
|
/// entries with resolved destination page indices.
|
|
pub fn extract_outline_from_bytes(&self, bytes: &[u8]) -> Result<Vec<PdfOutlineItem>> {
|
|
let guard = self.inner.lock();
|
|
if let Some(pdfium) = &guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF for outline extraction")?;
|
|
|
|
let mut items = Vec::new();
|
|
let mut current = doc.bookmarks().root();
|
|
while let Some(bookmark) = current {
|
|
items.push(collect_outline_items(&bookmark, 0));
|
|
current = bookmark.next_sibling();
|
|
}
|
|
|
|
Ok(items)
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// Extracts the document property metadata tags plus the total page count.
|
|
pub fn extract_metadata_from_bytes(&self, bytes: &[u8]) -> Result<PdfMetadata> {
|
|
let guard = self.inner.lock();
|
|
if let Some(pdfium) = &guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF for metadata extraction")?;
|
|
|
|
let metadata = doc.metadata();
|
|
|
|
Ok(PdfMetadata {
|
|
title: metadata_tag_value(metadata, PdfDocumentMetadataTagType::Title),
|
|
author: metadata_tag_value(metadata, PdfDocumentMetadataTagType::Author),
|
|
subject: metadata_tag_value(metadata, PdfDocumentMetadataTagType::Subject),
|
|
keywords: metadata_tag_value(metadata, PdfDocumentMetadataTagType::Keywords),
|
|
creator: metadata_tag_value(metadata, PdfDocumentMetadataTagType::Creator),
|
|
producer: metadata_tag_value(metadata, PdfDocumentMetadataTagType::Producer),
|
|
creation_date: metadata_tag_value(
|
|
metadata,
|
|
PdfDocumentMetadataTagType::CreationDate,
|
|
),
|
|
modification_date: metadata_tag_value(
|
|
metadata,
|
|
PdfDocumentMetadataTagType::ModificationDate,
|
|
),
|
|
page_count: doc.pages().len() as usize,
|
|
})
|
|
} else {
|
|
Ok(PdfMetadata::default())
|
|
}
|
|
}
|
|
|
|
/// Renders all pages and extracts text/links in a single fast pass over the loaded document.
|
|
pub fn render_and_extract_document_from_bytes(
|
|
&self,
|
|
bytes: &[u8],
|
|
options: RasterizerOptions,
|
|
) -> Result<PdfDocumentRenderResult> {
|
|
let guard = self.inner.lock();
|
|
if let Some(ref pdfium) = guard.0 {
|
|
let doc = pdfium
|
|
.load_pdf_from_byte_slice(bytes, None)
|
|
.context("Failed to load PDF for render and extraction")?;
|
|
|
|
let total_pages = doc.pages().len() as usize;
|
|
let mut full_text = String::new();
|
|
let mut rendered_pages = Vec::with_capacity(total_pages);
|
|
|
|
for (idx, mut page) in doc.pages().iter().enumerate() {
|
|
// Rotation only affects the rasterized frame; text and link extraction
|
|
// below runs after the original rotation has been restored.
|
|
let intrinsic_rotation = apply_page_rotation(&mut page, options.rotation_degrees);
|
|
|
|
let (target_width, target_height) = compute_target_dimensions(
|
|
page.width().value,
|
|
page.height().value,
|
|
options.target_dpi,
|
|
options.zoom_factor,
|
|
);
|
|
|
|
let render_config = PdfRenderConfig::new()
|
|
.set_target_width(target_width)
|
|
.set_target_height(target_height)
|
|
.render_form_data(true)
|
|
.render_annotations(true);
|
|
|
|
let mut rgba_buffer = {
|
|
let bitmap = page
|
|
.render_with_config(&render_config)
|
|
.with_context(|| format!("Pdfium failed to render page {}", idx))?;
|
|
|
|
bitmap.as_image().to_rgba8().into_raw()
|
|
};
|
|
|
|
restore_page_rotation(&mut page, intrinsic_rotation);
|
|
|
|
if options.dark_mode {
|
|
LuminosityToneMapper::apply(&mut rgba_buffer, options.saturation_threshold);
|
|
}
|
|
|
|
let (text, text_segments, links) =
|
|
extract_page_text_and_links(&page, idx, &mut full_text);
|
|
|
|
rendered_pages.push(PdfPageRenderResult {
|
|
page_index: idx,
|
|
width: target_width as u32,
|
|
height: target_height as u32,
|
|
rgba_buffer,
|
|
text,
|
|
text_segments,
|
|
links,
|
|
});
|
|
}
|
|
|
|
Ok(PdfDocumentRenderResult {
|
|
total_pages: rendered_pages.len(),
|
|
full_text,
|
|
pages: rendered_pages,
|
|
})
|
|
} else {
|
|
let dim = PageDimensions::new(612.0, 792.0);
|
|
let page = crate::rasterizer::PageRasterizer::render_mock_page(0, dim, options)?;
|
|
Ok(PdfDocumentRenderResult {
|
|
total_pages: 1,
|
|
full_text: String::new(),
|
|
pages: vec![PdfPageRenderResult {
|
|
page_index: 0,
|
|
width: page.width,
|
|
height: page.height,
|
|
rgba_buffer: page.rgba_buffer.as_ref().clone(),
|
|
text: String::new(),
|
|
text_segments: Vec::new(),
|
|
links: Vec::new(),
|
|
}],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_engine_initialization_safety() {
|
|
let engine = PdfiumEngine::new();
|
|
// Even without system libpdfium installed, instance must initialize safely
|
|
let is_avail = engine.is_native_available();
|
|
let _ = is_avail; // No panic
|
|
}
|
|
|
|
#[test]
|
|
fn test_fallback_rendering_when_native_absent() {
|
|
let engine = PdfiumEngine {
|
|
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
|
|
};
|
|
|
|
let dummy_pdf = b"%PDF-1.7\nSample";
|
|
let doc = engine
|
|
.load_document_from_bytes(dummy_pdf, None)
|
|
.expect("Fallback parse failed");
|
|
assert_eq!(doc.total_pages(), 1);
|
|
|
|
let opts = RasterizerOptions::default();
|
|
let page = engine
|
|
.render_page_from_bytes(dummy_pdf, 0, opts)
|
|
.expect("Fallback render failed");
|
|
assert!(page.width > 0);
|
|
assert!(page.height > 0);
|
|
assert_eq!(
|
|
page.rgba_buffer.len(),
|
|
(page.width * page.height * 4) as usize
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_document_multi_page() {
|
|
let engine = PdfiumEngine {
|
|
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
|
|
};
|
|
|
|
let dummy_pdf = b"%PDF-1.7\nSample";
|
|
let opts = RasterizerOptions::default();
|
|
let rendered = engine
|
|
.render_document_from_bytes(dummy_pdf, opts, 20)
|
|
.expect("Render document failed");
|
|
assert!(rendered.width > 0);
|
|
assert!(rendered.height > 0);
|
|
assert_eq!(
|
|
rendered.rgba_buffer.len(),
|
|
(rendered.width * rendered.height * 4) as usize
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_document_details() {
|
|
let engine = PdfiumEngine {
|
|
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
|
|
};
|
|
let dummy_pdf = b"%PDF-1.7\nSample";
|
|
let details = engine.extract_document_details(dummy_pdf);
|
|
assert!(details.is_ok());
|
|
let doc = details.unwrap();
|
|
// Without the native library the engine still yields a single mock US-Letter
|
|
// page so callers can open the document instead of failing outright.
|
|
assert_eq!(doc.total_pages, 1);
|
|
let page = doc.pages.first().expect("mock page should exist");
|
|
assert_eq!(page.width_pt, 612.0);
|
|
assert_eq!(page.height_pt, 792.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_outline_without_native_library() {
|
|
let engine = PdfiumEngine {
|
|
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
|
|
};
|
|
let dummy_pdf = b"%PDF-1.7\nSample";
|
|
let outline = engine
|
|
.extract_outline_from_bytes(dummy_pdf)
|
|
.expect("Outline extraction failed");
|
|
assert!(outline.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_metadata_without_native_library() {
|
|
let engine = PdfiumEngine {
|
|
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
|
|
};
|
|
let dummy_pdf = b"%PDF-1.7\nSample";
|
|
let metadata = engine
|
|
.extract_metadata_from_bytes(dummy_pdf)
|
|
.expect("Metadata extraction failed");
|
|
assert_eq!(metadata, PdfMetadata::default());
|
|
assert_eq!(metadata.page_count, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_and_extract_document_from_bytes() {
|
|
let engine = PdfiumEngine {
|
|
inner: Arc::new(Mutex::new(SerializedPdfium(None))),
|
|
};
|
|
let dummy_pdf = b"%PDF-1.7\nSample";
|
|
let opts = RasterizerOptions::default();
|
|
let res = engine.render_and_extract_document_from_bytes(dummy_pdf, opts);
|
|
assert!(res.is_ok());
|
|
let doc = res.unwrap();
|
|
assert_eq!(doc.total_pages, 1);
|
|
assert_eq!(doc.pages.len(), 1);
|
|
assert!(doc.pages[0].width > 0);
|
|
assert!(doc.pages[0].height > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_page_dimension_bounds() {
|
|
assert_eq!(MAX_PAGE_DIMENSION, 4096.0);
|
|
// Verify clamping logic for extreme/malicious dimensions
|
|
let huge_dim: f32 = 100_000.0;
|
|
let clamped = huge_dim.clamp(1.0, MAX_PAGE_DIMENSION);
|
|
assert_eq!(clamped, 4096.0);
|
|
|
|
let tiny_dim: f32 = -50.0;
|
|
let clamped_tiny = tiny_dim.clamp(1.0, MAX_PAGE_DIMENSION);
|
|
assert_eq!(clamped_tiny, 1.0);
|
|
|
|
// Verify uniform aspect ratio scaling under extreme dimensions
|
|
let (w, h) = compute_target_dimensions(10_000.0, 2_000.0, 72.0, 1.0);
|
|
assert_eq!(w, 4096);
|
|
assert_eq!(h, 819);
|
|
assert!(((w as f32 / h as f32) - 5.0).abs() < 0.01);
|
|
|
|
let (w2, h2) = compute_target_dimensions(2_000.0, 10_000.0, 72.0, 1.0);
|
|
assert_eq!(w2, 819);
|
|
assert_eq!(h2, 4096);
|
|
assert!(((h2 as f32 / w2 as f32) - 5.0).abs() < 0.01);
|
|
}
|
|
}
|