use super::{document, guarded, pages};
use crate::{Error, ResourceId, Result};
use hayro::{RenderSettings, hayro_interpret::InterpreterSettings, hayro_syntax::Pdf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
pub const RENDER_VERSION: &str =
"hayro-0.5.0;foxit-bundled;crop-rotate;white;edge-2048;uniform-8-0.01pct-v1;vello-png-v1";
const LONG_EDGE: u16 = 2048;
pub fn render_page(bytes: &[u8], source: &ResourceId, page: u32) -> Result<Vec<u8>> {
guarded(source, || PageRenderer::new(bytes, source)?.page(page))
}
pub(crate) struct PageRenderer {
pdf: Pdf,
source: ResourceId,
}
impl PageRenderer {
pub(crate) fn new(bytes: &[u8], source: &ResourceId) -> Result<Self> {
guarded(source, || {
let mut validated = document(bytes, source)?;
let expected = pages(&validated, source)?.len();
let mut normalized = Vec::new();
validated
.save_to(&mut normalized)
.map_err(|_| Error::PdfRender {
resource: source.clone(),
reason: "validated document could not be prepared for rendering",
})?;
drop(validated);
let pdf = Pdf::new(Arc::new(normalized)).map_err(|_| Error::PdfRender {
resource: source.clone(),
reason: "renderer could not load document",
})?;
if pdf.pages().len() != expected {
return Err(Error::PdfRender {
resource: source.clone(),
reason: "renderer page count disagrees with validated document",
});
}
Ok(Self {
pdf,
source: source.clone(),
})
})
}
pub(crate) fn page(&self, number: u32) -> Result<Vec<u8>> {
self.page_with_settings(number, InterpreterSettings::default())
}
fn page_with_settings(
&self,
number: u32,
mut settings: InterpreterSettings,
) -> Result<Vec<u8>> {
guarded(&self.source, || {
let failure = |reason| Error::PdfRender {
resource: self.source.clone(),
reason,
};
let index = number
.checked_sub(1)
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n < self.pdf.pages().len())
.ok_or_else(|| failure("page is out of bounds"))?;
let page = &self.pdf.pages()[index];
let crop = page.intersected_crop_box();
if !crop.width().is_finite()
|| !crop.height().is_finite()
|| crop.width() <= 0.0
|| crop.height() <= 0.0
{
return Err(failure("page crop must have a finite positive area"));
}
let (width, height) = page.render_dimensions();
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return Err(failure("page dimensions must be finite and positive"));
}
let scale = f32::from(LONG_EDGE) / width.max(height);
let (width, height) = ((width * scale).floor(), (height * scale).floor());
if !(1.0..=f32::from(LONG_EDGE)).contains(&width)
|| !(1.0..=f32::from(LONG_EDGE)).contains(&height)
{
return Err(failure("page aspect ratio exceeds raster limits"));
}
let warned = Arc::new(AtomicBool::new(false));
let sink = warned.clone();
settings.warning_sink = Arc::new(move |_| sink.store(true, Ordering::Relaxed));
let pixmap = hayro::render(
page,
&settings,
&RenderSettings {
x_scale: scale,
y_scale: scale,
width: Some(width as u16),
height: Some(height as u16),
bg_color: hayro::vello_cpu::color::palette::css::WHITE,
},
);
if warned.load(Ordering::Relaxed) {
return Err(failure("unsupported font or undecodable image"));
}
if has_image_xobjects(page.resources()) && near_uniform(pixmap.data_as_u8_slice()) {
return Err(failure("scanned page could not be rendered"));
}
pixmap
.into_png()
.map_err(|_| failure("PNG encoding failed"))
})
}
}
fn has_image_xobjects(resources: &hayro::hayro_syntax::page::Resources<'_>) -> bool {
let mut current = Some(resources);
while let Some(resource) = current {
if image_dictionary(&resource.x_objects, 0) {
return true;
}
current = resource.parent();
}
false
}
fn image_dictionary(dict: &hayro::hayro_syntax::object::Dict<'_>, depth: usize) -> bool {
use hayro::hayro_syntax::object::{Dict, Name, Stream};
dict.keys().any(|key| {
if depth >= 16 {
return true;
}
let Some(stream) = dict.get::<Stream<'_>>(key) else {
return true;
};
let dictionary = stream.dict();
match dictionary
.get::<Name<'_>>(b"Subtype".as_slice())
.as_ref()
.map(|n| n.as_str())
{
Some("Image") => true,
Some("Form") => dictionary
.get::<Dict<'_>>(b"Resources".as_slice())
.and_then(|r| r.get::<Dict<'_>>(b"XObject".as_slice()))
.is_some_and(|d| image_dictionary(&d, depth + 1)),
_ => true,
}
})
}
fn near_uniform(rgba: &[u8]) -> bool {
let mut low = [255u8; 3];
let mut high = [0u8; 3];
let mut nonwhite = 0usize;
for pixel in rgba.as_chunks::<4>().0.iter() {
for channel in 0..3 {
low[channel] = low[channel].min(pixel[channel]);
high[channel] = high[channel].max(pixel[channel]);
}
nonwhite += usize::from(pixel[..3].iter().any(|v| *v < 247));
}
(0..3).all(|i| high[i].saturating_sub(low[i]) <= 8) || nonwhite <= (rgba.len() / 4) / 10_000
}
#[cfg(test)]
#[path = "../../tests/support/scan_fixture.rs"]
mod fixture;
#[cfg(test)]
mod tests {
use super::*;
use hayro::vello_cpu::Pixmap;
fn source() -> ResourceId {
"mem://synthetic/render".parse().unwrap()
}
#[test]
fn crop_rotation_white_background_and_encoding_are_deterministic() {
for (extra, expected_size, red_point, digest) in [
(
"",
(1582, 2048),
(250, 1600),
"2205640e0711007be8194b79c8310430a13f0311fa51e330de870058378c87ec",
),
(
"/Rotate 90",
(2048, 1582),
(400, 250),
"f8bc63f5cd95aa694efc168c6d666501fd7c60d5fd10df0f52117d60ea86fd8a",
),
(
"/CropBox [40 60 400 600]",
(1365, 2048),
(200, 1600),
"04e4615cc61a271a5c53f780a618ba6e0310ed991b1cca2c632ce9630f5a2f68",
),
] {
let bytes = fixture::drawing(extra);
let png = render_page(&bytes, &source(), 1).unwrap();
assert_eq!(png, render_page(&bytes, &source(), 1).unwrap());
let pixmap = Pixmap::from_png(png.as_slice()).unwrap();
assert_eq!((pixmap.width(), pixmap.height()), expected_size);
let rgba = pixmap.data_as_u8_slice();
assert_eq!(&rgba[..4], &[255, 255, 255, 255]);
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3] == 255));
let offset = (red_point.1 * usize::from(pixmap.width()) + red_point.0) * 4;
assert_eq!(&rgba[offset..offset + 4], &[255, 0, 0, 255]);
assert_eq!(
blake3::hash(&png).to_hex().as_str(),
digest,
"bump RENDER_VERSION when changing raster/encoding goldens"
);
}
}
#[test]
fn renderer_unwind_is_contained_without_retaining_its_payload() {
let renderer = PageRenderer::new(&fixture::text(&["public text"]), &source()).unwrap();
let settings = InterpreterSettings {
font_resolver: Arc::new(|_| panic!("{}", crate::pdf::fixture::MARKER)),
..Default::default()
};
let result = renderer.page_with_settings(1, settings);
assert!(matches!(result, Err(Error::ParserPanicked { .. })));
assert!(
!result
.err()
.unwrap()
.to_string()
.contains(crate::pdf::fixture::MARKER)
);
}
#[test]
fn a_legitimately_empty_page_keeps_its_physical_number() {
use crate::pdf::fixture::{Kind, pdf};
let png = render_page(&pdf(&["valid", ""], Kind::EmptyPage), &source(), 2).unwrap();
let pixels = Pixmap::from_png(png.as_slice()).unwrap();
assert!(pixels.data_as_u8_slice().iter().all(|b| *b == 255));
}
#[test]
fn corrupted_stream_anywhere_rejects_selected_page_before_rendering() {
use crate::pdf::fixture::{Kind, pdf};
for kind in [
Kind::MissingContents,
Kind::MissingArrayContents,
Kind::TruncatedContents,
Kind::BadFlate,
] {
assert!(matches!(
render_page(&pdf(&["valid", "broken"], kind), &source(), 1),
Err(Error::PdfParse { .. })
));
}
}
#[test]
fn corrupt_image_omission_is_a_sanitized_render_diagnostic() {
let broken = fixture::broken_image(crate::pdf::fixture::MARKER);
let error = render_page(&broken, &source(), 1)
.expect_err("corrupt image must not become a blank scan hit");
assert!(matches!(
error,
Error::PdfRender {
reason: "scanned page could not be rendered",
..
}
));
assert!(!error.to_string().contains(crate::pdf::fixture::MARKER));
}
#[test]
fn dimensions_and_page_range_are_explicit_errors() {
let bytes = fixture::drawing("");
for page in [0, 2, u32::MAX] {
assert!(matches!(
render_page(&bytes, &source(), page),
Err(Error::PdfRender {
reason: "page is out of bounds",
..
})
));
}
let bad = fixture::drawing("/MediaBox [0 0 0 0]");
assert!(matches!(
render_page(&bad, &source(), 1),
Err(Error::PdfRender { .. })
));
}
}