#[cfg(test)]
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
mod decode_skip_tests;
#[cfg(test)]
mod parallel_tests;
use super::NativeDocument;
use crate::cancellation::CancellationToken;
use crate::pdf::error::{PdfError, Result};
use bytes::Bytes;
use image::{DynamicImage, ImageFormat};
use std::borrow::Cow;
use std::io::Cursor;
#[inline]
fn detect_image_format_from_bytes(data: &[u8]) -> &'static str {
if data.starts_with(b"\xff\xd8\xff") {
"jpeg"
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
"png"
} else if data.starts_with(b"GIF8") {
"gif"
} else if data.starts_with(b"II") || data.starts_with(b"MM") {
"tiff"
} else if data.starts_with(b"BM") {
"bmp"
} else if data.len() >= 8 && data[0..4] == [0x00, 0x00, 0x00, 0x0C] && data[4..8] == [0x6A, 0x50, 0x20, 0x20] {
"jpeg2000"
} else {
"raw"
}
}
fn extract_n_images_from_page_handles(
doc: &NativeDocument,
page_idx: usize,
limit: usize,
) -> Result<Vec<xberg_native_pdf::extractors::PdfImage>> {
let handles = doc.doc.page_image_handles(page_idx).map_err(|error| {
PdfError::ExtractionFailed(format!(
"enumerating image handles for PDF page {}: {error}",
page_idx + 1
))
})?;
let mut images = Vec::new();
for handle in handles.into_iter().take(limit) {
match handle.decode() {
Ok(img) => images.push(img),
Err(error) => {
tracing::debug!(page = page_idx, "image decompression failed: {error}");
}
}
}
Ok(images)
}
fn raw_pixels_to_png(
w: u32,
h: u32,
format: &xberg_native_pdf::extractors::PixelFormat,
pixels: &[u8],
) -> Result<Bytes> {
let dynamic = match *format {
xberg_native_pdf::extractors::PixelFormat::Grayscale => {
let checked = exact_pixel_buffer(w, h, 1, pixels, "grayscale")?;
let buf = image::GrayImage::from_raw(w, h, checked).ok_or_else(|| {
PdfError::ExtractionFailed(format!(
"grayscale pixel buffer ({} bytes) does not fit {}×{} image",
pixels.len(),
w,
h
))
})?;
DynamicImage::ImageLuma8(buf)
}
xberg_native_pdf::extractors::PixelFormat::RGB => {
let checked = exact_pixel_buffer(w, h, 3, pixels, "RGB")?;
let buf = image::RgbImage::from_raw(w, h, checked).ok_or_else(|| {
PdfError::ExtractionFailed(format!(
"RGB pixel buffer ({} bytes) does not fit {}×{} image",
pixels.len(),
w,
h
))
})?;
DynamicImage::ImageRgb8(buf)
}
xberg_native_pdf::extractors::PixelFormat::CMYK => {
let mut rgb = Vec::with_capacity((pixels.len() / 4) * 3);
for chunk in pixels.chunks_exact(4) {
let c = chunk[0] as f32 / 255.0;
let m = chunk[1] as f32 / 255.0;
let y = chunk[2] as f32 / 255.0;
let k = chunk[3] as f32 / 255.0;
rgb.push(((1.0 - c) * (1.0 - k) * 255.0) as u8);
rgb.push(((1.0 - m) * (1.0 - k) * 255.0) as u8);
rgb.push(((1.0 - y) * (1.0 - k) * 255.0) as u8);
}
let checked = exact_pixel_buffer(w, h, 3, &rgb, "CMYK→RGB")?;
let buf = image::RgbImage::from_raw(w, h, checked)
.ok_or_else(|| PdfError::ExtractionFailed(format!("CMYK→RGB buffer does not fit {}×{} image", w, h)))?;
DynamicImage::ImageRgb8(buf)
}
};
let mut png_bytes = Vec::new();
dynamic
.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)
.map_err(|e| PdfError::ExtractionFailed(format!("PNG re-encode of raw PDF image failed: {e}")))?;
Ok(Bytes::from(png_bytes))
}
fn exact_pixel_buffer(w: u32, h: u32, channels: usize, pixels: &[u8], kind: &str) -> Result<Vec<u8>> {
let expected = (w as usize)
.checked_mul(h as usize)
.and_then(|px| px.checked_mul(channels))
.ok_or_else(|| PdfError::ExtractionFailed(format!("{kind} image dimensions {w}×{h} overflow")))?;
if pixels.len() != expected {
return Err(PdfError::ExtractionFailed(format!(
"{kind} pixel buffer ({} bytes) does not match {}×{} image ({expected} bytes expected); \
the decoded row stride does not match width × {channels}",
pixels.len(),
w,
h
)));
}
Ok(pixels.to_vec())
}
fn unencodable_image_warning(image_index: u32, page_number: u32, error: &PdfError) -> crate::types::ProcessingWarning {
crate::types::ProcessingWarning {
source: std::borrow::Cow::Borrowed("pdf_images"),
message: std::borrow::Cow::Owned(format!(
"skipped image {image_index} on page {page_number}: could not be re-encoded from raw \
pixel data ({error})"
)),
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum XObjectRecovery {
EmbeddedJpeg,
ReencodedPixels,
RawCompressedStream,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
impl XObjectRecovery {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::EmbeddedJpeg => "embedded_jpeg",
Self::ReencodedPixels => "reencoded_pixels",
Self::RawCompressedStream => "raw_compressed_stream",
}
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[derive(Debug, Clone)]
pub(crate) struct PageFallbackImage {
pub bytes: Bytes,
pub format: &'static str,
pub recovery: XObjectRecovery,
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn page_ocr_fallback_image_bytes(
doc: &xberg_native_pdf::PdfDocument,
page_idx: usize,
) -> Vec<PageFallbackImage> {
let handles = match doc.page_image_handles(page_idx) {
Ok(h) => h,
Err(error) => {
tracing::debug!(
page = page_idx,
"force_ocr fallback: enumerating image handles failed: {error}"
);
return Vec::new();
}
};
let mut out = Vec::with_capacity(handles.len());
for handle in &handles {
match handle.decode() {
Ok(img) => match img.data() {
xberg_native_pdf::extractors::ImageData::Jpeg(jpeg_bytes) => out.push(PageFallbackImage {
bytes: Bytes::copy_from_slice(jpeg_bytes),
format: "jpeg",
recovery: XObjectRecovery::EmbeddedJpeg,
}),
xberg_native_pdf::extractors::ImageData::Raw { pixels, format } => {
match raw_pixels_to_png(img.width(), img.height(), format, pixels) {
Ok(bytes) => out.push(PageFallbackImage {
bytes,
format: "png",
recovery: XObjectRecovery::ReencodedPixels,
}),
Err(error) => {
tracing::debug!(page = page_idx, "force_ocr fallback: raw re-encode failed: {error}");
}
}
}
},
Err(decode_err) => {
let passthrough = matches!(
handle.filter_chain.last(),
Some(xberg_native_pdf::PdfFilter::DCTDecode) | Some(xberg_native_pdf::PdfFilter::JPXDecode)
);
if passthrough {
match handle.raw_compressed_bytes() {
Ok(raw) if matches!(detect_image_format_from_bytes(&raw), "jpeg" | "jpeg2000") => {
let format = detect_image_format_from_bytes(&raw);
out.push(PageFallbackImage {
bytes: Bytes::from(raw),
format,
recovery: XObjectRecovery::RawCompressedStream,
});
}
Ok(_) => {
tracing::debug!(
page = page_idx,
"force_ocr fallback: raw bytes not a recognizable JPEG/JP2"
);
}
Err(error) => {
tracing::debug!(
page = page_idx,
"force_ocr fallback: raw_compressed_bytes failed: {error}"
);
}
}
} else {
tracing::debug!(
page = page_idx,
"force_ocr fallback: undecodable image, non-JPEG codec: {decode_err}"
);
}
}
}
}
out
}
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
pub(crate) fn ocr_skip_candidate_pages(
page_structure: Option<&crate::types::PageStructure>,
) -> std::collections::HashMap<u32, (f64, f64)> {
let mut pages = std::collections::HashMap::new();
let Some(page_infos) = page_structure.and_then(|structure| structure.pages.as_ref()) else {
return pages;
};
for page in page_infos {
if page.is_blank == Some(false)
&& let Some(dimensions) = page.dimensions
{
pages.insert(page.number, (dimensions.width, dimensions.height));
}
}
pages
}
#[cfg(all(feature = "ocr", feature = "tokio-runtime"))]
fn covers_full_page(bbox: &xberg_native_pdf::geometry::Rect, page_width: f64, page_height: f64) -> bool {
if page_width <= 0.0 || page_height <= 0.0 {
return false;
}
let image_area = f64::from(bbox.width) * f64::from(bbox.height);
image_area / (page_width * page_height) >= crate::core::pipeline::FULL_PAGE_IMAGE_AREA_RATIO
}
#[cfg(not(all(feature = "ocr", feature = "tokio-runtime")))]
fn covers_full_page(_bbox: &xberg_native_pdf::geometry::Rect, _page_width: f64, _page_height: f64) -> bool {
false
}
fn skipped_image_outcome(
handle: &xberg_native_pdf::PdfImageHandle<'_>,
page_number: u32,
alt_text: Option<String>,
) -> PageImageOutcome {
Ok(crate::types::ExtractedImage {
data: Bytes::new(),
format: Cow::Borrowed("skipped"),
image_index: 0,
page_number: Some(page_number),
width: Some(handle.width),
height: Some(handle.height),
colorspace: Some(format!("{:?}", handle.color_space)),
bits_per_component: Some(handle.bits_per_component as u32),
is_mask: false,
description: alt_text,
ocr_result: None,
bounding_box: Some(crate::types::BoundingBox {
x0: handle.bbox.x as f64,
y0: handle.bbox.y as f64,
x1: (handle.bbox.x + handle.bbox.width) as f64,
y1: (handle.bbox.y + handle.bbox.height) as f64,
}),
source_path: None,
image_kind: None,
kind_confidence: None,
cluster_id: None,
caption: None,
qr_codes: None,
data_base64: None,
})
}
fn image_outcome_from_decoded(
native_img: &xberg_native_pdf::extractors::PdfImage,
page_number: u32,
alt_text: Option<String>,
) -> PageImageOutcome {
let (data, format) = match native_img.data() {
xberg_native_pdf::extractors::ImageData::Jpeg(jpeg_bytes) => {
let data_bytes = Bytes::copy_from_slice(jpeg_bytes);
let actual_format = detect_image_format_from_bytes(data_bytes.as_ref());
(data_bytes, Cow::Borrowed(actual_format))
}
xberg_native_pdf::extractors::ImageData::Raw { pixels, format } => {
match raw_pixels_to_png(native_img.width(), native_img.height(), format, pixels) {
Ok(bytes) => (bytes, Cow::Borrowed("png")),
Err(e) => return Err(e),
}
}
};
Ok(crate::types::ExtractedImage {
data,
format,
image_index: 0,
page_number: Some(page_number),
width: Some(native_img.width()),
height: Some(native_img.height()),
colorspace: Some(format!("{:?}", native_img.color_space())),
bits_per_component: Some(native_img.bits_per_component() as u32),
is_mask: false,
description: alt_text,
ocr_result: None,
bounding_box: native_img.bbox().map(|r| crate::types::BoundingBox {
x0: r.x as f64,
y0: r.y as f64,
x1: (r.x + r.width) as f64,
y1: (r.y + r.height) as f64,
}),
source_path: None,
image_kind: None,
kind_confidence: None,
cluster_id: None,
caption: None,
qr_codes: None,
data_base64: None,
})
}
pub(crate) fn extract_images_with_data(
doc: &mut NativeDocument,
max_images_per_page: Option<u32>,
cancel_token: Option<&CancellationToken>,
ocr_skip_candidate_pages: &std::collections::HashMap<u32, (f64, f64)>,
) -> Result<(Vec<crate::types::ExtractedImage>, Vec<crate::types::ProcessingWarning>)> {
if max_images_per_page == Some(0) {
return Ok((Vec::new(), Vec::new()));
}
tracing::debug!(
target: "xberg::pdf::native::images",
event = "decompression_started",
"extract_images_with_data entered"
);
let page_count = doc
.doc
.page_count()
.map_err(|e| PdfError::MetadataExtractionFailed(format!("xberg_native_pdf: failed to get page count: {e}")))?;
let alt_text_by_page = super::hierarchy::extract_figure_alt_text_by_page(doc);
let doc: &NativeDocument = doc;
let per_page = extract_all_page_images(
doc,
page_count,
max_images_per_page,
&alt_text_by_page,
cancel_token,
ocr_skip_candidate_pages,
);
let mut all_images = Vec::new();
let mut warnings = Vec::new();
let mut global_index = 0u32;
for (page_idx, outcomes) in per_page.into_iter().enumerate() {
let page_number = (page_idx + 1) as u32;
for outcome in outcomes {
match outcome {
Ok(mut image) => {
image.image_index = global_index;
all_images.push(image);
global_index += 1;
}
Err(error) => {
tracing::warn!(
page = page_number,
image_index = global_index,
"skipping raw PDF image that could not be re-encoded: {error}"
);
warnings.push(unencodable_image_warning(global_index, page_number, &error));
}
}
}
}
Ok((all_images, warnings))
}
type PageImageOutcome = std::result::Result<crate::types::ExtractedImage, PdfError>;
fn extract_all_page_images(
doc: &NativeDocument,
page_count: usize,
max_images_per_page: Option<u32>,
alt_text_by_page: &std::collections::HashMap<u32, Vec<Option<String>>>,
cancel_token: Option<&CancellationToken>,
ocr_skip_candidate_pages: &std::collections::HashMap<u32, (f64, f64)>,
) -> Vec<Vec<PageImageOutcome>> {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
(0..page_count)
.into_par_iter()
.map(|page_idx| {
extract_page_images(
doc,
page_idx,
max_images_per_page,
alt_text_by_page,
cancel_token,
ocr_skip_candidate_pages,
)
})
.collect()
}
#[cfg(target_arch = "wasm32")]
{
(0..page_count)
.map(|page_idx| {
extract_page_images(
doc,
page_idx,
max_images_per_page,
alt_text_by_page,
cancel_token,
ocr_skip_candidate_pages,
)
})
.collect()
}
}
fn extract_page_images(
doc: &NativeDocument,
page_idx: usize,
max_images_per_page: Option<u32>,
alt_text_by_page: &std::collections::HashMap<u32, Vec<Option<String>>>,
cancel_token: Option<&CancellationToken>,
ocr_skip_candidate_pages: &std::collections::HashMap<u32, (f64, f64)>,
) -> Vec<PageImageOutcome> {
#[cfg(test)]
record_page_thread();
if cancel_token.is_some_and(|t| t.is_cancelled()) {
return Vec::new();
}
let page_number = (page_idx + 1) as u32;
let page_alt_texts = alt_text_by_page.get(&(page_idx as u32));
match max_images_per_page.map(|n| n as usize) {
Some(limit) => outcomes_from_capped_page(doc, page_idx, limit, page_number, page_alt_texts),
None => outcomes_from_uncapped_page(doc, page_idx, page_number, page_alt_texts, ocr_skip_candidate_pages),
}
}
fn outcomes_from_capped_page(
doc: &NativeDocument,
page_idx: usize,
limit: usize,
page_number: u32,
page_alt_texts: Option<&Vec<Option<String>>>,
) -> Vec<PageImageOutcome> {
let handle_images = match extract_n_images_from_page_handles(doc, page_idx, limit) {
Ok(images) => images,
Err(error) => {
tracing::debug!(
page = page_idx,
"capped image-handle extraction failed; falling back to eager extraction: {error}"
);
Vec::new()
}
};
let native_images = if !handle_images.is_empty() {
handle_images
} else {
match doc.doc.extract_images(page_idx) {
Ok(imgs) => imgs.into_iter().take(limit).collect(),
Err(e) => {
tracing::debug!(
page = page_idx,
"xberg_native_pdf: failed to extract images (fallback): {e}"
);
return Vec::new();
}
}
};
decoded_images_to_outcomes(native_images, page_number, page_alt_texts)
}
fn outcomes_from_uncapped_page(
doc: &NativeDocument,
page_idx: usize,
page_number: u32,
page_alt_texts: Option<&Vec<Option<String>>>,
ocr_skip_candidate_pages: &std::collections::HashMap<u32, (f64, f64)>,
) -> Vec<PageImageOutcome> {
let handles = match doc.doc.page_image_handles(page_idx) {
Ok(h) => h,
Err(error) => {
tracing::debug!(
page = page_idx,
"failed to enumerate image handles; falling back to eager extraction: {error}"
);
Vec::new()
}
};
if handles.is_empty() {
return match doc.doc.extract_images(page_idx) {
Ok(imgs) => decoded_images_to_outcomes(imgs, page_number, page_alt_texts),
Err(e) => {
tracing::debug!(page = page_idx, "xberg_native_pdf: failed to extract images: {e}");
Vec::new()
}
};
}
let page_dimensions = ocr_skip_candidate_pages.get(&page_number).copied();
let mut outcomes = Vec::with_capacity(handles.len());
for (page_image_position, handle) in handles.iter().enumerate() {
let alt_text = page_alt_texts
.and_then(|alts| alts.get(page_image_position))
.and_then(|alt| alt.clone());
if page_dimensions.is_some_and(|(width, height)| covers_full_page(&handle.bbox, width, height)) {
outcomes.push(skipped_image_outcome(handle, page_number, alt_text));
continue;
}
match handle.decode() {
Ok(native_img) => outcomes.push(image_outcome_from_decoded(&native_img, page_number, alt_text)),
Err(error) => {
tracing::debug!(page = page_idx, "image decompression failed: {error}");
}
}
}
outcomes
}
fn decoded_images_to_outcomes(
native_images: Vec<xberg_native_pdf::extractors::PdfImage>,
page_number: u32,
page_alt_texts: Option<&Vec<Option<String>>>,
) -> Vec<PageImageOutcome> {
let mut outcomes = Vec::with_capacity(native_images.len());
for (page_image_position, native_img) in native_images.iter().enumerate() {
let alt_text = page_alt_texts
.and_then(|alts| alts.get(page_image_position))
.and_then(|alt| alt.clone());
outcomes.push(image_outcome_from_decoded(native_img, page_number, alt_text));
}
outcomes
}
#[cfg(test)]
static PAGE_CALL_THREAD_NAMES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
#[cfg(test)]
fn page_call_thread_names() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
PAGE_CALL_THREAD_NAMES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
#[cfg(test)]
fn record_page_thread() {
let current = std::thread::current();
let name = current.name().unwrap_or("<unnamed>");
let mut names = page_call_thread_names()
.lock()
.expect("page-thread record must not be poisoned");
if !names.contains(name) {
names.insert(name.to_owned());
}
}
#[cfg(test)]
mod tests;