pub mod color;
pub mod content;
pub mod font;
pub mod function;
pub mod geom;
pub mod image;
pub mod objects;
pub mod prepass;
use std::rc::Rc;
use ::image::RgbImage;
use lopdf::Document;
use crate::pdf_meta::PdfMeta;
use geom::Mat;
pub fn pixels_for_extent(extent: f64) -> u32 {
((extent - 1e-6).ceil().max(1.0)) as u32
}
pub struct Renderer<'a> {
meta: &'a PdfMeta,
shared: Rc<content::Shared>,
}
impl<'a> Renderer<'a> {
pub fn new(meta: &'a PdfMeta) -> Renderer<'a> {
Renderer {
meta,
shared: Rc::new(content::Shared::default()),
}
}
pub fn render_scaled(&self, index: usize, scale: f64) -> Option<RgbImage> {
let geom = self.meta.geometry(index)?;
let w = pixels_for_extent(f64::from(geom.width) * scale);
let h = pixels_for_extent(f64::from(geom.height) * scale);
self.render(index, w, h)
}
pub fn render(&self, index: usize, width: u32, height: u32) -> Option<RgbImage> {
if width == 0 || height == 0 || width > 1 << 15 || height > 1 << 15 {
return None;
}
let meta = self.meta;
let doc = meta.doc();
let pid = meta.page_id(index)?;
let page = doc.get_object(pid).ok()?.as_dict().ok()?;
let geom = meta.geometry(index)?;
let pb = crate::textparse::page_box(doc, pid);
let (cw, ch) = if geom.rotation == 90 || geom.rotation == 270 {
(height, width)
} else {
(width, height)
};
let sx = f64::from(cw) / f64::from(pb.w).max(1e-6);
let sy = f64::from(ch) / f64::from(pb.h).max(1e-6);
let base = Mat::new(
sx,
0.0,
0.0,
-sy,
-f64::from(pb.l) * sx,
f64::from(ch) + f64::from(pb.b) * sy,
);
let content = doc.get_page_content(pid);
let resources = page_resources(doc, pid);
let mut interp = content::Interp::new(doc, cw, ch, self.shared.clone())?;
crate::timing::timed("render.content", || {
interp.run_page(&content, resources, base);
interp.run_widgets(page, base);
});
let canvas = interp.into_canvas();
let rgb = crate::timing::timed("render.finish", || to_rgb(&canvas));
Some(rotate(rgb, geom.rotation))
}
}
pub fn render_page(meta: &PdfMeta, index: usize, scale: f64) -> Option<RgbImage> {
Renderer::new(meta).render_scaled(index, scale)
}
pub fn render_page_sized(
meta: &PdfMeta,
index: usize,
width: u32,
height: u32,
) -> Option<RgbImage> {
Renderer::new(meta).render(index, width, height)
}
fn page_resources(doc: &Document, pid: lopdf::ObjectId) -> Option<&lopdf::Dictionary> {
doc.get_page_resources(pid).ok().and_then(|(inline, ids)| {
inline.or_else(|| ids.into_iter().find_map(|id| doc.get_dictionary(id).ok()))
})
}
fn to_rgb(pm: &tiny_skia::Pixmap) -> RgbImage {
let (w, h) = (pm.width(), pm.height());
let mut out = RgbImage::new(w, h);
let data = pm.data();
for (i, px) in out.pixels_mut().enumerate() {
let a = u32::from(data[i * 4 + 3]);
let un = |c: u8| -> u8 {
if a == 0 {
255
} else if a == 255 {
c
} else {
let c = u32::from(c);
(c + (255 - a)).min(255) as u8
}
};
*px = ::image::Rgb([un(data[i * 4]), un(data[i * 4 + 1]), un(data[i * 4 + 2])]);
}
out
}
fn rotate(img: RgbImage, rotation: u16) -> RgbImage {
match rotation {
90 => ::image::imageops::rotate90(&img),
180 => ::image::imageops::rotate180(&img),
270 => ::image::imageops::rotate270(&img),
_ => img,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
#[test]
fn renders_the_corpus() {
let dir = root().join("tests/data/pdf/sources");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "pdf"))
.collect();
files.sort();
assert!(!files.is_empty());
for f in files {
let bytes = std::fs::read(&f).unwrap();
let Some(meta) = PdfMeta::open(&bytes) else {
continue;
};
let renderer = Renderer::new(&meta);
for i in 0..meta.page_count().min(3) {
let g = meta.geometry(i).unwrap();
let img = renderer
.render_scaled(i, 1.0)
.unwrap_or_else(|| panic!("{}: page {} declined", f.display(), i + 1));
assert_eq!(
img.width(),
pixels_for_extent(f64::from(g.width)),
"{}",
f.display()
);
assert_eq!(
img.height(),
pixels_for_extent(f64::from(g.height)),
"{}",
f.display()
);
let dark = img.pixels().filter(|p| p[0] < 200).count();
assert!(dark > 0, "{} p{}: blank render", f.display(), i + 1);
}
}
}
#[test]
fn against_the_docling_parse_shim() {
if std::env::var_os("DOCLING_PARSE_RENDER_LIB").is_none() {
let lib = root().join(".docling-parse/lib");
if lib.is_dir() {
std::env::set_var("DOCLING_PARSE_RENDER_LIB", &lib);
}
}
std::env::set_var("DOCLING_RS_RENDERER", "docling-parse");
let Some(plugin) = crate::dparse_render::plugin() else {
eprintln!("docling-parse shim not installed — skipping the renderer comparison");
return;
};
let _ = plugin;
let dir = root().join("tests/data/pdf/sources");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "pdf"))
.collect();
files.sort();
let mut worst = 0.0f64;
let mut total_mad = 0.0f64;
let mut n = 0usize;
for f in &files {
let bytes = std::fs::read(f).unwrap();
let Some(meta) = PdfMeta::open(&bytes) else {
continue;
};
let renderer = Renderer::new(&meta);
let Some(dp) = crate::dparse_render::Doc::open_if_enabled(&bytes, None) else {
continue;
};
for i in 0..meta.page_count() {
for scale in [1.0f64, 2.0] {
let Ok(want) = dp.render(i, scale, 1.0) else {
continue;
};
let Some(got) = renderer.render(i, want.width(), want.height()) else {
continue;
};
let mad = mean_abs_diff(&got, &want);
worst = worst.max(mad);
total_mad += mad;
n += 1;
eprintln!(
"{} p{} @{scale}: mean |Δ| = {mad:.2}",
f.file_name().unwrap().to_string_lossy(),
i + 1
);
}
dp.release_page(i);
}
}
if n > 0 {
eprintln!(
"renderer vs shim: {n} renders, mean |Δ| {:.2}, worst {worst:.2}",
total_mad / n as f64
);
}
assert!(
worst < 10.0,
"a render diverged from the shim (mean |Δ| {worst:.1})"
);
if n > 0 {
assert!(
total_mad / (n as f64) < 2.0,
"the corpus drifted from the shim"
);
}
}
fn mean_abs_diff(a: &RgbImage, b: &RgbImage) -> f64 {
if a.dimensions() != b.dimensions() {
return 255.0;
}
let sum: u64 = a
.as_raw()
.iter()
.zip(b.as_raw())
.map(|(x, y)| u64::from((i32::from(*x) - i32::from(*y)).unsigned_abs()))
.sum();
sum as f64 / a.as_raw().len().max(1) as f64
}
}
#[cfg(test)]
mod synthetic {
use super::*;
use lopdf::{dictionary, Dictionary, Object, Stream};
fn synth(
doc: &mut Document,
media: [f32; 4],
page_extra: Dictionary,
resources: Dictionary,
content: &str,
) -> Vec<u8> {
synth_bytes(doc, media, page_extra, resources, content.as_bytes())
}
fn synth_bytes(
doc: &mut Document,
media: [f32; 4],
page_extra: Dictionary,
resources: Dictionary,
content: &[u8],
) -> Vec<u8> {
let pages_id = doc.new_object_id();
let res_id = doc.add_object(resources);
let content_id = doc.add_object(Stream::new(Dictionary::new(), content.to_vec()));
let mut page = dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => media.iter().map(|&v| Object::Real(v)).collect::<Vec<_>>(),
"Contents" => Object::Reference(content_id),
"Resources" => Object::Reference(res_id),
};
for (k, v) in page_extra.into_iter() {
page.set(k, v);
}
let page_id = doc.add_object(page);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1,
}),
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let mut out = Vec::new();
doc.save_to(&mut out).unwrap();
out
}
fn page(resources: Dictionary, content: &str) -> Vec<u8> {
let mut doc = Document::with_version("1.5");
synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
resources,
content,
)
}
fn render(bytes: &[u8]) -> RgbImage {
let meta = PdfMeta::open(bytes).expect("object model");
render_page(&meta, 0, 1.0).expect("render")
}
fn px(img: &RgbImage, x: u32, y: u32) -> [u8; 3] {
img.get_pixel(x, y).0
}
fn near(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
a.iter()
.zip(b)
.all(|(&x, y)| (i32::from(x) - i32::from(y)).abs() <= tol)
}
const WHITE: [u8; 3] = [255, 255, 255];
const RED: [u8; 3] = [255, 0, 0];
fn ink_in(img: &RgbImage, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
let mut n = 0;
for y in y0..y1 {
for x in x0..x1 {
if px(img, x, y) != WHITE {
n += 1;
}
}
}
n
}
#[test]
fn fills_land_in_the_docling_parse_frame() {
let img = render(&page(Dictionary::new(), "1 0 0 rg 60 10 40 30 re f"));
assert_eq!((img.width(), img.height()), (200, 100));
assert_eq!(px(&img, 80, 75), RED);
assert_eq!(px(&img, 61, 61), RED);
assert_eq!(px(&img, 80, 55), WHITE);
assert_eq!(px(&img, 105, 75), WHITE);
assert_eq!(px(&img, 5, 5), WHITE);
}
#[test]
fn crop_box_is_the_canvas() {
let mut doc = Document::with_version("1.5");
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
dictionary! { "CropBox" => vec![50.into(), 0.into(), 150.into(), 100.into()] },
Dictionary::new(),
"1 0 0 rg 60 10 40 30 re f",
);
let img = render(&bytes);
assert_eq!((img.width(), img.height()), (100, 100));
assert_eq!(px(&img, 30, 75), RED);
assert_eq!(px(&img, 5, 75), WHITE);
assert_eq!(px(&img, 55, 75), WHITE);
}
#[test]
fn rotate_transposes_the_canvas() {
let mut doc = Document::with_version("1.5");
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
dictionary! { "Rotate" => 90 },
Dictionary::new(),
"1 0 0 rg 0 0 40 20 re f",
);
let img = render(&bytes);
assert_eq!((img.width(), img.height()), (100, 200));
assert_eq!(px(&img, 10, 20), RED);
assert_eq!(px(&img, 10, 45), WHITE);
assert_eq!(px(&img, 90, 190), WHITE);
let meta = PdfMeta::open(&bytes).unwrap();
let sized = render_page_sized(&meta, 0, 50, 100).unwrap();
assert_eq!((sized.width(), sized.height()), (50, 100));
assert_eq!(px(&sized, 5, 10), RED);
}
#[test]
fn hairline_strokes_are_one_pixel_wide() {
let img = render(&page(Dictionary::new(), "0 w 10 50.5 m 190 50.5 l S"));
assert!(ink_in(&img, 100, 49, 101, 51) >= 1);
assert_eq!(ink_in(&img, 100, 40, 101, 48), 0);
assert_eq!(ink_in(&img, 100, 52, 101, 60), 0);
let img = render(&page(Dictionary::new(), "4 w 10 50 m 190 50 l S"));
assert!(ink_in(&img, 100, 44, 101, 56) >= 4);
assert_eq!(ink_in(&img, 100, 40, 101, 47), 0);
}
#[test]
fn constant_alpha_blends_over_white() {
let res = dictionary! {
"ExtGState" => dictionary! {
"GS" => dictionary! { "ca" => 0.5, "CA" => 0.25 },
},
};
let img = render(&page(res, "/GS gs 0 g 20 20 160 60 re f"));
let p = px(&img, 100, 50);
assert!(near(p, [128, 128, 128], 2), "{p:?}");
}
#[test]
fn rect_clip_cuts_the_fill() {
let img = render(&page(
Dictionary::new(),
"q 0 0 100 100 re W n 0 0 1 rg 0 0 200 100 re f Q 0 0 200 10 re f",
));
assert_eq!(px(&img, 50, 50), [0, 0, 255]);
assert_eq!(px(&img, 150, 50), WHITE);
assert_eq!(px(&img, 150, 95), [0, 0, 0]);
}
#[test]
fn shape_clip_follows_the_path() {
let img = render(&page(
Dictionary::new(),
"q 0 0 m 200 0 l 100 100 l h W n 0 1 0 rg 0 0 200 100 re f Q",
));
assert_eq!(px(&img, 100, 50), [0, 255, 0]);
assert_eq!(px(&img, 100, 90), [0, 255, 0]);
assert_eq!(px(&img, 5, 5), WHITE);
assert_eq!(px(&img, 195, 5), WHITE);
}
#[test]
fn image_xobject_is_blitted_with_its_orientation() {
let data = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0];
let mut doc = Document::with_version("1.5");
let im = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject", "Subtype" => "Image",
"Width" => 2, "Height" => 2, "BitsPerComponent" => 8,
"ColorSpace" => "DeviceRGB",
},
data,
));
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
"q 200 0 0 100 0 0 cm /Im0 Do Q",
);
let img = render(&bytes);
assert!(near(px(&img, 50, 25), RED, 6));
assert!(near(px(&img, 150, 25), [0, 255, 0], 6));
assert!(near(px(&img, 50, 75), [0, 0, 255], 6));
assert!(near(px(&img, 150, 75), [0, 0, 0], 6));
}
#[test]
fn inline_image_and_stencil_mask() {
let mut content = b"q 100 0 0 100 0 0 cm BI /W 1 /H 1 /CS /G /BPC 8 ID ".to_vec();
content.push(0x80);
content.extend_from_slice(
b" EI Q q 1 0 0 rg 100 0 0 100 100 0 cm BI /W 2 /H 1 /IM true /D [0 1] ID ",
);
content.push(0x40);
content.extend_from_slice(b" EI Q");
let mut doc = Document::with_version("1.5");
let bytes = synth_bytes(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
Dictionary::new(),
&content,
);
let img = render(&bytes);
assert!(near(px(&img, 50, 50), [128, 128, 128], 1));
assert_eq!(px(&img, 125, 50), RED);
assert_eq!(px(&img, 175, 50), WHITE);
}
#[test]
fn axial_shading_interpolates() {
let res = dictionary! {
"Shading" => dictionary! {
"Sh" => dictionary! {
"ShadingType" => 2, "ColorSpace" => "DeviceGray",
"Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
"Function" => dictionary! {
"FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
"C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
},
"Extend" => vec![true.into(), true.into()],
},
},
};
let img = render(&page(res, "/Sh sh"));
assert!(px(&img, 5, 50)[0] < 40);
assert!(px(&img, 195, 50)[0] > 215);
assert!(near(px(&img, 100, 50), [128, 128, 128], 12));
let res = dictionary! {
"Pattern" => dictionary! {
"P" => dictionary! {
"PatternType" => 2,
"Shading" => dictionary! {
"ShadingType" => 2, "ColorSpace" => "DeviceGray",
"Coords" => vec![0.into(), 0.into(), 200.into(), 0.into()],
"Function" => dictionary! {
"FunctionType" => 2, "Domain" => vec![0.into(), 1.into()],
"C0" => vec![0.into()], "C1" => vec![1.into()], "N" => 1,
},
},
},
},
};
let img = render(&page(res, "/Pattern cs /P scn 0 0 100 100 re f"));
assert!(px(&img, 5, 50)[0] < 40);
assert!(near(px(&img, 50, 50), [64, 64, 64], 12));
assert_eq!(px(&img, 150, 50), WHITE);
}
#[test]
fn tiling_pattern_repeats_its_cell() {
let mut doc = Document::with_version("1.5");
let pat = doc.add_object(Stream::new(
dictionary! {
"PatternType" => 1, "PaintType" => 1, "TilingType" => 1,
"BBox" => vec![0.into(), 0.into(), 20.into(), 20.into()],
"XStep" => 20, "YStep" => 20, "Resources" => Dictionary::new(),
},
b"0 g 0 0 10 10 re f".to_vec(),
));
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
dictionary! { "Pattern" => dictionary! { "P" => Object::Reference(pat) } },
"/Pattern cs /P scn 0 0 200 100 re f",
);
let img = render(&bytes);
let inked = ink_in(&img, 0, 0, 200, 100);
assert!((4000..=6500).contains(&inked), "{inked}");
assert_eq!(px(&img, 5, 95), [0, 0, 0]);
assert_eq!(px(&img, 15, 85), WHITE);
}
#[test]
fn form_xobject_matrix_and_bbox() {
let mut doc = Document::with_version("1.5");
let fx = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject", "Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 50.into(), 50.into()],
"Matrix" => vec![1.into(), 0.into(), 0.into(), 1.into(), 100.into(), 0.into()],
},
b"1 0 0 rg -500 -500 1000 1000 re f".to_vec(),
));
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
dictionary! { "XObject" => dictionary! { "Fx" => Object::Reference(fx) } },
"/Fx Do",
);
let img = render(&bytes);
assert_eq!(px(&img, 125, 75), RED);
assert_eq!(px(&img, 75, 75), WHITE);
assert_eq!(px(&img, 125, 25), WHITE);
assert_eq!(px(&img, 175, 75), WHITE);
}
#[test]
fn transparency_group_alpha_reaches_its_contents() {
let mut doc = Document::with_version("1.5");
let fx = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject", "Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 200.into(), 100.into()],
"Group" => dictionary! { "S" => "Transparency", "CS" => "DeviceRGB" },
},
b"0 g 0 0 200 100 re f".to_vec(),
));
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
dictionary! {
"XObject" => dictionary! { "Fx" => Object::Reference(fx) },
"ExtGState" => dictionary! { "GS" => dictionary! { "ca" => 0.5 } },
},
"/GS gs /Fx Do",
);
let img = render(&bytes);
assert!(near(px(&img, 100, 50), [128, 128, 128], 2));
}
#[test]
fn widgets_draw_their_appearance_unless_hidden() {
let mut doc = Document::with_version("1.5");
let ap = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject", "Subtype" => "Form",
"BBox" => vec![0.into(), 0.into(), 10.into(), 10.into()],
},
b"1 0 0 rg 0 0 10 10 re f".to_vec(),
));
let widget = |x0: i64, flags: i64, subtype: &str| -> Object {
Object::Dictionary(dictionary! {
"Type" => "Annot", "Subtype" => subtype, "F" => flags,
"Rect" => vec![x0.into(), 20.into(), (x0 + 40).into(), 60.into()],
"AP" => dictionary! { "N" => Object::Reference(ap) },
})
};
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
dictionary! {
"Annots" => vec![
widget(10, 4, "Widget"),
widget(60, 2, "Widget"),
widget(110, 4, "Square"),
],
},
Dictionary::new(),
"",
);
let img = render(&bytes);
assert_eq!(px(&img, 30, 60), RED);
assert_eq!(px(&img, 12, 42), RED);
assert_eq!(px(&img, 48, 78), RED);
assert_eq!(px(&img, 30, 30), WHITE);
assert_eq!(px(&img, 80, 60), WHITE);
assert_eq!(px(&img, 130, 60), WHITE);
}
#[test]
fn text_paints_glyphs_from_a_fallback_face() {
let style = font::fallback::style_for("Helvetica", None, None);
if font::fallback::face(style).is_none() {
eprintln!("no fallback fonts on this host — skipping");
return;
}
let res = dictionary! {
"Font" => dictionary! {
"F1" => dictionary! {
"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica",
},
},
};
let img = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HELLO) Tj ET"));
assert!(ink_in(&img, 20, 40, 160, 70) > 200);
assert_eq!(ink_in(&img, 0, 0, 200, 35), 0);
assert_eq!(ink_in(&img, 0, 75, 200, 100), 0);
let img = render(&page(
res.clone(),
"BT 3 Tr /F1 40 Tf 20 30 Td (HELLO) Tj ET",
));
assert_eq!(ink_in(&img, 0, 0, 200, 100), 0);
let wide = render(&page(res.clone(), "BT /F1 40 Tf 20 30 Td (HHHH) Tj ET"));
let narrow = render(&page(res, "BT /F1 40 Tf 50 Tz 20 30 Td (HHHH) Tj ET"));
let right = |img: &RgbImage| {
(0..200)
.rev()
.find(|&x| ink_in(img, x, 0, x + 1, 100) > 0)
.unwrap()
};
assert!(right(&wide) > right(&narrow) + 30);
}
#[test]
fn type3_glyphs_run_their_procedures() {
let mut doc = Document::with_version("1.5");
let square = doc.add_object(Stream::new(
Dictionary::new(),
b"1000 0 0 0 750 750 d1 0 0 750 750 re f".to_vec(),
));
let font = doc.add_object(dictionary! {
"Type" => "Font", "Subtype" => "Type3",
"FontBBox" => vec![0.into(), 0.into(), 750.into(), 750.into()],
"FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()],
"CharProcs" => dictionary! { "square" => Object::Reference(square) },
"Encoding" => dictionary! {
"Type" => "Encoding",
"Differences" => vec![97.into(), Object::Name(b"square".to_vec())],
},
"FirstChar" => 97, "LastChar" => 97, "Widths" => vec![1000.into()],
"Resources" => Dictionary::new(),
});
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
dictionary! { "Font" => dictionary! { "F1" => Object::Reference(font) } },
"BT /F1 40 Tf 20 20 Td 0 0 1 rg (aa) Tj ET",
);
let img = render(&bytes);
assert_eq!(px(&img, 35, 65), [0, 0, 255]);
assert_eq!(px(&img, 75, 65), [0, 0, 255]);
assert_eq!(px(&img, 55, 65), WHITE);
assert_eq!(px(&img, 105, 65), WHITE);
assert_eq!(px(&img, 35, 45), WHITE);
}
#[test]
fn cmyk_follows_the_docling_parse_model() {
let img = render(&page(Dictionary::new(), "0 0 0 1 k 0 0 200 100 re f"));
assert_eq!(px(&img, 100, 50), [35, 31, 32]);
}
#[test]
fn a_renderer_reuses_its_caches_across_renders() {
let data: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 251) as u8).collect();
let mut doc = Document::with_version("1.5");
let im = doc.add_object(Stream::new(
dictionary! {
"Type" => "XObject", "Subtype" => "Image",
"Width" => 64, "Height" => 64, "BitsPerComponent" => 8,
"ColorSpace" => "DeviceRGB",
},
data,
));
let bytes = synth(
&mut doc,
[0.0, 0.0, 200.0, 100.0],
Dictionary::new(),
dictionary! { "XObject" => dictionary! { "Im0" => Object::Reference(im) } },
"q 100 0 0 50 50 25 cm /Im0 Do Q q 20 0 0 20 0 0 cm /Im0 Do Q",
);
let meta = PdfMeta::open(&bytes).unwrap();
let r = Renderer::new(&meta);
let a1 = r.render(0, 200, 100).unwrap();
let a2 = r.render(0, 400, 200).unwrap();
let a3 = r.render(0, 200, 100).unwrap();
assert_eq!(
a1.as_raw(),
render_page_sized(&meta, 0, 200, 100).unwrap().as_raw()
);
assert_eq!(
a2.as_raw(),
render_page_sized(&meta, 0, 400, 200).unwrap().as_raw()
);
assert_eq!(a1.as_raw(), a3.as_raw());
}
}