Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a012cb65a6 | ||
|
|
6567e1ab2d | ||
|
|
3af409d27f |
@@ -49,6 +49,9 @@ ttf-parser = "0.25"
|
|||||||
lopdf = { version = "0.42.0", features = ["rayon"] }
|
lopdf = { version = "0.42.0", features = ["rayon"] }
|
||||||
rayon = "1.10"
|
rayon = "1.10"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
# Optional native page rendering for OCR pipelines. PDFium is loaded at
|
||||||
|
# runtime, so enabling this feature does not link or download a native library.
|
||||||
|
firecrawl-pdfium = { version = "0.1.0", optional = true }
|
||||||
|
|
||||||
# Browser builds use JavaScript randomness for encrypted PDFs and embed the
|
# Browser builds use JavaScript randomness for encrypted PDFs and embed the
|
||||||
# bundled CMaps because there is no filesystem at runtime.
|
# bundled CMaps because there is no filesystem at runtime.
|
||||||
@@ -62,6 +65,7 @@ tempfile = "3.3"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
python = ["pyo3"]
|
python = ["pyo3"]
|
||||||
|
render-pdfium = ["dep:firecrawl-pdfium"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pdf2md"
|
name = "pdf2md"
|
||||||
|
|||||||
+40
-1
@@ -1,6 +1,6 @@
|
|||||||
# pdf-inspector
|
# pdf-inspector
|
||||||
|
|
||||||
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Pure Rust, no ML models, no external services; the only PDF dependency is [lopdf](https://crates.io/crates/lopdf). Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector).
|
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. The default build is pure Rust, has no ML models or external services, and uses [lopdf](https://crates.io/crates/lopdf) for PDF parsing. Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector/).
|
||||||
|
|
||||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||||
|
|
||||||
@@ -117,6 +117,45 @@ let bytes = std::fs::read("document.pdf")?;
|
|||||||
let result = process_pdf_mem(&bytes)?;
|
let result = process_pdf_mem(&bytes)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Optional native page rendering
|
||||||
|
|
||||||
|
The `render-pdfium` feature adds a native-only page renderer backed by
|
||||||
|
[`firecrawl-pdfium`](https://crates.io/crates/firecrawl-pdfium). It is the
|
||||||
|
rendering boundary for OCR pipelines; enabling it does not include an OCR
|
||||||
|
model or change the existing extraction functions.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
pdf-inspector = { version = "1", features = ["render-pdfium"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
PDFium is loaded at runtime. Set `PDFIUM_LIB_PATH`, place its shared library
|
||||||
|
next to the executable, or use another discovery route supported by
|
||||||
|
`firecrawl-pdfium`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pdf_inspector::vision::{PdfiumRenderer, RenderOptions};
|
||||||
|
|
||||||
|
let renderer = PdfiumRenderer::load()?;
|
||||||
|
let bytes = std::fs::read("document.pdf")?;
|
||||||
|
let pages = renderer.render_pages(
|
||||||
|
&bytes,
|
||||||
|
&[1, 3], // 1-indexed, matching pages_needing_ocr
|
||||||
|
None, // optional PDF password
|
||||||
|
&RenderOptions::new().dpi(150.0),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for page in pages {
|
||||||
|
// Owned RGB pixels can leave the PDFium critical section and be sent to
|
||||||
|
// an OCR worker. OCR pixel boxes can be mapped back to PDF coordinates.
|
||||||
|
let rect = page.pixel_rect_to_pdf_rect(20.0, 30.0, 100.0, 24.0);
|
||||||
|
println!("page {}: {}x{}, rect={rect:?}", page.page(), page.width(), page.height());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser WASM remains on the default text-only path and does not expose native
|
||||||
|
PDFium rendering.
|
||||||
|
|
||||||
Extract per-page Markdown (one string per page, plus document-wide layout
|
Extract per-page Markdown (one string per page, plus document-wide layout
|
||||||
metadata):
|
metadata):
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ mod text_quality;
|
|||||||
pub mod text_utils;
|
pub mod text_utils;
|
||||||
pub mod tounicode;
|
pub mod tounicode;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod vision;
|
||||||
|
|
||||||
pub use detector::{
|
pub use detector::{
|
||||||
detect_pdf_type, detect_pdf_type_mem, detect_pdf_type_mem_with_config,
|
detect_pdf_type, detect_pdf_type_mem, detect_pdf_type_mem_with_config,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//! Optional native vision primitives used by local extraction pipelines.
|
||||||
|
//!
|
||||||
|
//! The existing lopdf extractor remains the default path. Native page
|
||||||
|
//! rendering is available only with the `render-pdfium` feature and is kept
|
||||||
|
//! separate so browser WASM and text-only consumers do not take on PDFium.
|
||||||
|
|
||||||
|
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||||
|
mod pdfium;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||||
|
pub use pdfium::{
|
||||||
|
PagePoint, PdfiumRenderer, RenderError, RenderOptions, RenderPixelFormat, RenderedPage,
|
||||||
|
};
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
//! PDFium-backed page rendering for OCR.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use firecrawl_pdfium::{
|
||||||
|
PageRect as PdfiumPageRect, Pdfium, PixelFormat, PixelPoint, PixelRect, RenderConfig,
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::PdfRect;
|
||||||
|
|
||||||
|
/// Default rendering resolution for OCR.
|
||||||
|
pub const DEFAULT_RENDER_DPI: f32 = 150.0;
|
||||||
|
|
||||||
|
/// Default maximum size of one rendered page: 256 MiB.
|
||||||
|
pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Pixel layout returned by [`RenderedPage`].
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum RenderPixelFormat {
|
||||||
|
/// Three bytes per pixel in red, green, blue order. This is the default
|
||||||
|
/// because OCR preprocessors conventionally consume RGB images.
|
||||||
|
#[default]
|
||||||
|
Rgb8,
|
||||||
|
/// Four bytes per pixel in red, green, blue, alpha order.
|
||||||
|
Rgba8,
|
||||||
|
/// One luminance byte per pixel.
|
||||||
|
Gray8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderPixelFormat {
|
||||||
|
/// Number of bytes used by one pixel.
|
||||||
|
pub fn bytes_per_pixel(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Rgb8 => 3,
|
||||||
|
Self::Rgba8 => 4,
|
||||||
|
Self::Gray8 => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdfium_format(self) -> PixelFormat {
|
||||||
|
match self {
|
||||||
|
// PDFium produces BGR directly; `RenderedPage::from_pdfium`
|
||||||
|
// swaps the red and blue channels in place.
|
||||||
|
Self::Rgb8 => PixelFormat::Bgr8,
|
||||||
|
Self::Rgba8 => PixelFormat::Rgba8,
|
||||||
|
Self::Gray8 => PixelFormat::Gray8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for pages rendered as input to a local vision pipeline.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RenderOptions {
|
||||||
|
/// Output resolution. Defaults to 150 DPI.
|
||||||
|
pub dpi: f32,
|
||||||
|
/// Pixel layout. Defaults to three-channel RGB.
|
||||||
|
pub pixel_format: RenderPixelFormat,
|
||||||
|
/// Include PDF annotations in the rendered bitmap.
|
||||||
|
pub annotations: bool,
|
||||||
|
/// Include visible static AcroForm field appearances.
|
||||||
|
pub form_fields: bool,
|
||||||
|
/// Maximum allocation for each rendered page.
|
||||||
|
pub max_output_bytes_per_page: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RenderOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
dpi: DEFAULT_RENDER_DPI,
|
||||||
|
pixel_format: RenderPixelFormat::Rgb8,
|
||||||
|
annotations: true,
|
||||||
|
form_fields: true,
|
||||||
|
max_output_bytes_per_page: DEFAULT_MAX_OUTPUT_BYTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOptions {
|
||||||
|
/// Creates local-rendering options with OCR-oriented defaults.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the output resolution in dots per inch.
|
||||||
|
pub fn dpi(mut self, dpi: f32) -> Self {
|
||||||
|
self.dpi = dpi;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the output pixel layout.
|
||||||
|
pub fn pixel_format(mut self, pixel_format: RenderPixelFormat) -> Self {
|
||||||
|
self.pixel_format = pixel_format;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles annotation rendering.
|
||||||
|
pub fn annotations(mut self, annotations: bool) -> Self {
|
||||||
|
self.annotations = annotations;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles visible static form-field rendering.
|
||||||
|
pub fn form_fields(mut self, form_fields: bool) -> Self {
|
||||||
|
self.form_fields = form_fields;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum allocation for each rendered page.
|
||||||
|
pub fn max_output_bytes_per_page(mut self, bytes: u64) -> Self {
|
||||||
|
self.max_output_bytes_per_page = bytes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdfium_config(&self) -> RenderConfig {
|
||||||
|
RenderConfig::new()
|
||||||
|
.dpi(self.dpi)
|
||||||
|
.pixel_format(self.pixel_format.pdfium_format())
|
||||||
|
.annotations(self.annotations)
|
||||||
|
.form_fields(self.form_fields)
|
||||||
|
.max_output_bytes(self.max_output_bytes_per_page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point in PDF page space, measured in points from the bottom-left.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PagePoint {
|
||||||
|
/// Horizontal position in PDF points.
|
||||||
|
pub x: f32,
|
||||||
|
/// Vertical position in PDF points, increasing upward.
|
||||||
|
pub y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rendered page with owned pixels and its pixel-to-PDF transform.
|
||||||
|
///
|
||||||
|
/// The value contains no live PDFium page or document handles. It can be
|
||||||
|
/// moved to an OCR worker and retained after [`PdfiumRenderer::render_pages`]
|
||||||
|
/// returns.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RenderedPage {
|
||||||
|
page: u32,
|
||||||
|
page_width: f32,
|
||||||
|
page_height: f32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
stride: usize,
|
||||||
|
format: RenderPixelFormat,
|
||||||
|
pixels: Vec<u8>,
|
||||||
|
transform: firecrawl_pdfium::PageTransform,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderedPage {
|
||||||
|
fn from_pdfium(
|
||||||
|
page: u32,
|
||||||
|
page_width: f32,
|
||||||
|
page_height: f32,
|
||||||
|
format: RenderPixelFormat,
|
||||||
|
rendered: firecrawl_pdfium::RenderedPage,
|
||||||
|
) -> Self {
|
||||||
|
let width = rendered.width();
|
||||||
|
let height = rendered.height();
|
||||||
|
let stride = rendered.stride();
|
||||||
|
let transform = *rendered.transform();
|
||||||
|
let mut pixels = rendered.into_pixels();
|
||||||
|
|
||||||
|
if format == RenderPixelFormat::Rgb8 {
|
||||||
|
bgr_to_rgb_in_place(&mut pixels, width, height, stride);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
page,
|
||||||
|
page_width,
|
||||||
|
page_height,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stride,
|
||||||
|
format,
|
||||||
|
pixels,
|
||||||
|
transform,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1-indexed page number.
|
||||||
|
pub fn page(&self) -> u32 {
|
||||||
|
self.page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page width in PDF points after applying the page's `/Rotate` entry.
|
||||||
|
pub fn page_width(&self) -> f32 {
|
||||||
|
self.page_width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page height in PDF points after applying the page's `/Rotate` entry.
|
||||||
|
pub fn page_height(&self) -> f32 {
|
||||||
|
self.page_height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitmap width in pixels.
|
||||||
|
pub fn width(&self) -> u32 {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bitmap height in pixels.
|
||||||
|
pub fn height(&self) -> u32 {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of bytes between adjacent bitmap rows.
|
||||||
|
pub fn stride(&self) -> usize {
|
||||||
|
self.stride
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel layout of [`pixels`](Self::pixels).
|
||||||
|
pub fn format(&self) -> RenderPixelFormat {
|
||||||
|
self.format
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owned bitmap bytes, with rows ordered top-to-bottom.
|
||||||
|
pub fn pixels(&self) -> &[u8] {
|
||||||
|
&self.pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the page and returns its pixel buffer.
|
||||||
|
pub fn into_pixels(self) -> Vec<u8> {
|
||||||
|
self.pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a bitmap point (top-left origin, y-down) to PDF page space
|
||||||
|
/// (bottom-left origin, y-up).
|
||||||
|
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
|
||||||
|
let point = self.transform.pixel_to_page(PixelPoint::new(x, y));
|
||||||
|
PagePoint {
|
||||||
|
x: point.x as f32,
|
||||||
|
y: point.y as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a bitmap rectangle to the repository's existing PDF-space
|
||||||
|
/// rectangle type. The returned page number remains 1-indexed.
|
||||||
|
pub fn pixel_rect_to_pdf_rect(&self, x: f64, y: f64, width: f64, height: f64) -> PdfRect {
|
||||||
|
let rect = self
|
||||||
|
.transform
|
||||||
|
.pixel_rect_to_page(PixelRect::new(x, y, width, height));
|
||||||
|
PdfRect {
|
||||||
|
x: rect.left as f32,
|
||||||
|
y: rect.bottom as f32,
|
||||||
|
width: rect.width() as f32,
|
||||||
|
height: rect.height() as f32,
|
||||||
|
page: self.page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a PDF-space rectangle to bitmap coordinates
|
||||||
|
/// `(x, y, width, height)` with a top-left origin.
|
||||||
|
pub fn pdf_rect_to_pixel(&self, rect: &PdfRect) -> (f64, f64, f64, f64) {
|
||||||
|
let rect = self.transform.page_rect_to_pixel(PdfiumPageRect::new(
|
||||||
|
f64::from(rect.x),
|
||||||
|
f64::from(rect.y),
|
||||||
|
f64::from(rect.x + rect.width),
|
||||||
|
f64::from(rect.y + rect.height),
|
||||||
|
));
|
||||||
|
(rect.x, rect.y, rect.width, rect.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced by the optional local renderer.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum RenderError {
|
||||||
|
/// Page numbers in pdf-inspector APIs are 1-indexed, so zero is invalid.
|
||||||
|
#[error("page numbers are 1-indexed; page 0 is invalid")]
|
||||||
|
InvalidPageNumber,
|
||||||
|
/// The requested 1-indexed page is not present in the document.
|
||||||
|
#[error("page {page} is out of bounds for a {page_count}-page document")]
|
||||||
|
PageOutOfBounds {
|
||||||
|
/// Requested 1-indexed page.
|
||||||
|
page: u32,
|
||||||
|
/// Number of pages in the document.
|
||||||
|
page_count: usize,
|
||||||
|
},
|
||||||
|
/// PDFium loading, document parsing, form setup, or rendering failed.
|
||||||
|
#[error(transparent)]
|
||||||
|
Pdfium(#[from] firecrawl_pdfium::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loaded PDFium renderer used to prepare pages for OCR.
|
||||||
|
///
|
||||||
|
/// PDFium calls are safe from concurrent threads but serialize inside the
|
||||||
|
/// underlying binding. Returned [`RenderedPage`] values are ordinary owned
|
||||||
|
/// data and can be processed concurrently after rendering.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct PdfiumRenderer {
|
||||||
|
pdfium: Pdfium,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfiumRenderer {
|
||||||
|
/// Loads PDFium using `firecrawl-pdfium`'s documented discovery chain.
|
||||||
|
pub fn load() -> Result<Self, RenderError> {
|
||||||
|
Ok(Self {
|
||||||
|
pdfium: Pdfium::load()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads PDFium from an explicit native library path.
|
||||||
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, RenderError> {
|
||||||
|
Ok(Self {
|
||||||
|
pdfium: Pdfium::load_from_path(path)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the active PDFium library, if it was loaded from a concrete
|
||||||
|
/// file rather than through the system loader.
|
||||||
|
pub fn loaded_from(&self) -> Option<&Path> {
|
||||||
|
self.pdfium.loaded_from()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders selected 1-indexed pages in the same order as `pages`.
|
||||||
|
///
|
||||||
|
/// The PDF is parsed once for the full batch. Passing an empty page list
|
||||||
|
/// returns immediately without parsing or allocating.
|
||||||
|
pub fn render_pages(
|
||||||
|
&self,
|
||||||
|
pdf_bytes: &[u8],
|
||||||
|
pages: &[u32],
|
||||||
|
password: Option<&str>,
|
||||||
|
options: &RenderOptions,
|
||||||
|
) -> Result<Vec<RenderedPage>, RenderError> {
|
||||||
|
if pages.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
if pages.contains(&0) {
|
||||||
|
return Err(RenderError::InvalidPageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
let document = self.pdfium.load_document(pdf_bytes.to_vec(), password)?;
|
||||||
|
let page_count = document.page_count();
|
||||||
|
|
||||||
|
if let Some(&page) = pages.iter().find(|&&page| page as usize > page_count) {
|
||||||
|
return Err(RenderError::PageOutOfBounds { page, page_count });
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.form_fields {
|
||||||
|
document.enable_form_rendering()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = options.pdfium_config();
|
||||||
|
let mut rendered_pages = Vec::with_capacity(pages.len());
|
||||||
|
for &page_number in pages {
|
||||||
|
let page = document.page(page_number as usize - 1)?;
|
||||||
|
let size = page.size();
|
||||||
|
let rendered = page.render(&config)?;
|
||||||
|
rendered_pages.push(RenderedPage::from_pdfium(
|
||||||
|
page_number,
|
||||||
|
size.width,
|
||||||
|
size.height,
|
||||||
|
options.pixel_format,
|
||||||
|
rendered,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rendered_pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bgr_to_rgb_in_place(pixels: &mut [u8], width: u32, height: u32, stride: usize) {
|
||||||
|
let row_bytes = width as usize * RenderPixelFormat::Rgb8.bytes_per_pixel();
|
||||||
|
assert!(stride >= row_bytes, "pixel stride is shorter than one row");
|
||||||
|
assert_eq!(
|
||||||
|
pixels.len(),
|
||||||
|
stride * height as usize,
|
||||||
|
"pixel buffer length does not match stride and height"
|
||||||
|
);
|
||||||
|
|
||||||
|
for row in pixels.chunks_exact_mut(stride) {
|
||||||
|
for pixel in row[..row_bytes].chunks_exact_mut(3) {
|
||||||
|
pixel.swap(0, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_ocr_oriented_and_bounded() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
assert_eq!(options.dpi, 150.0);
|
||||||
|
assert_eq!(options.pixel_format, RenderPixelFormat::Rgb8);
|
||||||
|
assert!(options.annotations);
|
||||||
|
assert!(options.form_fields);
|
||||||
|
assert_eq!(options.max_output_bytes_per_page, 256 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bgr_pixels_are_converted_to_rgb_in_place() {
|
||||||
|
let mut pixels = vec![1, 2, 3, 4, 5, 6];
|
||||||
|
bgr_to_rgb_in_place(&mut pixels, 2, 1, 6);
|
||||||
|
assert_eq!(pixels, [3, 2, 1, 6, 5, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bgr_conversion_skips_row_padding() {
|
||||||
|
let mut pixels = vec![1, 2, 3, 9, 7, 8, 9, 6];
|
||||||
|
bgr_to_rgb_in_place(&mut pixels, 1, 2, 4);
|
||||||
|
assert_eq!(pixels, [3, 2, 1, 9, 9, 8, 7, 6]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#![cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||||
|
|
||||||
|
use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions, RenderPixelFormat};
|
||||||
|
|
||||||
|
fn load_renderer() -> Option<PdfiumRenderer> {
|
||||||
|
match PdfiumRenderer::load() {
|
||||||
|
Ok(renderer) => Some(renderer),
|
||||||
|
Err(RenderError::Pdfium(firecrawl_pdfium::Error::Load(
|
||||||
|
firecrawl_pdfium::LoadError::LibraryNotFound { .. },
|
||||||
|
))) => {
|
||||||
|
eprintln!("skipping PDFium runtime test because no native library is installed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(error) => panic!("failed to load PDFium: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_owned_rgb_page_and_round_trips_coordinates() {
|
||||||
|
let Some(renderer) = load_renderer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||||
|
let pages = renderer
|
||||||
|
.render_pages(
|
||||||
|
&bytes,
|
||||||
|
&[1],
|
||||||
|
None,
|
||||||
|
&RenderOptions::new().dpi(150.0).form_fields(false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pages.len(), 1);
|
||||||
|
let page = &pages[0];
|
||||||
|
assert_eq!(page.page(), 1);
|
||||||
|
assert_eq!(page.format(), RenderPixelFormat::Rgb8);
|
||||||
|
assert_eq!(page.stride(), page.width() as usize * 3);
|
||||||
|
assert_eq!(page.pixels().len(), page.stride() * page.height() as usize);
|
||||||
|
assert!((page.width() as f32 - page.page_width()).abs() > 1.0);
|
||||||
|
|
||||||
|
let pdf_rect = page.pixel_rect_to_pdf_rect(10.0, 10.0, 20.0, 12.0);
|
||||||
|
let pixel_rect = page.pdf_rect_to_pixel(&pdf_rect);
|
||||||
|
assert!((pixel_rect.0 - 10.0).abs() < 0.01);
|
||||||
|
assert!((pixel_rect.1 - 10.0).abs() < 0.01);
|
||||||
|
assert!((pixel_rect.2 - 20.0).abs() < 0.01);
|
||||||
|
assert!((pixel_rect.3 - 12.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_and_out_of_range_page_numbers() {
|
||||||
|
let Some(renderer) = load_renderer() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
renderer.render_pages(&bytes, &[0], None, &RenderOptions::new()),
|
||||||
|
Err(RenderError::InvalidPageNumber)
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
renderer.render_pages(&bytes, &[u32::MAX], None, &RenderOptions::new()),
|
||||||
|
Err(RenderError::PageOutOfBounds { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user