use crate::Result;
use crate::core::diagnostics::{push_warning_deduped, warning};
use crate::error::XbergError;
use crate::types::ProcessingWarning;
use std::cell::RefCell;
use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};
const PDF_RENDER_WARNING_SOURCE: &str = "pdf-render";
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
const ROTATED_PNG_ENCODE_BYTES_PER_PIXEL: u64 = 4;
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
const ROTATED_PNG_ENCODE_FIXED_BYTES: u64 = 256 * 1024;
thread_local! {
static ENGINE_LOG_CAPTURE: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
static ENGINE_PENDING_WARNINGS: RefCell<Vec<ProcessingWarning>> = const { RefCell::new(Vec::new()) };
}
static ENGINE_LOGGER_INIT: Once = Once::new();
static ENGINE_CAPTURE_ACTIVE: AtomicBool = AtomicBool::new(false);
pub const ENGINE_LOG_TARGET_ROOT: &str = xberg_native_pdf::LOG_TARGET_ROOT;
fn is_pdf_engine_target(target: &str) -> bool {
target.starts_with(ENGINE_LOG_TARGET_ROOT)
}
struct EngineWarningCapture;
impl EngineWarningCapture {
fn interested(metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() <= tracing::Level::WARN && is_pdf_engine_target(metadata.target())
}
}
#[derive(Default)]
struct MessageVisitor(String);
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.0 = value.to_string();
}
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for EngineWarningCapture {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
if !Self::interested(event.metadata()) {
return;
}
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
ENGINE_LOG_CAPTURE.with(|cell| {
if let Some(buffer) = cell.borrow_mut().as_mut() {
buffer.push(visitor.0);
}
});
}
}
#[cfg_attr(alef, alef(skip))]
pub fn glyph_drop_capture_layer<S: tracing::Subscriber>() -> impl tracing_subscriber::Layer<S> {
ENGINE_CAPTURE_ACTIVE.store(true, Ordering::Release);
EngineWarningCapture
}
pub fn install_pdf_render_diagnostics() -> bool {
ENGINE_LOGGER_INIT.call_once(|| {
use tracing_subscriber::layer::SubscriberExt as _;
let subscriber = tracing_subscriber::registry().with(EngineWarningCapture);
if tracing::subscriber::set_global_default(subscriber).is_ok() {
ENGINE_CAPTURE_ACTIVE.store(true, Ordering::Release);
tracing::callsite::rebuild_interest_cache();
}
});
ENGINE_CAPTURE_ACTIVE.load(Ordering::Acquire)
}
fn glyph_drop_warning(page_index: usize, cause: &str) -> ProcessingWarning {
warning(
PDF_RENDER_WARNING_SOURCE,
format!(
"Page {} rendering could not paint one or more glyphs and continued anyway \
(advance-only, so layout is preserved but the glyph ink is missing): {cause}",
page_index + 1
),
)
}
fn indicates_unrenderable_image(cause: &str) -> bool {
cause.contains("unrenderable image XObject") || cause.contains("unrenderable ImageMask XObject")
}
fn image_render_failure_warning(page_index: usize, cause: &str) -> ProcessingWarning {
warning(
PDF_RENDER_WARNING_SOURCE,
format!(
"Page {} could not rasterize one or more images and left that region blank: {cause}",
page_index + 1
),
)
}
fn render_page_capturing_glyph_drops(
page_index: usize,
render: impl FnOnce() -> std::result::Result<xberg_native_pdf::rendering::RenderedImage, xberg_native_pdf::Error>,
) -> std::result::Result<xberg_native_pdf::rendering::RenderedImage, xberg_native_pdf::Error> {
if !ENGINE_CAPTURE_ACTIVE.load(Ordering::Acquire) {
return render();
}
ENGINE_LOG_CAPTURE.with(|cell| *cell.borrow_mut() = Some(Vec::new()));
let result = render();
let captured = ENGINE_LOG_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
if !captured.is_empty() {
ENGINE_PENDING_WARNINGS.with(|pending| {
let mut pending = pending.borrow_mut();
for cause in captured.iter() {
let processing_warning = if indicates_unrenderable_image(cause) {
image_render_failure_warning(page_index, cause)
} else {
glyph_drop_warning(page_index, cause)
};
push_warning_deduped(&mut pending, processing_warning);
}
});
}
result
}
pub fn take_xberg_native_pdf_render_warnings() -> Vec<ProcessingWarning> {
ENGINE_PENDING_WARNINGS.with(|pending| std::mem::take(&mut *pending.borrow_mut()))
}
const MAX_RENDER_DIMENSION_PX: f32 = 16384.0;
fn choose_safe_dpi(w_pt: f32, h_pt: f32, base_dpi: u32) -> u32 {
if w_pt <= 0.0 || h_pt <= 0.0 {
return base_dpi.max(72);
}
let scale = base_dpi as f32 / 72.0;
let w_px = w_pt * scale;
let h_px = h_pt * scale;
let max_dim = w_px.max(h_px);
if max_dim <= MAX_RENDER_DIMENSION_PX {
return base_dpi;
}
let factor = MAX_RENDER_DIMENSION_PX / max_dim;
(base_dpi as f32 * factor).max(72.0) as u32
}
pub(crate) fn get_page_dimensions_pt(doc: &xberg_native_pdf::PdfDocument, page_index: usize) -> (f32, f32) {
doc.get_page_media_box(page_index)
.map(|(llx, lly, urx, ury)| ((urx - llx).abs(), (ury - lly).abs()))
.unwrap_or((612.0, 792.0))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn rendered_page_dpi(rendered_width_px: u32, page_width_pt: f32) -> Option<f64> {
const POINTS_PER_INCH: f64 = 72.0;
let page_width_pt = f64::from(page_width_pt);
if rendered_width_px == 0 || !page_width_pt.is_finite() || page_width_pt <= 0.0 {
return None;
}
Some(f64::from(rendered_width_px) * POINTS_PER_INCH / page_width_pt)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn pixel_bbox_to_pdf_points(
bbox: crate::types::BoundingBox,
rendered_w: u32,
rendered_h: u32,
page_w_pt: f32,
page_h_pt: f32,
) -> crate::types::BoundingBox {
if rendered_w == 0 || rendered_h == 0 {
return bbox;
}
let sx = page_w_pt as f64 / rendered_w as f64;
let sy = page_h_pt as f64 / rendered_h as f64;
let x_pts = [bbox.x0 * sx, bbox.x1 * sx];
let y_pts = [page_h_pt as f64 - bbox.y0 * sy, page_h_pt as f64 - bbox.y1 * sy];
crate::types::BoundingBox {
x0: x_pts[0].min(x_pts[1]),
y0: y_pts[0].min(y_pts[1]),
x1: x_pts[0].max(x_pts[1]),
y1: y_pts[0].max(y_pts[1]),
}
}
#[cfg(all(test, any(feature = "ocr", feature = "ocr-pipeline")))]
mod pixel_bbox_tests {
use super::pixel_bbox_to_pdf_points;
use crate::types::BoundingBox;
#[test]
fn scales_and_flips_y_to_bottom_left_origin() {
let px = BoundingBox {
x0: 127.5,
y0: 165.0,
x1: 255.0,
y1: 330.0,
};
let pt = pixel_bbox_to_pdf_points(px, 1275, 1650, 612.0, 792.0);
assert!((pt.x0 - 61.2).abs() < 1e-6);
assert!((pt.x1 - 122.4).abs() < 1e-6);
assert!((pt.y1 - (792.0 - 79.2)).abs() < 1e-6);
assert!((pt.y0 - (792.0 - 158.4)).abs() < 1e-6);
assert!(pt.y0 < pt.y1);
}
#[test]
fn adapts_to_reduced_render_dpi() {
let px = BoundingBox {
x0: 63.75,
y0: 82.5,
x1: 127.5,
y1: 165.0,
};
let pt = pixel_bbox_to_pdf_points(px, 637, 825, 612.0, 792.0);
assert!((pt.x0 - 61.25).abs() < 0.2);
assert!((pt.y1 - 712.8).abs() < 0.5);
}
#[test]
fn zero_dimension_render_returns_input() {
let px = BoundingBox {
x0: 1.0,
y0: 2.0,
x1: 3.0,
y1: 4.0,
};
assert_eq!(pixel_bbox_to_pdf_points(px, 0, 100, 612.0, 792.0), px);
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
pub(crate) fn get_page_rotations(doc: &xberg_native_pdf::PdfDocument, page_count: usize) -> Vec<u32> {
(0..page_count)
.map(|page_index| match doc.get_page_rotation(page_index) {
Ok(degrees) => degrees as u32,
Err(error) => {
tracing::warn!(
page = page_index + 1,
%error,
"could not resolve /Rotate for page; defaulting to 0 (no rotation)"
);
0
}
})
.collect()
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
pub(crate) fn get_page_rotations_from_bytes(content: &[u8], page_count: usize) -> Vec<u32> {
match xberg_native_pdf::PdfDocument::from_bytes(content.to_vec()) {
Ok(doc) => get_page_rotations(&doc, page_count),
Err(error) => {
tracing::warn!(%error, "failed to open PDF to read page rotations; assuming none");
vec![0; page_count]
}
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
pub(crate) fn rotate_dynamic_image(img: image::DynamicImage, rotation_degrees: u32) -> image::DynamicImage {
match rotation_degrees % 360 {
90 => img.rotate90(),
180 => img.rotate180(),
270 => img.rotate270(),
_ => img,
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
pub(crate) fn ocr_page_correction_degrees(rotation_degrees: u32) -> u32 {
(360 - rotation_degrees % 360) % 360
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[cfg(test)]
pub(crate) fn rotate_png_page_if_needed(
png_data: Vec<u8>,
width: u32,
height: u32,
rotation_degrees: u32,
) -> Result<(Vec<u8>, u32, u32)> {
rotate_png_page_if_needed_with_security_limits(
png_data,
width,
height,
rotation_degrees,
&crate::extractors::security::SecurityLimits::default(),
)
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
pub(crate) fn rotate_png_page_if_needed_with_security_limits(
png_data: Vec<u8>,
width: u32,
height: u32,
rotation_degrees: u32,
security_limits: &crate::extractors::security::SecurityLimits,
) -> Result<(Vec<u8>, u32, u32)> {
if rotation_degrees.is_multiple_of(360) {
return Ok((png_data, width, height));
}
let pixel_count = u64::from(width)
.checked_mul(u64::from(height))
.ok_or_else(|| crate::extraction::image_decode::image_dimension_error(width, height, u64::MAX, u64::MAX))?;
let additional_live_bytes = pixel_count
.checked_mul(3 + ROTATED_PNG_ENCODE_BYTES_PER_PIXEL)
.and_then(|bytes| bytes.checked_add(ROTATED_PNG_ENCODE_FIXED_BYTES))
.ok_or_else(|| crate::extraction::image_decode::image_dimension_error(width, height, u64::MAX, u64::MAX))?;
let rgb = crate::extraction::image_decode::decode_standard_rgb8_with_additional_live_bytes_and_security_limits(
&png_data,
security_limits,
additional_live_bytes,
)
.map_err(|error| match error {
error @ XbergError::Validation { .. } => error,
error => XbergError::Parsing {
message: format!("failed to decode rendered page for rotation correction: {error}"),
source: Some(Box::new(error)),
},
})?;
let img = image::DynamicImage::ImageRgb8(rgb);
let rotated = rotate_dynamic_image(img, rotation_degrees);
let (w, h) = (rotated.width(), rotated.height());
let mut buf = Vec::new();
rotated
.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.map_err(|e| XbergError::Parsing {
message: format!("failed to re-encode rotated page: {e}"),
source: None,
})?;
Ok((buf, w, h))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[cfg(test)]
pub(crate) fn normalize_rendered_page_for_ocr(
png_data: Vec<u8>,
width: u32,
height: u32,
rotation_degrees: u32,
) -> Result<(Vec<u8>, u32, u32)> {
rotate_png_page_if_needed(png_data, width, height, ocr_page_correction_degrees(rotation_degrees))
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
pub(crate) fn normalize_rendered_page_for_ocr_with_security_limits(
png_data: Vec<u8>,
width: u32,
height: u32,
rotation_degrees: u32,
security_limits: &crate::extractors::security::SecurityLimits,
) -> Result<(Vec<u8>, u32, u32)> {
rotate_png_page_if_needed_with_security_limits(
png_data,
width,
height,
ocr_page_correction_degrees(rotation_degrees),
security_limits,
)
}
fn guard_render_panic(
page_index: usize,
render: impl FnOnce() -> std::result::Result<xberg_native_pdf::rendering::RenderedImage, xberg_native_pdf::Error>,
) -> std::result::Result<xberg_native_pdf::rendering::RenderedImage, xberg_native_pdf::Error> {
super::native::guard_native_panic(render, |message| {
xberg_native_pdf::Error::InvalidPdf(format!(
"page {} could not be rasterized: the rasterizer panicked and was contained ({message})",
page_index + 1
))
})
}
pub(crate) fn render_page_with_safeguards(
doc: &xberg_native_pdf::PdfDocument,
page_index: usize,
base_dpi: u32,
) -> std::result::Result<xberg_native_pdf::rendering::RenderedImage, xberg_native_pdf::Error> {
let (w_pt, h_pt) = get_page_dimensions_pt(doc, page_index);
let safe_dpi = choose_safe_dpi(w_pt, h_pt, base_dpi);
if safe_dpi != base_dpi {
tracing::warn!(
page = page_index + 1,
original_dpi = base_dpi,
effective_dpi = safe_dpi,
width_pt = w_pt,
height_pt = h_pt,
"reducing render DPI for page due to extreme dimensions (wide vector-heavy PDF or similar)"
);
}
let options = xberg_native_pdf::rendering::RenderOptions::with_dpi(safe_dpi);
render_page_capturing_glyph_drops(page_index, || {
guard_render_panic(page_index, || {
xberg_native_pdf::rendering::render_page(doc, page_index, &options)
})
})
}
pub(crate) fn open_pdf_document(pdf_bytes: &[u8], password: Option<&str>) -> Result<xberg_native_pdf::PdfDocument> {
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf_bytes.to_vec()).map_err(|e| XbergError::Parsing {
message: format!("Failed to open PDF: {e}"),
source: None,
})?;
if let Some(pwd) = password {
doc.authenticate(pwd.as_bytes()).map_err(|e| XbergError::Parsing {
message: format!("Failed to authenticate PDF: {e}"),
source: None,
})?;
}
Ok(doc)
}
pub(crate) fn document_page_count(doc: &xberg_native_pdf::PdfDocument) -> Result<usize> {
doc.page_count().map_err(|e| XbergError::Parsing {
message: format!("Failed to read page count: {e}"),
source: None,
})
}
pub(crate) fn render_open_pdf_page_to_png(
doc: &xberg_native_pdf::PdfDocument,
page_index: usize,
dpi: Option<i32>,
) -> Result<Vec<u8>> {
let render_dpi = dpi.unwrap_or(150).max(1) as u32;
let rendered = render_page_with_safeguards(doc, page_index, render_dpi).map_err(|e| XbergError::Parsing {
message: format!("Failed to render page {page_index}: {e}"),
source: None,
})?;
Ok(rendered.data)
}
#[cfg_attr(alef, alef(skip))]
pub struct PdfRenderSession {
document: xberg_native_pdf::PdfDocument,
page_count: usize,
}
#[cfg_attr(alef, alef(skip))]
impl PdfRenderSession {
pub fn open(pdf_bytes: &[u8], password: Option<&str>) -> Result<Self> {
let document = open_pdf_document(pdf_bytes, password)?;
let page_count = document_page_count(&document)?;
Ok(Self { document, page_count })
}
#[must_use]
pub fn page_count(&self) -> usize {
self.page_count
}
pub fn render_page_to_png(&self, page_index: usize, dpi: Option<i32>) -> Result<Vec<u8>> {
if page_index >= self.page_count {
return Err(XbergError::Parsing {
message: format!(
"Page index {page_index} out of range (document has {} pages)",
self.page_count
),
source: None,
});
}
render_open_pdf_page_to_png(&self.document, page_index, dpi)
}
}
pub fn render_pdf_page_to_png(
pdf_bytes: &[u8],
page_index: usize,
dpi: Option<i32>,
password: Option<&str>,
) -> Result<Vec<u8>> {
PdfRenderSession::open(pdf_bytes, password)?.render_page_to_png(page_index, dpi)
}
pub fn pdf_page_count(pdf_bytes: &[u8], password: Option<&str>) -> Result<usize> {
Ok(PdfRenderSession::open(pdf_bytes, password)?.page_count())
}
#[cfg(all(test, feature = "pdf"))]
pub(crate) fn build_minimal_pdf_with_mediabox(w: f32, h: f32) -> Vec<u8> {
let mut buf = Vec::<u8>::new();
buf.extend_from_slice(b"%PDF-1.4\n");
let obj1_offset = buf.len();
buf.extend_from_slice(b"1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n");
let obj2_offset = buf.len();
buf.extend_from_slice(b"2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n");
let obj3_offset = buf.len();
let mb = format!("[0 0 {} {}]", w, h);
buf.extend_from_slice(format!("3 0 obj\n<</Type /Page /MediaBox {} /Parent 2 0 R>>\nendobj\n", mb).as_bytes());
let xref_offset = buf.len();
buf.extend_from_slice(b"xref\n");
buf.extend_from_slice(b"0 4\n");
buf.extend_from_slice(b"0000000000 65535 f \n");
buf.extend_from_slice(format!("{:010} 00000 n \n", obj1_offset).as_bytes());
buf.extend_from_slice(format!("{:010} 00000 n \n", obj2_offset).as_bytes());
buf.extend_from_slice(format!("{:010} 00000 n \n", obj3_offset).as_bytes());
buf.extend_from_slice(b"trailer\n<</Size 4 /Root 1 0 R>>\n");
buf.extend_from_slice(format!("startxref\n{}\n%%EOF\n", xref_offset).as_bytes());
buf
}
#[cfg(all(test, feature = "pdf"))]
pub(crate) fn build_dotsection_cff_pdf() -> Vec<u8> {
const CFF: &[u8] = include_bytes!("testdata/dotsection_test_font.cff");
const DIFFERENCES: &str = "[ 33 /exclam 44 /comma 46 /period 58 /colon 59 /semicolon \
63 /question 105 /i 106 /j 108 /l 110 /n ]";
let mut content = String::new();
for (codes, y) in [(DOTSECTION_ROW, 650u32), (CONTROL_ROW, 450u32)] {
for (k, code) in codes.iter().enumerate() {
let x = 72 + k * 72;
content.push_str(&format!("BT /F1 48 Tf {x} {y} Td <{code:02X}> Tj ET\n"));
}
}
let widths = (33..=110).map(|_| "500").collect::<Vec<_>>().join(" ");
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"
.to_vec(),
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /XbergDotsectionTest \
/FirstChar 33 /LastChar 110 /Widths [ {widths} ] \
/FontDescriptor 6 0 R /Encoding 7 0 R >>"
)
.into_bytes(),
{
let mut o = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
o.extend_from_slice(content.as_bytes());
o.extend_from_slice(b"endstream");
o
},
b"<< /Type /FontDescriptor /FontName /XbergDotsectionTest /Flags 32 \
/FontBBox [-200 -250 1000 1000] /ItalicAngle 0 /Ascent 800 \
/Descent -200 /CapHeight 700 /StemV 80 /FontFile3 8 0 R >>"
.to_vec(),
format!("<< /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences {DIFFERENCES} >>").into_bytes(),
{
let mut o = format!("<< /Subtype /Type1C /Length {} >>\nstream\n", CFF.len()).into_bytes();
o.extend_from_slice(CFF);
o.extend_from_slice(b"\nendstream");
o
},
];
let mut buf = Vec::<u8>::new();
buf.extend_from_slice(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
let mut offsets = Vec::with_capacity(objects.len());
for (i, body) in objects.iter().enumerate() {
offsets.push(buf.len());
buf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
buf.extend_from_slice(body);
buf.extend_from_slice(b"\nendobj\n");
}
let xref_offset = buf.len();
buf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
buf.extend_from_slice(b"0000000000 65535 f \n");
for off in offsets {
buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
buf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\n", objects.len() + 1).as_bytes());
buf.extend_from_slice(format!("startxref\n{xref_offset}\n%%EOF\n").as_bytes());
buf
}
#[cfg(all(test, feature = "pdf"))]
pub(crate) const DOTSECTION_ROW: &[u8] = &[105, 106, 46, 58, 59, 33, 63];
#[cfg(all(test, feature = "pdf"))]
pub(crate) const CONTROL_ROW: &[u8] = &[108, 110, 44];
#[cfg(all(test, feature = "pdf"))]
mod tests {
use super::*;
#[test]
fn test_choose_safe_dpi_normal_page_unchanged() {
let dpi = choose_safe_dpi(612.0, 792.0, 150);
assert_eq!(dpi, 150);
}
#[test]
fn test_choose_safe_dpi_extreme_wide_reduced() {
let dpi = choose_safe_dpi(20000.0, 200.0, 150);
assert_eq!(dpi, 72);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn should_derive_render_dpi_from_raster_width_and_mediabox() {
assert_eq!(rendered_page_dpi(1275, 612.0), Some(150.0));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn should_derive_reduced_render_dpi_when_safe_dpi_clamped_the_page() {
assert_eq!(rendered_page_dpi(20000, 20000.0), Some(72.0));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn should_return_none_when_page_box_or_raster_is_degenerate() {
assert_eq!(rendered_page_dpi(1275, 0.0), None);
assert_eq!(rendered_page_dpi(1275, -612.0), None);
assert_eq!(rendered_page_dpi(1275, f32::NAN), None);
assert_eq!(rendered_page_dpi(0, 612.0), None);
}
#[test]
fn test_render_pdf_page_to_png_very_wide_does_not_panic_or_hard_fail() {
let wide_pdf = build_minimal_pdf_with_mediabox(20000.0, 300.0);
let res = render_pdf_page_to_png(&wide_pdf, 0, None, None);
assert!(
res.is_ok(),
"wide page render should succeed thanks to safeguard, got: {:?}",
res.err()
);
}
#[test]
fn test_guard_render_panic_contains_panic_as_page_error() {
let message = match guard_render_panic(3, || panic!("simulated tiny-skia unwrap")) {
Ok(_) => panic!("panic must not escape the guard"),
Err(error) => error.to_string(),
};
assert!(
message.contains("page 4"),
"error should name the 1-based page: {message}"
);
assert!(
message.contains("simulated tiny-skia unwrap"),
"error should carry the panic message: {message}"
);
}
#[test]
fn test_pdf_page_count_single_page() {
let pdf = build_minimal_pdf_with_mediabox(612.0, 792.0);
let count = pdf_page_count(&pdf, None).expect("page count should succeed for a valid PDF");
assert_eq!(count, 1, "minimal single-page PDF must report 1 page");
}
#[test]
fn pdf_render_session_reports_count_and_renders_without_reopening() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PdfRenderSession>();
let pdf = build_minimal_pdf_with_mediabox(612.0, 792.0);
let session = PdfRenderSession::open(&pdf, None).expect("valid PDF should open");
assert_eq!(session.page_count(), 1);
let png = session
.render_page_to_png(0, Some(72))
.expect("open session should render its page");
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
let error = session
.render_page_to_png(1, Some(72))
.expect_err("out-of-range page must fail");
assert!(error.to_string().contains("document has 1 pages"));
}
#[test]
fn test_pdf_page_count_invalid_pdf_errors() {
let err = pdf_page_count(b"not a pdf", None).expect_err("invalid PDF bytes must error");
assert!(
matches!(err, XbergError::Parsing { .. }),
"expected a Parsing error, got: {err:?}"
);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn test_rotate_dynamic_image_0_degrees_is_noop() {
let img = image::DynamicImage::new_rgb8(100, 150);
let rotated = rotate_dynamic_image(img, 0);
assert_eq!((rotated.width(), rotated.height()), (100, 150));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn test_rotate_dynamic_image_90_degrees_swaps_dimensions() {
let img = image::DynamicImage::new_rgb8(100, 150);
let rotated = rotate_dynamic_image(img, 90);
assert_eq!((rotated.width(), rotated.height()), (150, 100));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn test_rotate_dynamic_image_180_degrees_keeps_dimensions() {
let img = image::DynamicImage::new_rgb8(100, 150);
let rotated = rotate_dynamic_image(img, 180);
assert_eq!((rotated.width(), rotated.height()), (100, 150));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline"))]
#[test]
fn test_rotate_dynamic_image_270_degrees_swaps_dimensions() {
let img = image::DynamicImage::new_rgb8(100, 150);
let rotated = rotate_dynamic_image(img, 270);
assert_eq!((rotated.width(), rotated.height()), (150, 100));
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn rotated_png_rejects_request_limit_before_peak_allocation() {
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 255, 255]));
let mut encoded = Vec::new();
image::DynamicImage::ImageRgb8(image)
.write_to(&mut std::io::Cursor::new(&mut encoded), image::ImageFormat::Png)
.expect("encode rotation fixture");
let limits = crate::extractors::security::SecurityLimits {
max_content_size: 1_000,
..Default::default()
};
let error = rotate_png_page_if_needed_with_security_limits(encoded, 2, 2, 90, &limits)
.expect_err("rotation, PNG encoder workspace, and output must honor request limits");
assert!(matches!(error, XbergError::Validation { .. }));
}
fn dark_pixels_in_cell(img: &image::GrayImage, cell: usize, baseline_pt: f32) -> u32 {
const SCALE: f32 = 150.0 / 72.0;
const PAGE_H_PT: f32 = 792.0;
let x0 = (((72 + cell * 72) as f32 - 4.0) * SCALE).max(0.0) as u32;
let x1 = ((((72 + cell * 72) + 56) as f32) * SCALE).min(img.width() as f32) as u32;
let y0 = ((PAGE_H_PT - (baseline_pt + 40.0)) * SCALE).max(0.0) as u32;
let y1 = ((PAGE_H_PT - (baseline_pt - 14.0)) * SCALE).min(img.height() as f32) as u32;
let mut dark = 0u32;
for y in y0..y1 {
for x in x0..x1 {
if img.get_pixel(x, y).0[0] < 128 {
dark += 1;
}
}
}
dark
}
#[test]
fn test_render_paints_cff_glyphs_that_use_dotsection() {
let names_row1 = ["i", "j", "period", "colon", "semicolon", "exclam", "question"];
let names_row2 = ["l", "n", "comma"];
let pdf = build_dotsection_cff_pdf();
let png = render_pdf_page_to_png(&pdf, 0, Some(150), None).expect("fixture page must render");
let img = image::load_from_memory(&png)
.expect("rendered PNG must decode")
.to_luma8();
for (names, codes, baseline) in [
(&names_row1[..], DOTSECTION_ROW, 650.0),
(&names_row2[..], CONTROL_ROW, 450.0),
] {
for (cell, name) in names.iter().enumerate() {
let dark = dark_pixels_in_cell(&img, cell, baseline);
assert!(
dark >= 3,
"glyph '{name}' (char code {}) rendered no ink: {dark} dark pixels in its cell; \
dotsection charstrings must produce outlines",
codes[cell],
);
}
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn ocr_page_correction_inverts_pdf_rotation() {
assert_eq!(ocr_page_correction_degrees(0), 0);
assert_eq!(ocr_page_correction_degrees(90), 270);
assert_eq!(ocr_page_correction_degrees(180), 180);
assert_eq!(ocr_page_correction_degrees(270), 90);
assert_eq!(ocr_page_correction_degrees(360), 0);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn rendered_page_ocr_normalization_applies_inverse_quarter_turns() {
let marker = image::Rgb([17, 31, 47]);
let mut source = image::RgbImage::new(3, 2);
source.put_pixel(0, 0, marker);
let mut encoded = std::io::Cursor::new(Vec::new());
source
.write_to(&mut encoded, image::ImageFormat::Png)
.expect("test image should encode");
let encoded = encoded.into_inner();
let cases = [
(0, (3, 2), (0, 0)),
(90, (2, 3), (0, 2)),
(180, (3, 2), (2, 1)),
(270, (2, 3), (1, 0)),
];
for (pdf_rotation, expected_dimensions, marker_position) in cases {
let (normalized, width, height) = normalize_rendered_page_for_ocr(encoded.clone(), 3, 2, pdf_rotation)
.expect("in-memory OCR normalization should succeed");
let image = image::load_from_memory(&normalized)
.expect("normalized PNG should decode")
.to_rgb8();
assert_eq!((width, height), expected_dimensions, "PDF rotation {pdf_rotation}");
assert_eq!(image.dimensions(), expected_dimensions, "PDF rotation {pdf_rotation}");
assert_eq!(
*image.get_pixel(marker_position.0, marker_position.1),
marker,
"PDF rotation {pdf_rotation}"
);
}
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn test_get_page_rotations_no_rotate_attribute_yields_zeroes() {
let pdf = build_minimal_pdf_with_mediabox(612.0, 792.0);
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf).expect("fixture PDF must open");
assert_eq!(get_page_rotations(&doc, 1), vec![0]);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
fn build_pdf_with_rotate(rotation: i64, inherited: bool) -> Vec<u8> {
use lopdf::{Document, Object, Stream, dictionary};
let mut document = Document::with_version("1.5");
let pages_id = document.new_object_id();
let page_id = document.new_object_id();
let content_id = document.add_object(Stream::new(dictionary! {}, Vec::new()));
let mut page = dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
"Resources" => dictionary! {},
"Contents" => content_id,
};
if !inherited {
page.set("Rotate", rotation);
}
document.objects.insert(page_id, Object::Dictionary(page));
let mut pages = dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
};
if inherited {
pages.set("Rotate", rotation);
}
document.objects.insert(pages_id, Object::Dictionary(pages));
let catalog_id = document.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
});
document.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
document.save_to(&mut bytes).expect("fixture PDF must serialize");
bytes
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn test_get_page_rotations_own_rotate_is_read() {
let pdf = build_pdf_with_rotate(90, false);
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf).expect("fixture PDF must open");
assert_eq!(get_page_rotations(&doc, 1), vec![90]);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn test_get_page_rotations_inherited_rotate_is_resolved_from_parent() {
let pdf = build_pdf_with_rotate(90, true);
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf).expect("fixture PDF must open");
assert_eq!(get_page_rotations(&doc, 1), vec![90]);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn test_get_page_rotations_negative_rotate_normalizes_to_positive_equivalent() {
let pdf = build_pdf_with_rotate(-90, false);
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf).expect("fixture PDF must open");
assert_eq!(get_page_rotations(&doc, 1), vec![270]);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn test_get_page_rotations_non_multiple_of_90_folds_to_zero() {
let pdf = build_pdf_with_rotate(135, false);
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf).expect("fixture PDF must open");
assert_eq!(get_page_rotations(&doc, 1), vec![0]);
}
#[cfg(any(feature = "ocr", feature = "ocr-pipeline", feature = "layout-detection"))]
#[test]
fn test_get_page_rotations_page_index_beyond_tree_defaults_to_zero() {
let pdf = build_minimal_pdf_with_mediabox(612.0, 792.0);
let doc = xberg_native_pdf::PdfDocument::from_bytes(pdf).expect("fixture PDF must open");
assert_eq!(get_page_rotations(&doc, 3), vec![0, 0, 0]);
}
#[test]
fn engine_log_targets_are_captured_under_both_the_current_and_former_crate_names() {
assert!(
is_pdf_engine_target(&format!("{ENGINE_LOG_TARGET_ROOT}::rendering::page_renderer")),
"the CURRENT engine crate name must be captured -- this is the assertion that was \
failing in production"
);
assert!(
is_pdf_engine_target(&format!("{ENGINE_LOG_TARGET_ROOT}::fonts")),
"explicit-target sites inside the engine must be captured too"
);
assert!(
is_pdf_engine_target(ENGINE_LOG_TARGET_ROOT),
"the FORMER crate name must stay accepted so a revert or a rename back is not a \
silent regression"
);
assert!(
!is_pdf_engine_target("tower::buffer::worker"),
"unrelated crates must not be captured: this sink owns the process's only log \
backend, so anything it accepts is diverted from every other consumer"
);
assert!(
!is_pdf_engine_target("xberg::extractors::pdf"),
"xberg's own records must not be captured"
);
}
#[test]
fn genuine_warning_sharing_the_old_substring_is_not_silently_excluded() {
assert!(
install_pdf_render_diagnostics(),
"no other component should own the tracing dispatcher in this test binary"
);
let _ = take_xberg_native_pdf_render_warnings();
let result = render_page_capturing_glyph_drops(0, || {
tracing::warn!(
target: "xberg_native_pdf::fonts",
"a genuine problem that happens to mention PDF spec compliant wording"
);
Ok(xberg_native_pdf::rendering::RenderedImage {
data: vec![0u8; 4],
width: 1,
height: 1,
format: xberg_native_pdf::rendering::ImageFormat::RawRgba8,
})
});
assert!(result.is_ok(), "capturing a warning must not change the render outcome");
let warnings = take_xberg_native_pdf_render_warnings();
assert_eq!(
warnings.len(),
1,
"a captured WARN-level engine event must be reported even when its text incidentally \
contains the retired Latin-1-fallback substring; got: {warnings:?}"
);
}
}