#[cfg(feature = "ml")]
use crate::PdfError;
#[cfg(feature = "ocr-prep")]
use image::RgbImage;
#[derive(Debug, Clone)]
pub struct TextCell {
pub text: String,
pub l: f32,
pub t: f32,
pub r: f32,
pub b: f32,
}
pub const RENDER_SCALE: f32 = 2.0;
#[derive(Clone)]
pub struct PdfPage {
pub width: f32,
pub height: f32,
pub scale: f32,
pub cells: Vec<TextCell>,
pub code_cells: Vec<TextCell>,
pub word_cells: Vec<TextCell>,
#[cfg(feature = "ocr-prep")]
pub image: RgbImage,
#[cfg(feature = "ocr-prep")]
pub image_layout: Option<RgbImage>,
pub links: Vec<LinkAnnot>,
pub rotation: u16,
}
impl PdfPage {
#[cfg(feature = "ocr-prep")]
pub fn from_cells(width: f32, height: f32, scale: f32, cells: Vec<TextCell>) -> Self {
Self {
width,
height,
scale,
cells,
code_cells: Vec::new(),
word_cells: Vec::new(),
#[cfg(feature = "ocr-prep")]
image: RgbImage::new(0, 0),
#[cfg(feature = "ocr-prep")]
image_layout: None,
links: Vec::new(),
rotation: 0,
}
}
#[cfg(feature = "ocr-prep")]
pub fn from_cells_with_image(
width: f32,
height: f32,
scale: f32,
cells: Vec<TextCell>,
image: RgbImage,
) -> Self {
Self {
image,
..Self::from_cells(width, height, scale, cells)
}
}
#[cfg(feature = "ocr-prep")]
pub(crate) fn unrotate(&mut self, deg: u16) {
if deg == 0 {
return;
}
use image::imageops::{rotate180, rotate270, rotate90};
let un = |img: &RgbImage| match deg {
90 => rotate270(img),
180 => rotate180(img),
_ => rotate90(img),
};
if self.image.width() > 1 {
self.image = un(&self.image);
}
self.image_layout = self.image_layout.as_ref().map(&un);
let (width, height) = (self.width, self.height);
for l in &mut self.links {
let (nl, nt, nr, nb) = match deg {
90 => (l.t, width - l.r, l.b, width - l.l),
180 => (width - l.r, height - l.b, width - l.l, height - l.t),
_ => (height - l.b, l.l, height - l.t, l.r),
};
(l.l, l.t, l.r, l.b) = (nl, nt, nr, nb);
}
if deg != 180 {
(self.width, self.height) = (height, width);
}
self.rotation = (self.rotation + deg) % 360;
}
}
#[derive(Debug, Clone)]
pub struct LinkAnnot {
pub l: f32,
pub t: f32,
pub r: f32,
pub b: f32,
pub uri: String,
}
#[cfg(feature = "ml")]
pub struct PdfDocument {
pub pages: Vec<PdfPage>,
}
#[cfg(feature = "ml")]
impl PdfDocument {
pub fn open(bytes: &[u8], password: Option<&str>) -> Result<Self, PdfError> {
let mut pages = Vec::new();
for_each_page::<PdfError, _>(bytes, password, true, true, None, |_, _, page| {
pages.push(page);
Ok(())
})?;
Ok(PdfDocument { pages })
}
}
#[cfg(feature = "ml")]
fn rust_parser_cells(
bytes: &[u8],
password: Option<&str>,
) -> Option<crate::textparse::PageTextParser> {
crate::timing::timed("textparse.open", || {
crate::textparse::PageTextParser::open_with_password(bytes, password)
})
}
impl crate::textparse::PageTextParser {
fn cells_timed(&mut self, index: usize) -> crate::textparse::PageParserCells {
crate::timing::timed("textparse", || self.cells(index))
}
}
#[cfg(feature = "ml")]
pub fn page_count(bytes: &[u8], password: Option<&str>) -> Result<usize, PdfError> {
if let Some(meta) = crate::timing::timed("meta.open", || {
crate::pdf_meta::PdfMeta::open_with_password(bytes, password)
})? {
return Ok(meta.page_count());
}
if let Some(dp) = crate::dparse_render::Doc::open_if_enabled(bytes, password) {
return Ok(dp.page_count());
}
crate::timing::timed("pdfium.page_count", || {
let lib = native::bind()?;
let n = lib.open(bytes, password)?.page_count();
Ok(n)
})
}
#[cfg(feature = "ml")]
pub fn for_each_page<E, F>(
bytes: &[u8],
password: Option<&str>,
render_image: bool,
extract_text: bool,
range: Option<(usize, usize)>,
mut f: F,
) -> Result<(), E>
where
E: From<PdfError>,
F: FnMut(usize, usize, PdfPage) -> Result<(), E>,
{
let meta = crate::timing::timed("meta.open", || {
crate::pdf_meta::PdfMeta::open_with_password(bytes, password)
})?;
let dparse = if render_image {
crate::dparse_render::Doc::open_if_enabled(bytes, password)
} else {
None
};
let pdfium = bind_or_skip(meta.is_some())?;
let session = match &pdfium {
Some(p) => Some(p.open(bytes, password)?),
None => None,
};
let mut rust = if extract_text {
rust_parser_cells(bytes, password)
} else {
None
};
let total = match (&session, &meta) {
(Some(s), _) => s.page_count(),
(None, Some(m)) => m.page_count(),
(None, None) => 0,
};
let meta = meta.filter(|m| {
session
.as_ref()
.is_none_or(|s| s.page_count() == m.page_count())
});
let renderer = match (&meta, render_image) {
(Some(m), true) => Some(crate::render::Renderer::new(m)),
_ => None,
};
let (first, last) = range.unwrap_or((0, total.saturating_sub(1)));
for i in first..=last {
if i >= total {
break;
}
let page = match &session {
Some(s) => Some(s.page(i)?),
None => None,
};
let geom = match (&meta, &page) {
(Some(m), _) => m.geometry(i).ok_or_else(|| no_raster(i as i32))?,
(None, Some(p)) => p.geom(),
(None, None) => return Err(no_raster(i as i32).into()),
};
let links = match (&meta, &page) {
(Some(m), _) => m.links(i),
(None, Some(p)) => p.links(geom.unrotated().1),
(None, None) => Vec::new(),
};
let rc = rust.as_mut().map(|p| p.cells_timed(i));
let extracted = extract_page(
PageSources {
page: page.as_ref(),
dparse: dparse.as_ref(),
meta: meta.as_ref(),
renderer: renderer.as_ref(),
},
geom,
links,
i as i32,
rc,
render_image,
)?;
f(i, total, extracted)?;
}
if let Some(parser) = rust {
std::thread::spawn(move || crate::timing::timed("textparse.close", || drop(parser)));
}
Ok(())
}
#[cfg(feature = "ml")]
#[derive(Debug, Clone)]
pub struct RenderedPage {
pub page_no: usize,
pub width: u32,
pub height: u32,
pub png: Vec<u8>,
}
#[cfg(feature = "ml")]
fn max_render_side() -> u32 {
static M: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
*M.get_or_init(|| docling_core::env::parse("DOCLING_RS_MAX_RENDER_PIXELS").unwrap_or(15_000))
}
#[cfg(feature = "ml")]
fn checked_render_dims(
w_px: f64,
h_px: f64,
page_no: usize,
) -> Result<(i32, i32), crate::PdfError> {
let cap = max_render_side();
let w = w_px.round().max(1.0);
let h = h_px.round().max(1.0);
if w > f64::from(cap) || h > f64::from(cap) {
return Err(crate::PdfError::Pdfium(format!(
"page {page_no}: render size {w:.0}x{h:.0} px exceeds the {cap}px per-side cap \
(raise DOCLING_RS_MAX_RENDER_PIXELS); the page's declared size is implausibly large"
)));
}
Ok((w as i32, h as i32))
}
#[cfg(feature = "ml")]
pub fn render_pages(
bytes: &[u8],
password: Option<&str>,
range: Option<(usize, usize)>,
scale: f32,
) -> Result<Vec<RenderedPage>, crate::PdfError> {
let dparse = crate::dparse_render::Doc::open_if_enabled(bytes, password);
let meta = crate::pdf_meta::PdfMeta::open_with_password(bytes, password)?;
let pdfium = match &dparse {
Some(_) => None,
None => bind_or_skip(meta.is_some())?,
};
let session = match &pdfium {
Some(p) => Some(p.open(bytes, password)?),
None => None,
};
let total = match (&session, &dparse, &meta) {
(Some(s), _, _) => s.page_count(),
(None, Some(dp), _) => dp.page_count(),
(None, None, Some(m)) => m.page_count(),
(None, None, None) => 0,
};
let (first, last) = match range {
None => (0, total.saturating_sub(1)),
Some((first, last)) => {
if first == 0 || last < first {
return Err(PdfError::Document(format!(
"invalid page range {first}-{last} (pages are 1-based, first <= last)"
)));
}
if first > total {
return Err(PdfError::Document(format!(
"page range {first}-{last} is outside the document ({total} page(s))"
)));
}
(first - 1, last.min(total) - 1)
}
};
let renderer = match (&dparse, &meta) {
(None, Some(m)) => Some(crate::render::Renderer::with_bitmap_hint(m, 0.0)),
_ => None,
};
let mut out = Vec::with_capacity(last.saturating_sub(first) + 1);
for i in first..=last {
if i >= total {
break;
}
let rust = || {
let m = meta.as_ref()?;
let g = m.geometry(i)?;
let (tw, th) = checked_render_dims(
f64::from(g.width * scale),
f64::from(g.height * scale),
i + 1,
)
.ok()?;
rust_bitmap(
Some(m),
renderer.as_ref(),
session.is_some(),
i as i32,
tw as u32,
th as u32,
0.0,
)
};
let bitmap = match (&dparse, &session) {
(Some(dp), _) => {
crate::timing::timed("dparse.rasterize", || dp.render(i, f64::from(scale), 0.0))
.map_err(PdfError::Document)?
}
(None, session) => match rust() {
Some(img) => img,
None => {
let Some(s) = session else {
return Err(no_raster(i as i32));
};
let page = s.page(i)?;
let (pw, ph) = page.size();
let (tw, th) =
checked_render_dims(f64::from(pw * scale), f64::from(ph * scale), i + 1)?;
page.render(tw, th, "pdfium.rasterize")?
}
},
};
let mut png = Vec::new();
bitmap
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| PdfError::Document(format!("PNG-encoding page {}: {e}", i + 1)))?;
out.push(RenderedPage {
page_no: i + 1,
width: bitmap.width(),
height: bitmap.height(),
png,
});
}
Ok(out)
}
#[cfg(feature = "ml")]
fn no_raster(index: i32) -> PdfError {
PdfError::Document(format!(
"page {}: no page renderer — the file's object model could not be read (lopdf), \
the docling-parse renderer plugin is not installed (.docling-parse/lib) and \
pdfium is not available (the `pdfium` feature + PDFIUM_DYNAMIC_LIB_PATH / \
.pdfium/lib)",
index + 1
))
}
#[cfg(feature = "ml")]
fn rust_bitmap(
meta: Option<&crate::pdf_meta::PdfMeta>,
renderer: Option<&crate::render::Renderer<'_>>,
pdfium_page: bool,
index: i32,
width: u32,
height: u32,
bitmap_hint: f64,
) -> Option<RgbImage> {
let meta = meta?;
if let Some(img) = crate::timing::timed("raster.render", || {
crate::raster::render(meta, index as usize, width, height)
}) {
return Some(img);
}
if pdfium_page && crate::dparse_render::choice() == crate::dparse_render::Choice::Pdfium {
return None;
}
let renderer = renderer?;
crate::timing::timed("render.page", || {
renderer.render_with_hint(index as usize, width, height, bitmap_hint)
})
}
#[cfg(feature = "ml")]
fn bind_or_skip(meta_ok: bool) -> Result<Option<native::Lib>, PdfError> {
if meta_ok && crate::dparse_render::choice() != crate::dparse_render::Choice::Pdfium {
return Ok(None);
}
match native::bind() {
Ok(p) => Ok(Some(p)),
Err(e) if meta_ok => {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!(
"docling-pdf: DOCLING_RS_RENDERER=pdfium but the pdfium library could not be \
loaded ({e}); rendering with the Rust renderer"
);
});
Ok(None)
}
Err(e) => Err(e),
}
}
#[cfg(feature = "ml")]
#[derive(Clone, Copy)]
struct PageSources<'a> {
page: Option<&'a native::Page<'a>>,
dparse: Option<&'a crate::dparse_render::Doc>,
meta: Option<&'a crate::pdf_meta::PdfMeta>,
renderer: Option<&'a crate::render::Renderer<'a>>,
}
#[cfg(feature = "ml")]
fn extract_page(
sources: PageSources<'_>,
geom: crate::pdf_meta::PageGeom,
links: Vec<LinkAnnot>,
index: i32,
rust_cells: Option<crate::textparse::PageParserCells>,
render_image: bool,
) -> Result<PdfPage, PdfError> {
let PageSources {
page,
dparse,
meta,
renderer,
} = sources;
let width = geom.width;
let height = geom.height;
let rotation = geom.rotation;
let (unrot_w, unrot_h) = geom.unrotated();
let rc = rust_cells.unwrap_or_default();
let (mut cells, mut code_cells, mut word_cells) = (rc.prose, rc.code, rc.words);
if rotation != 0 {
for c in cells
.iter_mut()
.chain(word_cells.iter_mut())
.chain(code_cells.iter_mut())
{
let (l, t, r, b) = to_display_frame((c.l, c.t, c.r, c.b), rotation, unrot_w, unrot_h);
(c.l, c.t, c.r, c.b) = (l, t, r, b);
}
}
let scanned = cells.is_empty() && word_cells.is_empty() && code_cells.is_empty();
let (mut dp_image, mut dp_layout) = (None, None);
if let (true, Some(dp)) = (render_image, dparse) {
let layout = crate::timing::timed("dparse.render_layout", || {
dp.render(index as usize, 1.0, 1.0)
})
.map_err(PdfError::Document)?;
if !scanned {
let full = crate::timing::timed("dparse.render", || {
dp.render(index as usize, f64::from(RENDER_SCALE), 1.0)
})
.map_err(PdfError::Document)?;
dp_image = Some(full);
}
dp.release_page(index as usize);
dp_layout = Some(layout);
}
let scanned_fallback = |page: Option<&native::Page<'_>>| match (page, dparse) {
(None, Some(dp)) if scanned => {
let full = crate::timing::timed("dparse.render", || {
dp.render(index as usize, f64::from(RENDER_SCALE), 1.0)
})
.map_err(PdfError::Document)?;
dp.release_page(index as usize);
Ok(Some(full))
}
_ => Ok::<_, PdfError>(None),
};
let image = if let Some(img) = dp_image.take() {
img
} else if render_image {
const SUPERSAMPLE: f32 = 1.5;
let (tw, th) = checked_render_dims(
f64::from(width * RENDER_SCALE * SUPERSAMPLE),
f64::from(height * RENDER_SCALE * SUPERSAMPLE),
(index + 1) as usize,
)?;
let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
let hint = if cells.is_empty() { 0.0 } else { 1.0 };
let big = match rust_bitmap(
meta,
renderer,
page.is_some(),
index,
tw as u32,
th as u32,
hint,
) {
Some(img) => Some(img),
None if page.is_some() => {
let page = page.ok_or_else(|| no_raster(index))?;
Some(page.render(tw, th, "pdfium.render")?)
}
None => None,
};
match (big, scanned_fallback(page)?) {
(Some(big), _) => crate::timing::timed("image.resize", || fast_downscale(&big, dw, dh)),
(None, Some(canvas)) => canvas,
(None, None) => return Err(no_raster(index)),
}
} else {
RgbImage::new(1, 1)
};
let image_layout = if let Some(img) = dp_layout.take() {
Some(img)
} else if render_image {
let tw = f64::from(width * 1.5).ceil().max(1.0) as i32;
let th = f64::from(height * 1.5).ceil().max(1.0) as i32;
let big = match rust_bitmap(
meta,
renderer,
page.is_some(),
index,
tw as u32,
th as u32,
1.0,
) {
Some(img) => img,
None => {
let page = page.ok_or_else(|| no_raster(index))?;
page.render(tw, th, "pdfium.render_layout")?
}
};
let dw = f64::from(width).round_ties_even().max(1.0) as u32;
let dh = f64::from(height).round_ties_even().max(1.0) as u32;
Some(crate::timing::timed("image.resize_layout", || {
crate::resample::pil_resize(&big, dw, dh, crate::resample::PilFilter::Bicubic)
}))
} else {
None
};
let mut links = links;
if rotation != 0 {
for l in &mut links {
let (a, t, r, b) = to_display_frame((l.l, l.t, l.r, l.b), rotation, unrot_w, unrot_h);
(l.l, l.t, l.r, l.b) = (a, t, r, b);
}
}
let mut page = PdfPage {
width,
height,
scale: RENDER_SCALE,
image_layout,
cells,
code_cells,
word_cells,
image,
links,
rotation: 0,
};
if rotation != 0 && scanned && render_image {
page.unrotate(rotation);
}
Ok(page)
}
#[cfg(feature = "ml")]
fn fast_downscale(big: &RgbImage, dw: u32, dh: u32) -> RgbImage {
use fast_image_resize as fir;
static SLOW: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
let slow = *SLOW.get_or_init(|| docling_core::env::flag("DOCLING_RS_SLOW_RESIZE"));
if !slow {
if let Some(out) = (|| {
let src = fir::images::ImageRef::new(
big.width(),
big.height(),
big.as_raw(),
fir::PixelType::U8x3,
)
.ok()?;
let mut dst = fir::images::Image::new(dw, dh, fir::PixelType::U8x3);
fir::Resizer::new()
.resize(
&src,
&mut dst,
&fir::ResizeOptions::new()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom)),
)
.ok()?;
RgbImage::from_raw(dw, dh, dst.into_vec())
})() {
return out;
}
}
image::imageops::resize(big, dw, dh, image::imageops::FilterType::CatmullRom)
}
pub(crate) fn to_display_frame(
(l, t, r, b): (f32, f32, f32, f32),
rotation: u16,
unrot_w: f32,
unrot_h: f32,
) -> (f32, f32, f32, f32) {
match rotation {
90 => (unrot_h - b, l, unrot_h - t, r),
180 => (unrot_w - r, unrot_h - b, unrot_w - l, unrot_h - t),
270 => (t, unrot_w - r, b, unrot_w - l),
_ => (l, t, r, b),
}
}
pub(crate) struct Glyph {
pub(crate) ch: char,
pub(crate) l: f32,
pub(crate) b: f32,
pub(crate) r: f32,
pub(crate) t: f32,
pub(crate) ll: f32,
pub(crate) lb: f32,
pub(crate) lr: f32,
pub(crate) lt: f32,
pub(crate) font: u64,
}
#[derive(Clone, Copy, PartialEq)]
enum Grouping {
CodeGap,
}
fn lines_from_glyphs(gs: &[Glyph], page_h: f32, mode: Grouping) -> Vec<TextCell> {
let mut cells: Vec<TextCell> = Vec::new();
let mut words: Vec<String> = Vec::new(); let mut word = String::new();
let (mut ll, mut lb, mut lr, mut lt) = (
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
);
let mut line_h: f32 = 0.0;
let mut prev: Option<&Glyph> = None;
let mut pending_space = false;
for g in gs {
if g.ch == ' ' {
pending_space = true;
continue;
}
let h = (g.t - g.b).abs().max(1.0);
let (mut new_word, mut new_line) = (false, false);
if let Some(p) = prev {
let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
g.l > p.r
} else {
g.l < p.r
};
new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
let word_gap = line_h.max(h) * 0.25;
new_word = match mode {
Grouping::CodeGap => new_line || pending_space || g.l - p.r > word_gap,
};
}
pending_space = false;
if new_line {
push_word(&mut word, &mut words);
push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
(ll, lb, lr, lt) = (
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
);
line_h = 0.0;
} else if new_word {
push_word(&mut word, &mut words);
}
word.push(g.ch);
ll = ll.min(g.l);
lb = lb.min(g.b);
lr = lr.max(g.r);
lt = lt.max(g.t);
line_h = line_h.max(h);
prev = Some(g);
}
push_word(&mut word, &mut words);
push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
cells
}
pub(crate) fn code_cells_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
lines_from_glyphs(gs, page_h, Grouping::CodeGap)
}
fn is_arabic(c: char) -> bool {
('\u{0600}'..='\u{06FF}').contains(&c)
}
fn push_word(word: &mut String, words: &mut Vec<String>) {
if !word.is_empty() {
words.push(std::mem::take(word));
}
}
fn push_line(
words: &mut Vec<String>,
bbox: (f32, f32, f32, f32),
page_h: f32,
cells: &mut Vec<TextCell>,
) {
if words.is_empty() {
return;
}
let text = std::mem::take(words).join(" ");
let (l, b, r, t) = bbox;
cells.push(TextCell {
text,
l,
t: page_h - t,
r,
b: page_h - b,
});
}
#[cfg(feature = "pdfium")]
mod native {
use super::LinkAnnot;
use crate::PdfError;
use image::RgbImage;
use pdfium_render::prelude::*;
pub(super) struct Lib(Pdfium);
pub(super) struct Session<'a> {
doc: PdfDocument<'a>,
}
pub(super) struct Page<'a>(PdfPage<'a>);
fn try_bind_dir(path: &str) -> Option<Box<dyn PdfiumLibraryBindings>> {
let name = Pdfium::pdfium_platform_library_name_at_path(path);
if let Ok(b) = Pdfium::bind_to_library(&name) {
return Some(b);
}
Pdfium::bind_to_library(path).ok()
}
pub(super) fn bind() -> Result<Lib, PdfError> {
if let Some(path) = docling_core::env::nonempty("PDFIUM_DYNAMIC_LIB_PATH") {
if let Some(b) = try_bind_dir(&path) {
return Ok(Lib(Pdfium::new(b)));
}
}
if let Some(b) = try_bind_dir(&crate::resolve_asset(".pdfium/lib")) {
return Ok(Lib(Pdfium::new(b)));
}
Pdfium::bind_to_system_library()
.map(|b| Lib(Pdfium::new(b)))
.map_err(Into::into)
}
#[cfg(test)]
pub(crate) fn bind_for_tests() -> Result<Pdfium, PdfError> {
if std::env::var_os("PDFIUM_DYNAMIC_LIB_PATH").is_none() {
let lib = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.pdfium/lib");
if lib.is_dir() {
std::env::set_var("PDFIUM_DYNAMIC_LIB_PATH", &lib);
}
}
bind().map(|l| l.0)
}
impl Lib {
pub(super) fn open<'a>(
&'a self,
bytes: &'a [u8],
password: Option<&str>,
) -> Result<Session<'a>, PdfError> {
crate::timing::timed("pdfium.open", || {
self.0
.load_pdf_from_byte_slice(bytes, password)
.map(|doc| Session { doc })
.map_err(Into::into)
})
}
}
impl Session<'_> {
pub(super) fn page_count(&self) -> usize {
self.doc.pages().len() as usize
}
pub(super) fn page(&self, index: usize) -> Result<Page<'_>, PdfError> {
self.doc
.pages()
.get(index as PdfPageIndex)
.map(Page)
.map_err(Into::into)
}
}
impl Page<'_> {
pub(super) fn size(&self) -> (f32, f32) {
(self.0.width().value, self.0.height().value)
}
pub(super) fn geom(&self) -> crate::pdf_meta::PageGeom {
crate::pdf_meta::PageGeom {
width: self.0.width().value,
height: self.0.height().value,
rotation: match self.0.rotation() {
Ok(PdfPageRenderRotation::Degrees90) => 90u16,
Ok(PdfPageRenderRotation::Degrees180) => 180,
Ok(PdfPageRenderRotation::Degrees270) => 270,
_ => 0,
},
}
}
pub(super) fn render(
&self,
w: i32,
h: i32,
stage: &'static str,
) -> Result<RgbImage, PdfError> {
let cfg = PdfRenderConfig::new()
.set_target_width(w)
.set_target_height(h);
crate::timing::timed(stage, || {
self.0
.render_with_config(&cfg)
.map(|b| b.as_image().into_rgb8())
.map_err(Into::into)
})
}
pub(super) fn links(&self, page_h: f32) -> Vec<LinkAnnot> {
let mut out = Vec::new();
for link in self.0.links().iter() {
let Some(uri) = link
.action()
.and_then(|a| a.as_uri_action().and_then(|u| u.uri().ok()))
else {
continue;
};
let scheme_ok = ["http://", "https://", "mailto:", "tel:"]
.iter()
.any(|s| uri.starts_with(s));
if !scheme_ok {
continue;
}
if let Ok(rect) = link.rect() {
out.push(LinkAnnot {
l: rect.left().value,
t: page_h - rect.top().value,
r: rect.right().value,
b: page_h - rect.bottom().value,
uri,
});
}
}
out
}
}
}
#[cfg(all(feature = "ml", not(feature = "pdfium")))]
mod native {
use super::LinkAnnot;
use crate::PdfError;
use image::RgbImage;
use std::convert::Infallible;
use std::marker::PhantomData;
pub(super) struct Lib(Infallible);
pub(super) struct Session<'a>(Infallible, PhantomData<&'a ()>);
pub(super) struct Page<'a>(Infallible, PhantomData<&'a ()>);
pub(super) fn bind() -> Result<Lib, PdfError> {
Err(PdfError::Document(
"pdfium support is not compiled in (docling-pdf feature `pdfium`)".into(),
))
}
impl Lib {
pub(super) fn open<'a>(
&'a self,
_bytes: &'a [u8],
_password: Option<&str>,
) -> Result<Session<'a>, PdfError> {
match self.0 {}
}
}
impl Session<'_> {
pub(super) fn page_count(&self) -> usize {
match self.0 {}
}
pub(super) fn page(&self, _index: usize) -> Result<Page<'_>, PdfError> {
match self.0 {}
}
}
impl Page<'_> {
pub(super) fn size(&self) -> (f32, f32) {
match self.0 {}
}
pub(super) fn geom(&self) -> crate::pdf_meta::PageGeom {
match self.0 {}
}
pub(super) fn render(
&self,
_w: i32,
_h: i32,
_stage: &'static str,
) -> Result<RgbImage, PdfError> {
match self.0 {}
}
pub(super) fn links(&self, _page_h: f32) -> Vec<LinkAnnot> {
match self.0 {}
}
}
}
#[cfg(all(test, feature = "pdfium"))]
pub(crate) use native::bind_for_tests;
#[cfg(test)]
mod tests {
use super::{checked_render_dims, max_render_side, to_display_frame};
#[test]
#[cfg(feature = "pdfium")]
fn pdf_meta_matches_pdfium_on_the_corpus() {
let Ok(_) = super::bind_for_tests() else {
eprintln!("skipping: pdfium not found");
return;
};
let lib = super::native::bind().unwrap();
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let mut files: Vec<std::path::PathBuf> = Vec::new();
for dir in ["tests/data/pdf/sources", "tests/data/scanned/sources"] {
let Ok(rd) = std::fs::read_dir(root.join(dir)) else {
continue;
};
files.extend(
rd.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "pdf")),
);
}
files.sort();
assert!(!files.is_empty(), "corpus not found");
let mut checked = 0;
for path in files {
let bytes = std::fs::read(&path).unwrap();
let Ok(doc) = lib.open(&bytes, None) else {
continue; };
let Some(meta) = crate::pdf_meta::PdfMeta::open(&bytes) else {
panic!(
"{}: lopdf could not read a file pdfium reads",
path.display()
);
};
assert_eq!(meta.page_count(), doc.page_count(), "{}", path.display());
for i in 0..meta.page_count() {
let page = doc.page(i).unwrap();
let want = page.geom();
let got = meta.geometry(i).unwrap();
assert!(
(got.width - want.width).abs() < 0.01
&& (got.height - want.height).abs() < 0.01
&& got.rotation == want.rotation,
"{} p{}: meta {got:?} vs pdfium {want:?}",
path.display(),
i + 1
);
let mut want_links = page.links(want.unrotated().1);
let mut got_links = meta.links(i);
let key = |l: &super::LinkAnnot| {
(
l.uri.clone(),
(l.l * 10.0) as i64,
(l.t * 10.0) as i64,
(l.r * 10.0) as i64,
(l.b * 10.0) as i64,
)
};
want_links.sort_by_key(key);
got_links.sort_by_key(key);
let want_keys: Vec<_> = want_links.iter().map(key).collect();
let got_keys: Vec<_> = got_links.iter().map(key).collect();
assert_eq!(got_keys, want_keys, "{} p{}: links", path.display(), i + 1);
checked += 1;
}
}
eprintln!("pdf_meta checked against pdfium on {checked} pages");
}
#[test]
fn oversized_render_is_rejected_not_allocated() {
let cap = f64::from(max_render_side());
let huge = checked_render_dims(cap + 1.0, 10.0, 1);
assert!(huge.is_err(), "over-cap width must be rejected");
let tall = checked_render_dims(10.0, cap + 1.0, 7);
assert!(tall.is_err(), "over-cap height must be rejected");
assert!(
tall.unwrap_err().to_string().contains("page 7"),
"the error names the offending page"
);
assert_eq!(
checked_render_dims(1836.4, 2375.6, 1).unwrap(),
(1836, 2376)
);
assert_eq!(
checked_render_dims(cap, cap, 1).unwrap(),
(cap as i32, cap as i32)
);
assert_eq!(checked_render_dims(0.0, 0.0, 1).unwrap(), (1, 1));
}
#[test]
fn display_frame_follows_the_page_rotation() {
let r = (72.0, 63.0, 387.0, 74.0); assert_eq!(to_display_frame(r, 0, 612.0, 792.0), r);
assert_eq!(
to_display_frame(r, 90, 612.0, 792.0),
(718.0, 72.0, 729.0, 387.0)
);
assert_eq!(
to_display_frame(r, 180, 612.0, 792.0),
(225.0, 718.0, 540.0, 729.0)
);
assert_eq!(
to_display_frame(r, 270, 612.0, 792.0),
(63.0, 225.0, 74.0, 540.0)
);
}
#[test]
fn display_frame_rotations_compose_to_identity() {
let r = (10.0, 20.0, 110.0, 40.0);
let once = to_display_frame(r, 90, 612.0, 792.0);
assert_eq!(to_display_frame(once, 270, 792.0, 612.0), r);
let twice = to_display_frame(to_display_frame(r, 180, 612.0, 792.0), 180, 612.0, 792.0);
assert_eq!(twice, r);
}
}