#![allow(clippy::expect_used, clippy::float_cmp, clippy::cast_precision_loss)]
use std::collections::BTreeMap;
use kurbo::{Affine, BezPath, Point, Rect};
use pdfrum_common::Diagnostics;
use pdfrum_page::{ClipRule, ContentMarks};
use pdfrum_page::{
ColorSpace, Content, FillRule, GraphicsState, Page, PageObject, PathObject, Rotation,
ShadingObject, TextRenderMode, Transparency,
};
use pdfrum_raster_tinyskia::TinySkiaBackend;
use pdfrum_raster_vello_cpu::VelloCpuBackend;
use pdfrum_render::{Pixmap, RenderOptions, RenderSession, render_page, render_page_with};
fn session_for<'a>(
caches: &'a mut pdfrum_render::RenderCaches,
visible: &'a pdfrum_page::Visibility,
) -> RenderSession<'a> {
RenderSession {
caches: Some(caches),
visible: Some(visible),
deadline: None,
}
}
fn page(width: f64, height: f64, objects: Vec<PageObject>) -> Page {
Page {
objects,
media_box: Rect::new(0.0, 0.0, width, height),
crop_box: Rect::new(0.0, 0.0, width, height),
rotate: Rotation::None,
transparency: Transparency::default(),
resources: None,
..Page::empty()
}
}
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((x0, y0));
p.line_to((x1, y0));
p.line_to((x1, y1));
p.line_to((x0, y1));
p.close_path();
p
}
fn filled(path: BezPath, rgb: [f32; 3]) -> PageObject {
let mut state = GraphicsState::default();
state.fill.set_stock(ColorSpace::DeviceRgb, &rgb);
PageObject::Path(Box::new(Content {
object: PathObject {
path,
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))
}
fn stroked(path: BezPath, rgb: [f32; 3], width: f32) -> PageObject {
let mut state = GraphicsState::default();
state.stroke.set_stock(ColorSpace::DeviceRgb, &rgb);
state.stroke_params.width = width;
PageObject::Path(Box::new(Content {
object: PathObject {
path,
matrix: Affine::IDENTITY,
fill_rule: FillRule::None,
stroke: true,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))
}
fn render_both(p: &Page, opts: &RenderOptions) -> (Pixmap, Pixmap) {
let mut diags = Diagnostics::default();
let vello = render_page(p, opts, &VelloCpuBackend::new(), &mut diags).expect("vello renders");
let mut diags = Diagnostics::default();
let tiny =
render_page(p, opts, &TinySkiaBackend::new(), &mut diags).expect("tiny-skia renders");
assert_eq!(
(vello.width(), vello.height()),
(tiny.width(), tiny.height()),
"both backends must produce the same size"
);
(vello, tiny)
}
fn divergence(a: &Pixmap, b: &Pixmap, tol: u8) -> f64 {
let total = (a.width() as usize) * (a.height() as usize);
if total == 0 {
return 0.0;
}
let mut differing = 0usize;
for y in 0..a.height() {
for x in 0..a.width() {
let (Some(pa), Some(pb)) = (a.pixel(x, y), b.pixel(x, y)) else {
continue;
};
if pa.iter().zip(pb.iter()).any(|(l, r)| l.abs_diff(*r) > tol) {
differing += 1;
}
}
}
differing as f64 / total as f64
}
#[test]
fn an_empty_page_is_opaque_white() {
let (vello, tiny) = render_both(&page(20.0, 10.0, Vec::new()), &RenderOptions::default());
assert_eq!(vello.pixel(5, 5), Some([255, 255, 255, 255]));
assert_eq!(tiny.pixel(5, 5), Some([255, 255, 255, 255]));
}
#[test]
fn a_page_group_alone_does_not_make_the_background_transparent() {
let mut p = page(20.0, 10.0, Vec::new());
p.transparency = Transparency {
group: true,
isolated: true,
knockout: false,
};
let (vello, tiny) = render_both(&p, &RenderOptions::default());
assert_eq!(vello.pixel(5, 5), Some([255, 255, 255, 255]));
assert_eq!(tiny.pixel(5, 5), Some([255, 255, 255, 255]));
}
#[test]
fn a_deep_blend_mode_makes_the_background_transparent() {
let mut object = filled(rect_path(0.0, 0.0, 4.0, 4.0), [0.0, 0.0, 0.0]);
if let PageObject::Path(p) = &mut object {
p.state.general.blend = pdfrum_page::BlendMode::Difference;
}
let (vello, tiny) = render_both(&page(20.0, 10.0, vec![object]), &RenderOptions::default());
assert_eq!(vello.pixel(18, 8), Some([0, 0, 0, 0]));
assert_eq!(tiny.pixel(18, 8), Some([0, 0, 0, 0]));
}
#[test]
fn multiply_is_not_deep_enough_to_need_a_backdrop() {
for blend in [
pdfrum_page::BlendMode::Normal,
pdfrum_page::BlendMode::Compatible,
pdfrum_page::BlendMode::Multiply,
] {
let mut object = filled(rect_path(0.0, 0.0, 4.0, 4.0), [0.0, 0.0, 0.0]);
if let PageObject::Path(p) = &mut object {
p.state.general.blend = blend;
}
let p = page(20.0, 10.0, vec![object]);
assert!(
!pdfrum_render::needs_alpha_background(&p),
"{blend:?} must not ask for a transparent background"
);
}
}
#[test]
fn an_axis_aligned_rect_fill_is_pixel_exact_on_both_backends() {
let objects = vec![filled(rect_path(2.0, 2.0, 8.0, 6.0), [1.0, 0.0, 0.0])];
let (vello, tiny) = render_both(&page(12.0, 8.0, objects), &RenderOptions::default());
assert_eq!(
divergence(&vello, &tiny, 0),
0.0,
"a snapped rect must be bit-identical"
);
for y in 0..8u32 {
for x in 0..12u32 {
let px = vello.pixel(x, y).expect("in bounds");
assert!(
px == [255, 0, 0, 255] || px == [255, 255, 255, 255],
"({x},{y}) = {px:?} is neither the fill nor the background"
);
}
}
}
#[test]
fn a_sub_pixel_rect_still_paints_a_whole_column() {
let objects = vec![filled(rect_path(3.2, 1.0, 3.4, 6.0), [0.0, 0.0, 0.0])];
let (vello, tiny) = render_both(&page(8.0, 8.0, objects), &RenderOptions::default());
let painted = |p: &Pixmap| {
(0..8u32)
.filter(|&x| p.pixel(x, 3).is_some_and(|px| px[0] < 128))
.count()
};
assert_eq!(painted(&vello), 1, "exactly one column, not zero");
assert_eq!(painted(&tiny), 1);
}
#[test]
fn a_rotated_rect_is_antialiased_and_the_backends_agree_at_interior_pixels() {
let mut path = rect_path(2.0, 2.0, 14.0, 10.0);
path.apply_affine(Affine::rotate_about(0.4, Point::new(8.0, 6.0)));
let objects = vec![filled(path, [0.0, 0.0, 1.0])];
let (vello, tiny) = render_both(&page(16.0, 12.0, objects), &RenderOptions::default());
assert_eq!(vello.pixel(8, 6), Some([0, 0, 255, 255]));
assert_eq!(tiny.pixel(8, 6), Some([0, 0, 255, 255]));
assert!(
divergence(&vello, &tiny, 8) < 0.35,
"too much divergence off the edges"
);
}
#[test]
fn a_zero_area_fill_becomes_a_quarter_alpha_hairline() {
let mut line = BezPath::new();
line.move_to((1.0, 4.0));
line.line_to((10.0, 4.0));
line.line_to((1.0, 4.0));
let objects = vec![filled(line, [0.0, 0.0, 0.0])];
let (vello, tiny) = render_both(&page(12.0, 8.0, objects), &RenderOptions::default());
let darkest = |p: &Pixmap| {
(0..8u32)
.flat_map(|y| (0..12u32).map(move |x| (x, y)))
.filter_map(|(x, y)| p.pixel(x, y).map(|px| px[0]))
.min()
.unwrap_or(255)
};
assert!(darkest(&vello) < 255, "something was drawn");
assert!(darkest(&tiny) < 255, "and on both backends");
}
#[test]
fn a_fold_inside_a_polygon_does_not_swallow_the_fill() {
let mut spiked = BezPath::new();
spiked.move_to((18.0, 2.0));
spiked.line_to((4.0, 4.0));
spiked.line_to((4.0, 30.0));
spiked.line_to((4.0, 16.0));
spiked.line_to((18.0, 2.0));
let objects = vec![filled(spiked, [0.0, 0.0, 0.0])];
let (vello, tiny) = render_both(&page(24.0, 34.0, objects), &RenderOptions::default());
for (name, p) in [("vello", &vello), ("tiny-skia", &tiny)] {
assert!(
p.pixel(7, 26).is_some_and(|px| px[0] < 128),
"{name}: the polygon the fold hangs off must still be filled"
);
assert!(
p.pixel(6, 22).is_some_and(|px| px[0] < 128),
"{name}: and filled through its interior, not just outlined"
);
}
}
#[test]
fn a_hairline_stroke_is_one_device_pixel_wide() {
let mut line = BezPath::new();
line.move_to((2.0, 4.5));
line.line_to((10.0, 4.5));
let objects = vec![stroked(line, [0.0, 0.0, 0.0], 0.0)];
let (vello, tiny) = render_both(&page(12.0, 9.0, objects), &RenderOptions::default());
for p in [&vello, &tiny] {
let painted_rows = (0..9u32)
.filter(|&y| p.pixel(6, y).is_some_and(|px| px[0] < 250))
.count();
assert!(painted_rows >= 1, "a zero-width stroke must still paint");
assert!(
painted_rows <= 2,
"and must not spread past a pixel: {painted_rows}"
);
}
}
#[test]
fn a_clipped_fill_stops_at_the_clip() {
let mut state = GraphicsState::default();
state
.fill
.set_stock(ColorSpace::DeviceRgb, &[1.0, 0.0, 0.0]);
state
.clip
.push_path(rect_path(0.0, 0.0, 6.0, 12.0), ClipRule::Winding);
let objects = vec![PageObject::Path(Box::new(Content {
object: PathObject {
path: rect_path(0.0, 0.0, 12.0, 12.0),
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))];
let (vello, tiny) = render_both(&page(12.0, 12.0, objects), &RenderOptions::default());
for p in [&vello, &tiny] {
assert_eq!(p.pixel(2, 6), Some([255, 0, 0, 255]), "inside the clip");
assert_eq!(p.pixel(9, 6), Some([255, 255, 255, 255]), "outside it");
}
assert_eq!(divergence(&vello, &tiny, 0), 0.0);
}
#[test]
fn an_axial_shading_paints_a_ramp_identically_on_both_backends() {
let shading = std::sync::Arc::new(pdfrum_page::Shading {
geometry: pdfrum_page::Geometry::Axial(pdfrum_page::Axial {
start: Point::new(0.0, 0.0),
end: Point::new(16.0, 0.0),
t_min: 0.0,
t_max: 1.0,
extend_start: true,
extend_end: true,
}),
space: std::sync::Arc::new(ColorSpace::DeviceGray),
functions: Box::new([]),
background: None,
bbox: None,
});
let objects = vec![PageObject::Shading(Box::new(Content {
object: ShadingObject {
shading,
matrix: Affine::IDENTITY,
bounds: Rect::new(0.0, 0.0, 16.0, 8.0),
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))];
let (vello, tiny) = render_both(&page(16.0, 8.0, objects), &RenderOptions::default());
assert_eq!(
divergence(&vello, &tiny, 0),
0.0,
"engine-computed pixels must be identical"
);
}
#[test]
fn a_form_renders_the_children_the_page_graph_gave_it() {
let placement = Affine::translate((6.0, 2.0));
let mut child = filled(rect_path(0.0, 0.0, 4.0, 4.0), [0.0, 1.0, 0.0]);
if let PageObject::Path(p) = &mut child {
p.object.matrix = placement;
}
let form = PageObject::Form(Box::new(Content {
object: pdfrum_page::FormObject {
objects: vec![child],
matrix: placement,
bbox: Some(Rect::new(0.0, 0.0, 4.0, 4.0)),
transparency: Transparency::default(),
oc: None,
source: None,
live_edit: false,
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
let (vello, tiny) = render_both(&page(12.0, 8.0, vec![form]), &RenderOptions::default());
for p in [&vello, &tiny] {
assert_eq!(
p.pixel(1, 1),
Some([255, 255, 255, 255]),
"not at the origin"
);
assert_eq!(
p.pixel(7, 3),
Some([0, 255, 0, 255]),
"but at the form's offset"
);
}
}
#[test]
fn a_translucent_fill_blends_with_the_background() {
let mut state = GraphicsState::default();
state
.fill
.set_stock(ColorSpace::DeviceRgb, &[0.0, 0.0, 0.0]);
state.general.fill_alpha = 0.5;
let objects = vec![PageObject::Path(Box::new(Content {
object: PathObject {
path: rect_path(0.0, 0.0, 8.0, 8.0),
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))];
let (vello, tiny) = render_both(&page(8.0, 8.0, objects), &RenderOptions::default());
for p in [&vello, &tiny] {
let px = p.pixel(4, 4).expect("in bounds");
assert!(px[0].abs_diff(128) <= 2, "half black over white: {px:?}");
assert_eq!(px[3], 255, "the page stays opaque");
}
}
#[test]
fn a_grayscale_render_uses_the_ntsc_weights() {
let objects = vec![filled(rect_path(0.0, 0.0, 8.0, 8.0), [1.0, 0.0, 0.0])];
let opts = RenderOptions {
color_mode: pdfrum_render::ColorMode::Gray,
..RenderOptions::default()
};
let (vello, _) = render_both(&page(8.0, 8.0, objects), &opts);
assert_eq!(vello.pixel(4, 4), Some([76, 76, 76, 255]));
}
#[test]
fn a_page_too_large_for_a_backend_is_an_error_not_a_panic() {
let opts = RenderOptions {
transform: Affine::scale(500.0),
..RenderOptions::default()
};
let mut diags = Diagnostics::default();
let err = render_page(
&page(1000.0, 1000.0, Vec::new()),
&opts,
&VelloCpuBackend::new(),
&mut diags,
)
.expect_err("beyond the u16 target limit");
assert!(matches!(err, pdfrum_render::Error::TargetTooLarge { .. }));
}
#[test]
fn a_spent_deadline_fails_the_render_and_a_generous_one_is_invisible() {
let objects = vec![filled(rect_path(0.0, 0.0, 10.0, 10.0), [1.0, 0.0, 0.0])];
let p = page(20.0, 20.0, objects);
let opts = RenderOptions::default();
let backend = VelloCpuBackend::new();
let no_time = pdfrum_common::Deadline::after(std::time::Duration::ZERO);
let spent = pdfrum_render::RenderSession {
deadline: Some(&no_time),
..Default::default()
};
let err = render_page_with(&p, &opts, &backend, spent, &mut Diagnostics::default())
.expect_err("no time at all");
assert!(matches!(
err,
pdfrum_render::Error::Limit(pdfrum_common::LimitExceeded::Time {
during: pdfrum_common::Operation::Render,
page: None,
..
})
));
let an_hour = pdfrum_common::Deadline::after(std::time::Duration::from_hours(1));
let generous = pdfrum_render::RenderSession {
deadline: Some(&an_hour),
..Default::default()
};
let timed = render_page_with(&p, &opts, &backend, generous, &mut Diagnostics::default())
.expect("an hour is plenty");
let plain = render_page(&p, &opts, &backend, &mut Diagnostics::default()).expect("render");
assert_eq!(timed.data(), plain.data());
}
#[test]
fn invisible_text_paints_nothing() {
let object = PageObject::Text(Box::new(Content {
object: pdfrum_page::TextObject {
segments: Box::new([]),
position: Point::ZERO,
matrix: Affine::IDENTITY,
font: None,
font_source: None,
render_mode: TextRenderMode::Invisible,
type3_metrics: BTreeMap::default(),
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
let (vello, tiny) = render_both(&page(8.0, 8.0, vec![object]), &RenderOptions::default());
assert_eq!(vello.pixel(4, 4), Some([255, 255, 255, 255]));
assert_eq!(tiny.pixel(4, 4), Some([255, 255, 255, 255]));
}
fn substituted_font(base_name: &str, first_char: i64, widths: &[i64]) -> pdfrum_font::Font {
use pdfrum_object::{Dict, Name, NoResolve, Object};
let dict = Dict::from_pairs([
(Name::from("Subtype"), Object::Name(Name::from("Type1"))),
(Name::from("BaseFont"), Object::Name(Name::from(base_name))),
(Name::from("FirstChar"), Object::Int(first_char)),
(
Name::from("LastChar"),
Object::Int(first_char + i64::try_from(widths.len()).expect("small") - 1),
),
(
Name::from("Widths"),
Object::Array(widths.iter().map(|w| Object::Int(*w)).collect()),
),
]);
pdfrum_font::load(
&dict,
&NoResolve,
&pdfrum_font::FontCache::new(),
&pdfrum_common::Limits::default(),
&mut Diagnostics::default(),
)
.expect("a simple font always constructs")
}
fn text_object(
font: pdfrum_font::Font,
size: f32,
codes: &[u8],
x: f64,
y: f64,
) -> (PageObject, std::sync::Arc<pdfrum_font::Font>) {
let font = std::sync::Arc::new(font);
let object = PageObject::Text(Box::new(Content {
object: pdfrum_page::TextObject {
segments: Box::new([pdfrum_page::TextSegment {
codes: codes.to_vec().into_boxed_slice(),
kerning: 0.0,
}]),
position: Point::new(x, y),
matrix: Affine::IDENTITY,
font: Some((std::sync::Arc::clone(&font), size)),
font_source: None,
render_mode: TextRenderMode::Fill,
type3_metrics: BTreeMap::default(),
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
(object, font)
}
fn ink_runs(p: &Pixmap, y0: u32, y1: u32) -> Vec<(u32, u32)> {
let mut runs = Vec::new();
let mut start = None;
for x in 0..p.width() {
let inked = (y0..y1).any(|y| p.pixel(x, y).is_some_and(|px| px[0] < 200));
match (inked, start) {
(true, None) => start = Some(x),
(false, Some(s)) => {
runs.push((s, x));
start = None;
}
_ => {}
}
}
if let Some(s) = start {
runs.push((s, p.width()));
}
runs
}
#[test]
fn a_substituted_fonts_glyphs_are_drawn_at_the_widths_the_pdf_declares() {
let mut widths = vec![0_i64; 128];
widths[usize::from(b'a')] = 800;
widths[usize::from(b'b')] = 100;
widths[usize::from(b'c')] = 400;
let font = substituted_font("AGaramond", 0, &widths);
assert!(
font.subst().is_some_and(|s| s.is_builtin_generic),
"the fixture must reach the Multiple-Master generic"
);
let (object, _keep) = text_object(font, 30.0, b"a c a c", 4.0, 10.0);
let (vello, tiny) = render_both(&page(160.0, 44.0, vec![object]), &RenderOptions::default());
for (name, p) in [("vello", &vello), ("tiny-skia", &tiny)] {
let runs = ink_runs(p, 0, 44);
assert_eq!(runs.len(), 4, "{name}: four separated glyphs, got {runs:?}");
let w: Vec<u32> = runs.iter().map(|(a, b)| b - a).collect();
let ratio = f64::from(w[0]) / f64::from(w[1]);
assert!(
(1.6..2.6).contains(&ratio),
"{name}: `a` at 800 units must be about twice `c` at 400, \
ratio {ratio:.2} from widths {w:?}"
);
assert_eq!(w[0], w[2], "{name}: the two `a`s agree, {w:?}");
assert_eq!(w[1], w[3], "{name}: the two `c`s agree, {w:?}");
}
}
#[test]
fn glyph_origins_snap_to_the_oracles_grid_unless_asked_not_to() {
let mut widths = vec![600_i64; 128];
widths[usize::from(b'H')] = 700;
let font = || substituted_font("SomeFontNobodyHas", 0, &widths);
let snapped = {
let (object, _keep) = text_object(font(), 20.0, b"H", 4.0, 10.4);
let (p, _) = render_both(&page(60.0, 40.0, vec![object]), &RenderOptions::default());
ink_rows(&p)
};
let whole = {
let (object, _keep) = text_object(font(), 20.0, b"H", 4.0, 10.0);
let (p, _) = render_both(&page(60.0, 40.0, vec![object]), &RenderOptions::default());
ink_rows(&p)
};
assert_eq!(
snapped, whole,
"a 0.4 px baseline offset snaps onto the same rows as a whole one"
);
let subpixel = RenderOptions {
subpixel_text_positioning: true,
..RenderOptions::default()
};
let free = {
let (object, _keep) = text_object(font(), 20.0, b"H", 4.0, 10.4);
let (p, _) = render_both(&page(60.0, 40.0, vec![object]), &subpixel);
ink_rows(&p)
};
assert_ne!(
free, snapped,
"the knob puts the glyph back where the PDF says it is"
);
}
#[test]
fn small_text_is_drawn_as_an_lcd_filtered_bitmap_and_large_text_is_not() {
let widths = vec![600_i64; 128];
let font = || substituted_font("SomeFontNobodyHas", 0, &widths);
let small = {
let (object, _keep) = text_object(font(), 8.0, b"H", 10.0, 10.0);
let (p, _) = render_both(&page(60.0, 40.0, vec![object]), &RenderOptions::default());
ink_runs(&p, 0, p.height())
};
let large = {
let (object, _keep) = text_object(font(), 80.0, b"H", 10.0, 10.0);
let (p, _) = render_both(&page(300.0, 200.0, vec![object]), &RenderOptions::default());
ink_runs(&p, 0, p.height())
};
assert!(!small.is_empty(), "the small glyph painted something");
assert!(!large.is_empty(), "the large glyph painted something");
let span = |runs: &[(u32, u32)]| {
let lo = runs.first().map_or(0, |r| r.0);
let hi = runs.last().map_or(0, |r| r.1);
f64::from(hi - lo)
};
let small_fraction = span(&small) / 8.0;
let large_fraction = span(&large) / 80.0;
assert!(
small_fraction > large_fraction * 1.15,
"the small glyph must be relatively wider for the filter's reach: \
{small_fraction} vs {large_fraction}"
);
}
#[test]
fn a_third_of_a_pixel_redistributes_a_small_glyphs_ink_without_moving_it() {
let widths = vec![600_i64; 128];
let font = || substituted_font("SomeFontNobodyHas", 0, &widths);
let at = |x: f64| {
let (object, _keep) = text_object(font(), 8.0, b"H", x, 10.0);
let (p, _) = render_both(&page(60.0, 40.0, vec![object]), &RenderOptions::default());
let runs = ink_runs(&p, 0, p.height());
let mut ink = Vec::new();
for y in 0..p.height() {
for x in 0..p.width() {
if let Some(px) = p.pixel(x, y)
&& px[0] < 255
{
ink.push(px[0]);
}
}
}
(runs, ink)
};
let (zero_runs, zero_row) = at(10.0);
let (third_runs, third_row) = at(10.4);
let start = |runs: &[(u32, u32)]| i64::from(runs.first().map_or(0, |r| r.0));
let end = |runs: &[(u32, u32)]| i64::from(runs.last().map_or(0, |r| r.1));
assert!(
(start(&zero_runs) - start(&third_runs)).abs() <= 1
&& (end(&zero_runs) - end(&third_runs)).abs() <= 1,
"the ink stays within a pixel of where it was: {zero_runs:?} vs {third_runs:?}"
);
assert_ne!(
zero_row, third_row,
"but the phase changes which window of the 3x bitmap is averaged"
);
}
#[test]
fn the_bitmap_path_and_the_outline_path_place_a_glyph_in_the_same_place() {
let widths = vec![600_i64; 128];
let font = || substituted_font("SomeFontNobodyHas", 0, &widths);
let bbox = |opts: &RenderOptions| {
let (object, _keep) = text_object(font(), 10.0, b"H", 10.0, 10.0);
let (p, _) = render_both(&page(60.0, 40.0, vec![object]), opts);
let rows: Vec<u32> = (0..p.height())
.filter(|y| (0..p.width()).any(|x| p.pixel(x, *y).is_some_and(|px| px[0] < 250)))
.collect();
(
rows.first().copied().unwrap_or(0),
rows.last().copied().unwrap_or(0),
)
};
let bitmap = bbox(&RenderOptions::default());
let outline = bbox(&RenderOptions {
subpixel_text_positioning: true,
..RenderOptions::default()
});
assert_eq!(
bitmap, outline,
"the bitmap path must not move the glyph vertically"
);
}
fn ink_rows(p: &Pixmap) -> (u32, u32, u8) {
let inked = |y: u32| (0..p.width()).any(|x| p.pixel(x, y).is_some_and(|px| px[0] < 250));
let rows: Vec<u32> = (0..p.height()).filter(|y| inked(*y)).collect();
let first = *rows.first().expect("the glyph painted something");
let last = *rows.last().expect("the glyph painted something");
let darkest = (0..p.width())
.filter_map(|x| p.pixel(x, first).map(|px| px[0]))
.min()
.unwrap_or(255);
(first, last, darkest)
}
#[test]
fn a_page_with_many_objects_stays_deterministic_across_runs() {
let objects: Vec<PageObject> = (0..40)
.map(|i| {
let x = f64::from(i % 8) * 2.0;
let y = f64::from(i / 8) * 2.0;
filled(
rect_path(x, y, x + 1.5, y + 1.5),
[0.1 * (i % 10) as f32, 0.5, 0.9],
)
})
.collect();
let p = page(16.0, 10.0, objects);
let opts = RenderOptions::default();
let (first, _) = render_both(&p, &opts);
let (second, _) = render_both(&p, &opts);
assert_eq!(
first, second,
"the pinned SIMD level makes runs reproducible"
);
}
fn pattern_filled(path: BezPath, pattern: pdfrum_page::Pattern, operands: &[f32]) -> PageObject {
let mut state = GraphicsState::default();
state
.fill
.set_space(std::sync::Arc::new(ColorSpace::Pattern(Box::new(
pdfrum_page::PatternSpace {
base: Some(Box::new(ColorSpace::DeviceRgb)),
},
))));
state.fill.set_pattern(
pdfrum_object::Name::from("P0"),
operands,
Some(std::sync::Arc::new(pattern)),
);
PageObject::Path(Box::new(Content {
object: PathObject {
path,
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))
}
fn solid_tile(
step: f32,
colored: bool,
cell_rgb: [f32; 3],
matrix: Affine,
) -> pdfrum_page::TilingPattern {
pdfrum_page::TilingPattern {
colored,
x_step: step,
y_step: step,
bbox: Rect::new(0.0, 0.0, f64::from(step), f64::from(step)),
matrix,
resources: None,
content: pdfrum_object::ByteSpan::empty(),
objects: vec![filled(
rect_path(0.0, 0.0, f64::from(step) / 2.0, f64::from(step)),
cell_rgb,
)],
}
}
#[test]
fn a_coloured_tiling_pattern_paints_its_cell_across_the_fill() {
let tiling = solid_tile(4.0, true, [1.0, 0.0, 0.0], Affine::IDENTITY);
let object = pattern_filled(
rect_path(0.0, 0.0, 16.0, 16.0),
pdfrum_page::Pattern::Tiling(Box::new(tiling)),
&[],
);
let (vello, tiny) = render_both(&page(16.0, 16.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
for x in [0, 1, 4, 5, 8, 9] {
let px = surface.pixel(x, 8).expect("a pixel");
assert!(
px[0] > 200 && px[1] < 60,
"column {x} should be tile ink, got {px:?}"
);
}
for x in [2, 3, 6, 7, 10, 11] {
assert_eq!(
surface.pixel(x, 8),
Some([255, 255, 255, 255]),
"column {x} is between tiles and must stay white"
);
}
}
}
#[test]
fn an_uncoloured_tiling_pattern_takes_the_operand_colour_not_the_cells() {
let tiling = solid_tile(4.0, false, [0.0, 1.0, 0.0], Affine::IDENTITY);
let object = pattern_filled(
rect_path(0.0, 0.0, 16.0, 16.0),
pdfrum_page::Pattern::Tiling(Box::new(tiling)),
&[0.0, 0.0, 1.0],
);
let (vello, tiny) = render_both(&page(16.0, 16.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
let px = surface.pixel(0, 8).expect("a pixel");
assert!(
px[2] > 200 && px[1] < 60,
"an uncoloured tile paints the operand blue, not the cell's green: {px:?}"
);
}
}
#[test]
fn a_pattern_fill_is_clipped_to_the_objects_own_geometry() {
let tiling = solid_tile(4.0, true, [1.0, 0.0, 0.0], Affine::IDENTITY);
let object = pattern_filled(
rect_path(4.0, 4.0, 12.0, 12.0),
pdfrum_page::Pattern::Tiling(Box::new(tiling)),
&[],
);
let (vello, tiny) = render_both(&page(16.0, 16.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
for (x, y) in [(1u32, 1u32), (14, 1), (1, 14), (14, 14), (8, 1), (1, 8)] {
assert_eq!(
surface.pixel(x, y),
Some([255, 255, 255, 255]),
"({x},{y}) is outside the filled rect and must not be painted"
);
}
let inside: u32 = (4..12)
.map(|x| {
u32::from(
surface
.pixel(x, 8)
.is_some_and(|px| px[0] > 200 && px[1] < 60),
)
})
.sum();
assert!(inside > 0, "the pattern must paint inside its own geometry");
}
}
#[test]
fn a_zero_step_tiling_pattern_paints_nothing_at_all() {
let mut tiling = solid_tile(4.0, true, [1.0, 0.0, 0.0], Affine::IDENTITY);
tiling.x_step = 0.0;
let object = pattern_filled(
rect_path(0.0, 0.0, 16.0, 16.0),
pdfrum_page::Pattern::Tiling(Box::new(tiling)),
&[],
);
let (vello, tiny) = render_both(&page(16.0, 16.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
for x in 0..16 {
assert_eq!(
surface.pixel(x, 8),
Some([255, 255, 255, 255]),
"a zero step must paint nothing, but ({x},8) is inked"
);
}
}
}
#[test]
fn a_pattern_that_did_not_resolve_paints_nothing_rather_than_black() {
let mut state = GraphicsState::default();
state
.fill
.set_space(std::sync::Arc::new(ColorSpace::Pattern(Box::default())));
state
.fill
.set_pattern(pdfrum_object::Name::from("Missing"), &[], None);
let object = PageObject::Path(Box::new(Content {
object: PathObject {
path: rect_path(0.0, 0.0, 8.0, 8.0),
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
let (vello, tiny) = render_both(&page(8.0, 8.0, vec![object]), &RenderOptions::default());
assert_eq!(vello.pixel(4, 4), Some([255, 255, 255, 255]));
assert_eq!(tiny.pixel(4, 4), Some([255, 255, 255, 255]));
}
fn soft_mask(
kind: pdfrum_page::SoftMaskKind,
backdrop: pdfrum_page::Rgb,
objects: Vec<PageObject>,
) -> std::sync::Arc<pdfrum_page::SoftMask> {
std::sync::Arc::new(pdfrum_page::SoftMask {
group: pdfrum_object::Stream {
dict: pdfrum_object::Dict::default(),
data: pdfrum_object::ByteSpan::empty(),
},
kind,
backdrop,
transfer: None,
matrix: Affine::IDENTITY,
objects,
})
}
fn masked_form(
inner: BezPath,
rgb: [f32; 3],
mask: std::sync::Arc<pdfrum_page::SoftMask>,
bbox: Rect,
) -> PageObject {
let mut state = GraphicsState::default();
state.general.soft_mask = Some(mask);
PageObject::Form(Box::new(Content {
object: pdfrum_page::FormObject {
objects: vec![filled(inner, rgb)],
matrix: Affine::IDENTITY,
bbox: Some(bbox),
transparency: Transparency {
group: true,
isolated: true,
knockout: false,
},
oc: None,
source: None,
live_edit: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))
}
#[test]
fn a_luminosity_mask_paints_its_group_through_the_masked_object() {
let mask = soft_mask(
pdfrum_page::SoftMaskKind::Luminosity,
pdfrum_page::Rgb::BLACK,
vec![filled(rect_path(0.0, 0.0, 8.0, 16.0), [1.0, 1.0, 1.0])],
);
let object = masked_form(
rect_path(0.0, 0.0, 16.0, 16.0),
[1.0, 0.0, 0.0],
mask,
Rect::new(0.0, 0.0, 16.0, 16.0),
);
let (vello, tiny) = render_both(&page(16.0, 16.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
let lit = surface.pixel(4, 8).expect("a pixel");
assert!(lit[0] > 200 && lit[1] < 60, "revealed half: {lit:?}");
assert_eq!(
surface.pixel(12, 8),
Some([255, 255, 255, 255]),
"the backdrop's black luminance must hide the right half"
);
}
}
#[test]
fn a_white_backdrop_reveals_where_the_group_paints_nothing() {
let mask = soft_mask(
pdfrum_page::SoftMaskKind::Luminosity,
pdfrum_page::Rgb {
r: 1.0,
g: 1.0,
b: 1.0,
},
Vec::new(),
);
let object = masked_form(
rect_path(0.0, 0.0, 16.0, 16.0),
[1.0, 0.0, 0.0],
mask,
Rect::new(0.0, 0.0, 16.0, 16.0),
);
let (vello, tiny) = render_both(&page(16.0, 16.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
let px = surface.pixel(8, 8).expect("a pixel");
assert!(px[0] > 200 && px[1] < 60, "fully revealed: {px:?}");
}
}
#[test]
fn a_white_fill_paints_white_rather_than_nothing() {
let objects = vec![
filled(rect_path(0.0, 0.0, 16.0, 16.0), [0.0, 0.0, 0.0]),
filled(rect_path(4.0, 4.0, 12.0, 12.0), [1.0, 1.0, 1.0]),
];
let (vello, tiny) = render_both(&page(16.0, 16.0, objects), &RenderOptions::default());
for surface in [&vello, &tiny] {
assert_eq!(
surface.pixel(1, 1),
Some([0, 0, 0, 255]),
"the black ground"
);
assert_eq!(
surface.pixel(8, 8),
Some([255, 255, 255, 255]),
"the white square must paint over the black, not vanish into it"
);
}
}
#[test]
fn an_unusable_pattern_paints_nothing_rather_than_the_current_colour() {
let mut state = GraphicsState::default();
state
.fill
.set_stock(ColorSpace::DeviceRgb, &[1.0, 0.0, 0.0]);
state
.fill
.set_pattern(pdfrum_object::Name::from("Broken"), &[], None);
assert!(
state.fill.is_pattern(),
"the stock /Pattern space is installed"
);
let object = PageObject::Path(Box::new(Content {
object: PathObject {
path: rect_path(0.0, 0.0, 8.0, 8.0),
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
let (vello, tiny) = render_both(&page(8.0, 8.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
assert_eq!(
surface.pixel(4, 4),
Some([255, 255, 255, 255]),
"an unusable pattern must paint nothing, not the previous colour"
);
}
}
#[test]
fn a_hidden_object_is_not_drawn_and_its_clip_never_reaches_the_device() {
let mut clipped = GraphicsState::default();
clipped
.clip
.push_path(rect_path(0.0, 0.0, 1.0, 1.0), ClipRule::Winding);
let visible_page = page(
8.0,
8.0,
vec![
filled(rect_path(0.0, 0.0, 8.0, 8.0), [1.0, 1.0, 1.0]),
filled(rect_path(0.0, 0.0, 8.0, 8.0), [1.0, 0.0, 0.0]),
],
);
let opts = RenderOptions::default();
let all = pdfrum_page::Visibility::all_visible();
let mut diags = Diagnostics::default();
let mut caches = pdfrum_render::RenderCaches::new();
let session = session_for(&mut caches, &all);
let shown = render_page_with(
&visible_page,
&opts,
&TinySkiaBackend::new(),
session,
&mut diags,
)
.expect("renders");
assert_eq!(shown.pixel(4, 4), Some([255, 0, 0, 255]));
let off = pdfrum_object::Dict::from_pairs([
(
pdfrum_object::Name::from("Type"),
pdfrum_object::Object::Name(pdfrum_object::Name::from("OCG")),
),
(
pdfrum_object::Name::from("Name"),
pdfrum_object::Object::Name(pdfrum_object::Name::from("Hidden")),
),
]);
let properties = pdfrum_object::Dict::from_pairs([
(
pdfrum_object::Name::from("OCGs"),
pdfrum_object::Object::Array(pdfrum_object::Array::of([pdfrum_object::Object::Dict(
off.clone(),
)])),
),
(
pdfrum_object::Name::from("D"),
pdfrum_object::Object::Dict(pdfrum_object::Dict::from_pairs([(
pdfrum_object::Name::from("OFF"),
pdfrum_object::Object::Array(pdfrum_object::Array::of([
pdfrum_object::Object::Dict(off.clone()),
])),
)])),
),
]);
let mut red = filled(rect_path(0.0, 0.0, 8.0, 8.0), [1.0, 0.0, 0.0]);
let PageObject::Path(content) = &mut red else {
panic!("a fill is a path object");
};
content.marks.push_with_properties(
pdfrum_object::Name::from("OC"),
&pdfrum_page::MarkProperties::Named(pdfrum_object::Name::from("MC0")),
|_| Some(off.clone()),
);
let hidden_page = page(
8.0,
8.0,
vec![filled(rect_path(0.0, 0.0, 8.0, 8.0), [1.0, 1.0, 1.0]), red],
);
let mut oc = pdfrum_page::OcContext::new(Some(properties), pdfrum_page::UsageType::View);
let mut build_diags = Diagnostics::default();
let visible = pdfrum_page::page_visibility(
&hidden_page,
&mut oc,
&pdfrum_object::NoResolve,
&mut build_diags,
);
assert!(!visible.visible(1), "the pre-pass hides the red fill");
let mut diags = Diagnostics::default();
let mut caches = pdfrum_render::RenderCaches::new();
let session = session_for(&mut caches, &visible);
let out = render_page_with(
&hidden_page,
&opts,
&TinySkiaBackend::new(),
session,
&mut diags,
)
.expect("renders");
assert_eq!(
out.pixel(4, 4),
Some([255, 255, 255, 255]),
"the hidden fill paints nothing at all"
);
let mut diags = Diagnostics::default();
let plain =
render_page(&hidden_page, &opts, &TinySkiaBackend::new(), &mut diags).expect("renders");
assert_eq!(
plain.pixel(4, 4),
Some([255, 0, 0, 255]),
"the visibility-free entry point still draws every layer"
);
}
#[test]
fn a_groups_own_alpha_multiplies_the_alpha_inside_it() {
let mut inner_state = GraphicsState::default();
inner_state
.fill
.set_stock(ColorSpace::DeviceRgb, &[1.0, 1.0, 1.0]);
inner_state.general.fill_alpha = 0.5;
let inner = PageObject::Path(Box::new(Content {
object: PathObject {
path: rect_path(0.0, 0.0, 8.0, 8.0),
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state: inner_state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
let mut form_state = GraphicsState::default();
form_state.general.fill_alpha = 0.5;
let form = PageObject::Form(Box::new(Content {
object: pdfrum_page::FormObject {
objects: vec![inner],
matrix: Affine::IDENTITY,
bbox: Some(Rect::new(0.0, 0.0, 8.0, 8.0)),
transparency: Transparency {
group: true,
isolated: true,
knockout: false,
},
oc: None,
source: None,
live_edit: false,
},
state: form_state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
let page = page(
8.0,
8.0,
vec![filled(rect_path(0.0, 0.0, 8.0, 8.0), [0.0, 0.0, 0.0]), form],
);
let (vello, tiny) = render_both(&page, &RenderOptions::default());
for surface in [&vello, &tiny] {
let px = surface.pixel(4, 4).expect("a pixel");
assert!(
px[0].abs_diff(63) <= 1,
"0.5 * 0.5 of white over black is 63, got {px:?}"
);
}
}
#[test]
fn the_glyph_buffer_comes_back_to_the_session_after_a_text_object() {
let font = pdfrum_font::Font::load_standard(
pdfrum_font::StandardFont::Helvetica,
&pdfrum_font::FontCache::default(),
);
let (object, _font) = text_object(font, 12.0, b"buffer", 4.0, 20.0);
let page = page(120.0, 40.0, vec![object]);
let mut caches = pdfrum_render::RenderCaches::new();
let mut diags = Diagnostics::default();
let pixmap = render_page_with(
&page,
&RenderOptions::default(),
&TinySkiaBackend::new(),
RenderSession {
caches: Some(&mut caches),
..Default::default()
},
&mut diags,
)
.expect("renders");
assert!(
(0..pixmap.width()).any(|x| (0..pixmap.height())
.any(|y| pixmap.pixel(x, y).is_some_and(|px| px[0] < 200))),
"the fixture must actually draw text for this test to mean anything"
);
assert!(
caches.glyph_buffer_capacity() > 0,
"the placement buffer was not returned to the session"
);
}
#[test]
fn stroked_text_in_a_pattern_colour_paints_its_glyphs() {
let (text, _font) = text_object(
substituted_font("Helvetica", 65, &[722; 26]),
40.0,
b"A",
4.0,
8.0,
);
let PageObject::Text(mut content) = text else {
panic!("text_object builds a text object");
};
content.object.render_mode = TextRenderMode::Stroke;
content
.state
.stroke
.set_space(std::sync::Arc::new(ColorSpace::Pattern(Box::new(
pdfrum_page::PatternSpace {
base: Some(Box::new(ColorSpace::DeviceRgb)),
},
))));
content.state.stroke.set_pattern(
pdfrum_object::Name::from("P0"),
&[],
Some(std::sync::Arc::new(pdfrum_page::Pattern::Tiling(Box::new(
solid_tile(4.0, true, [1.0, 0.0, 0.0], Affine::IDENTITY),
)))),
);
let object = PageObject::Text(content);
let (vello, tiny) = render_both(&page(48.0, 48.0, vec![object]), &RenderOptions::default());
for surface in [&vello, &tiny] {
let inked = (0..surface.width())
.flat_map(|x| (0..surface.height()).map(move |y| (x, y)))
.filter(|&(x, y)| {
surface
.pixel(x, y)
.is_some_and(|px| px != [255, 255, 255, 255])
})
.count();
assert!(
inked > 0,
"a pattern-coloured stroked run must paint its glyph outlines"
);
}
}