#[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 {
use super::*;
use crate::cancellation::CancellationToken;
use std::path::PathBuf;
const PNG_MAGIC: &[u8] = b"\x89PNG";
#[test]
fn test_raw_pixels_to_png_grayscale() {
let pixels: Vec<u8> = vec![0x00, 0x80, 0xc0, 0xff];
let result = raw_pixels_to_png(2, 2, &xberg_native_pdf::extractors::PixelFormat::Grayscale, &pixels);
let bytes = result.expect("grayscale 2×2 must encode without error");
assert!(
bytes.starts_with(PNG_MAGIC),
"output must be a PNG; got {:02x?}",
&bytes[..4.min(bytes.len())]
);
}
#[test]
fn test_raw_pixels_to_png_rgb() {
let pixels: Vec<u8> = vec![0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff];
let result = raw_pixels_to_png(2, 2, &xberg_native_pdf::extractors::PixelFormat::RGB, &pixels);
let bytes = result.expect("RGB 2×2 must encode without error");
assert!(
bytes.starts_with(PNG_MAGIC),
"output must be a PNG; got {:02x?}",
&bytes[..4.min(bytes.len())]
);
}
#[test]
fn test_raw_pixels_to_png_cmyk_converts_to_rgb_png() {
let pixels: Vec<u8> = vec![0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00];
let result = raw_pixels_to_png(1, 2, &xberg_native_pdf::extractors::PixelFormat::CMYK, &pixels);
let bytes = result.expect("CMYK 1×2 must encode without error");
assert!(
bytes.starts_with(PNG_MAGIC),
"output must be a PNG; got {:02x?}",
&bytes[..4.min(bytes.len())]
);
let decoded = image::load_from_memory(&bytes).expect("decoded PNG must be valid");
assert_eq!(decoded.width(), 1);
assert_eq!(decoded.height(), 2);
}
#[test]
fn test_raw_pixels_to_png_size_mismatch_returns_error() {
let pixels: Vec<u8> = vec![0x00, 0x80, 0xc0, 0xff];
let result = raw_pixels_to_png(4, 4, &xberg_native_pdf::extractors::PixelFormat::Grayscale, &pixels);
assert!(
result.is_err(),
"mismatched buffer size must return Err, not Ok or panic"
);
}
#[test]
fn test_raw_pixels_to_png_rgb_size_mismatch_returns_error() {
let pixels: Vec<u8> = vec![0xff; 9];
let result = raw_pixels_to_png(2, 2, &xberg_native_pdf::extractors::PixelFormat::RGB, &pixels);
assert!(result.is_err(), "mismatched RGB buffer must return Err");
}
#[test]
fn test_raw_pixels_to_png_cmyk_odd_length_returns_error() {
let pixels: Vec<u8> = vec![0x00, 0x00, 0x00];
let result = raw_pixels_to_png(1, 1, &xberg_native_pdf::extractors::PixelFormat::CMYK, &pixels);
assert!(
result.is_err(),
"CMYK buffer whose length is not a multiple of 4 must return Err, not panic"
);
}
#[test]
fn test_raw_pixels_to_png_row_padded_rgb_returns_error_not_panic() {
let (width, height) = (3u32, 2u32);
let mut pixels = Vec::new();
for _ in 0..height {
pixels.extend_from_slice(&[0xff; 9]);
pixels.push(0x00);
}
let result = raw_pixels_to_png(width, height, &xberg_native_pdf::extractors::PixelFormat::RGB, &pixels);
assert!(
result.is_err(),
"a row-padded RGB buffer must return Err, not panic in the PNG encoder"
);
}
#[test]
fn test_raw_pixels_to_png_row_padded_grayscale_returns_error_not_panic() {
let (width, height) = (3u32, 2u32);
let mut pixels = Vec::new();
for _ in 0..height {
pixels.extend_from_slice(&[0x80; 3]);
pixels.push(0x00);
}
let result = raw_pixels_to_png(
width,
height,
&xberg_native_pdf::extractors::PixelFormat::Grayscale,
&pixels,
);
assert!(
result.is_err(),
"a row-padded grayscale buffer must return Err, not panic in the PNG encoder"
);
}
#[test]
fn test_raw_pixels_to_png_row_padded_cmyk_returns_error_not_panic() {
let (width, height) = (3u32, 2u32);
let mut pixels = Vec::new();
for _ in 0..height {
for _ in 0..=width {
pixels.extend_from_slice(&[0x00, 0x00, 0x00, 0xff]);
}
}
let result = raw_pixels_to_png(width, height, &xberg_native_pdf::extractors::PixelFormat::CMYK, &pixels);
assert!(
result.is_err(),
"a row-padded CMYK buffer must return Err, not panic in the PNG encoder"
);
}
#[test]
fn test_unencodable_image_warning_names_index_and_page() {
let pixels: Vec<u8> = vec![0x00, 0x80, 0xc0, 0xff];
let error = raw_pixels_to_png(4, 4, &xberg_native_pdf::extractors::PixelFormat::Grayscale, &pixels)
.expect_err("4x4 grayscale from a 4-byte buffer must fail to re-encode");
let warning = unencodable_image_warning(3, 2, &error);
assert_eq!(warning.source.as_ref(), "pdf_images");
assert_eq!(
warning.message.as_ref(),
format!("skipped image 3 on page 2: could not be re-encoded from raw pixel data ({error})"),
"warning message must name the exact image index (3) and page (2)"
);
}
fn test_documents_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("test_documents")
}
#[test]
fn test_max_images_per_page_zero_returns_immediately() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
assert!(
pdf_path.exists(),
"missing fixture: test PDF not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let mut doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let (result, _warnings) = extract_images_with_data(&mut doc, Some(0), None, &std::collections::HashMap::new())
.expect("cap=0 must not error");
assert!(
result.is_empty(),
"max_images_per_page=Some(0) must return empty without decompressing any page; \
got {} image(s)",
result.len()
);
}
#[test]
fn test_cancellation_fires_between_pages() {
let pdf_path = test_documents_dir().join("pdf/nougat_039.pdf");
assert!(
pdf_path.exists(),
"missing fixture: nougat_039.pdf not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let mut doc_full = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let (full_result, _warnings) =
extract_images_with_data(&mut doc_full, None, None, &std::collections::HashMap::new())
.expect("uncancelled extraction must not error");
let full_count = full_result.len();
let page_count = doc_full
.doc
.page_count()
.expect("page_count must succeed on the fixture");
if page_count <= 1 || full_count == 0 {
eprintln!(
"SKIP test_cancellation_fires_between_pages: nougat_039.pdf has {} page(s) \
and {} extractable images — need ≥2 pages with images",
page_count, full_count
);
return;
}
let mut doc_cancel = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let token = CancellationToken::new();
let token_clone = token.clone();
let handle = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(20));
token_clone.cancel();
});
let (result, _warnings) =
extract_images_with_data(&mut doc_cancel, None, Some(&token), &std::collections::HashMap::new())
.expect("cancellation must not error");
handle.join().expect("background thread must not panic");
assert!(
token.is_cancelled(),
"token must be cancelled after background thread fires"
);
assert!(
result.len() <= full_count,
"cancelled extraction returned {} image(s); uncancelled returned {}; \
cancellation must never exceed the full count",
result.len(),
full_count
);
}
#[test]
fn test_cancellation_stops_extraction_early() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
assert!(
pdf_path.exists(),
"missing fixture: test PDF not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let mut doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let token = CancellationToken::new();
token.cancel();
let (result, _warnings) =
extract_images_with_data(&mut doc, None, Some(&token), &std::collections::HashMap::new())
.expect("extract must not error");
assert!(
result.is_empty(),
"pre-cancelled token must cause extraction to return empty vec immediately, \
got {} image(s)",
result.len()
);
}
#[test]
fn test_extract_images_with_data_default_path_populates_bounding_box() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
assert!(
pdf_path.exists(),
"missing fixture: test PDF not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let mut doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let (result, _warnings) = extract_images_with_data(&mut doc, None, None, &std::collections::HashMap::new())
.expect("extraction must not error");
assert!(!result.is_empty(), "fixture must contain at least one image");
assert!(
result.iter().all(|img| img.bounding_box.is_some()),
"every image extracted via the default (uncapped) path must carry a bounding_box \
from xberg_native_pdf's CTM-tracked extract_images(); got: {:?}",
result.iter().map(|img| img.bounding_box).collect::<Vec<_>>()
);
}
#[test]
fn test_extract_images_with_data_capped_path_preserves_bounding_box() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
assert!(
pdf_path.exists(),
"missing fixture: test PDF not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let mut doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let (result, _warnings) = extract_images_with_data(&mut doc, Some(50), None, &std::collections::HashMap::new())
.expect("extraction must not error");
assert!(!result.is_empty(), "fixture must contain at least one image");
assert!(
result.iter().all(|img| img.bounding_box.is_some()),
"the capped image-handle path must preserve CTM-derived bounding boxes; \
got: {:?}",
result.iter().map(|img| img.bounding_box).collect::<Vec<_>>()
);
}
#[test]
fn test_detect_image_format_from_bytes() {
let jpeg_data = b"\xff\xd8\xff\xe0\x00\x10JFIF";
assert_eq!(detect_image_format_from_bytes(jpeg_data), "jpeg");
let png_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
assert_eq!(detect_image_format_from_bytes(png_data), "png");
let gif_data = b"GIF89a";
assert_eq!(detect_image_format_from_bytes(gif_data), "gif");
let tiff_le = b"II\x2a\x00";
assert_eq!(detect_image_format_from_bytes(tiff_le), "tiff");
let tiff_be = b"MM\x00\x2a";
assert_eq!(detect_image_format_from_bytes(tiff_be), "tiff");
let bmp_data = b"BM\x00\x00\x00";
assert_eq!(detect_image_format_from_bytes(bmp_data), "bmp");
let jp2_data = b"\x00\x00\x00\x0cjP ";
assert_eq!(detect_image_format_from_bytes(jp2_data), "jpeg2000");
let raw_data = b"\x00\x01\x02\x03\x04\x05";
assert_eq!(detect_image_format_from_bytes(raw_data), "raw");
assert_eq!(detect_image_format_from_bytes(b""), "raw");
let incomplete = b"\xff\xd8";
assert_eq!(detect_image_format_from_bytes(incomplete), "raw");
}
#[cfg(feature = "ocr")]
#[test]
fn test_page_ocr_fallback_image_bytes_recovers_real_image() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
assert!(
pdf_path.exists(),
"missing fixture: test PDF not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let fallback_images = page_ocr_fallback_image_bytes(&doc.doc, 0);
assert!(
!fallback_images.is_empty(),
"fixture page 0 must contain at least one recoverable image XObject"
);
for fallback in &fallback_images {
let format = detect_image_format_from_bytes(&fallback.bytes);
assert!(
matches!(format, "jpeg" | "png" | "jpeg2000"),
"fallback image bytes must carry a recognizable magic (jpeg/png/jpeg2000); got {:02x?}",
&fallback.bytes[..8.min(fallback.bytes.len())]
);
assert_eq!(
fallback.format, format,
"reported format must match the recovered bytes' actual magic"
);
}
}
#[cfg(feature = "ocr")]
#[test]
fn should_report_recovery_mode_for_each_recovered_xobject() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let fallback_images = page_ocr_fallback_image_bytes(&doc.doc, 0);
let first = fallback_images
.first()
.expect("fixture page 0 must contain at least one recoverable image XObject");
assert_eq!(first.recovery, XObjectRecovery::EmbeddedJpeg);
assert_eq!(first.recovery.as_str(), "embedded_jpeg");
assert_eq!(first.format, "jpeg");
}
#[cfg(feature = "ocr")]
#[test]
fn test_page_ocr_fallback_image_bytes_out_of_range_page_returns_empty() {
let pdf_path = test_documents_dir().join("pdf/embedded_images_tables.pdf");
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let fallback_images = page_ocr_fallback_image_bytes(&doc.doc, 9999);
assert!(
fallback_images.is_empty(),
"out-of-range page must degrade to an empty vec, not panic or error"
);
}
#[cfg(feature = "ocr")]
#[test]
fn should_recover_dimension_bounded_high_ratio_ocr_image() {
let pdf_path = test_documents_dir().join("pdf/ocr_test.pdf");
assert!(pdf_path.exists(), "OCR regression fixture must exist");
let bytes = std::fs::read(&pdf_path).expect("read OCR regression fixture");
let doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("open OCR regression fixture");
let recovered = page_ocr_fallback_image_bytes(&doc.doc, 0);
let image = recovered.first().expect("recover the scanned page image for OCR");
let decoded = image::load_from_memory(&image.bytes).expect("decode recovered OCR image");
assert_eq!(decoded.width(), 4960);
assert_eq!(decoded.height(), 7016);
}
#[test]
fn test_extract_images_with_data_reads_tagged_pdf_alt_text() {
let pdf_path = test_documents_dir().join("pdf/nougat_049.pdf");
assert!(
pdf_path.exists(),
"missing fixture: test PDF not found at {}",
pdf_path.display()
);
let bytes = std::fs::read(&pdf_path).expect("failed to read test PDF");
let mut doc = crate::pdf::native::NativeDocument::open_bytes(&bytes).expect("failed to open PDF");
let (result, _warnings) = extract_images_with_data(&mut doc, None, None, &std::collections::HashMap::new())
.expect("extraction must not error");
let page_one_images: Vec<_> = result.iter().filter(|img| img.page_number == Some(1)).collect();
assert!(
!page_one_images.is_empty(),
"fixture page 1 must contain at least one extracted image"
);
assert_eq!(
page_one_images[0].description,
Some("officeArt object".to_string()),
"the first image on page 1 must carry the /Alt text from its Figure structure \
element instead of None; got {:?}",
page_one_images[0].description
);
}
}