use kurbo::{Affine, Rect, Shape};
use pdfrum_common::{Deadline, Diagnostics, Operation};
use pdfrum_page::{Page, PageObject, Visibility};
use crate::clip;
use crate::color::{Argb, ObjectKind, resolve_argb};
use crate::ctx::{RenderCaches, RenderCtx};
use crate::device::{ImageQuality, MAX_TARGET_DIMENSION, RasterBackend, RenderDevice};
use crate::error::Error;
use crate::group::{GroupFinish, GroupInputs, needs_backdrop, needs_offscreen};
use crate::image::{effective_quality, overprint_blend, resample_quality, to_pixmap};
use crate::options::RenderOptions;
use crate::paint::{PathPaint, draw_path};
use crate::path::{IntRect, is_available_matrix, outer_rect};
use crate::pattern::PatternClip;
use crate::pixmap::{Pixmap, alpha_byte_rounding, alpha_byte_truncating};
use crate::shading;
use crate::text::{has_face, paint_kinds, stroke_text_matrices};
use crate::transfer::TransferFunc;
#[derive(Debug, Default)]
pub struct RenderSession<'a> {
pub caches: Option<&'a mut RenderCaches>,
pub visible: Option<&'a Visibility>,
pub deadline: Option<&'a Deadline>,
}
pub fn render_page<B: RasterBackend>(
page: &Page,
opts: &RenderOptions,
backend: &B,
diags: &mut Diagnostics,
) -> Result<Pixmap, Error> {
render_page_with(page, opts, backend, RenderSession::default(), diags)
}
pub fn render_page_with<B: RasterBackend>(
page: &Page,
opts: &RenderOptions,
backend: &B,
session: RenderSession<'_>,
diags: &mut Diagnostics,
) -> Result<Pixmap, Error> {
let RenderSession {
caches,
visible,
deadline,
} = session;
let mut fresh_caches = RenderCaches::new();
let all_visible = Visibility::all_visible();
let visible = visible.unwrap_or(&all_visible);
let caches = caches.unwrap_or(&mut fresh_caches);
render_page_inner(page, opts, backend, visible, caches, deadline, diags)
}
fn check_deadline(deadline: Option<&Deadline>) -> Result<(), Error> {
match deadline {
Some(deadline) => deadline.check(Operation::Render).map_err(Error::Limit),
None => Ok(()),
}
}
fn render_page_inner<B: RasterBackend>(
page: &Page,
opts: &RenderOptions,
backend: &B,
visible: &Visibility,
caches: &mut RenderCaches,
deadline: Option<&Deadline>,
diags: &mut Diagnostics,
) -> Result<Pixmap, Error> {
let (w, h) = target_size(page, opts)?;
check_deadline(deadline)?;
let clear = opts.background_for(needs_alpha_background(page));
let mut device = backend.new_target(w, h, clear);
let ctx = RenderCtx {
deadline,
..RenderCtx::new(opts.clone(), page.transparency)
};
let to_device = page_matrix(page, opts);
let device_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
render_object_list(
&ctx,
&mut device,
backend,
caches,
&page.objects,
visible,
to_device,
device_box,
diags,
);
check_deadline(deadline)?;
Ok(backend.finish(device))
}
#[must_use]
pub fn needs_alpha_background(page: &Page) -> bool {
fn any_deep_blend(objects: &[PageObject]) -> bool {
objects.iter().any(|object| {
let deep = matches!(
object.state().general.blend,
pdfrum_page::BlendMode::Screen
| pdfrum_page::BlendMode::Overlay
| pdfrum_page::BlendMode::Darken
| pdfrum_page::BlendMode::Lighten
| pdfrum_page::BlendMode::ColorDodge
| pdfrum_page::BlendMode::ColorBurn
| pdfrum_page::BlendMode::HardLight
| pdfrum_page::BlendMode::SoftLight
| pdfrum_page::BlendMode::Difference
| pdfrum_page::BlendMode::Exclusion
| pdfrum_page::BlendMode::Hue
| pdfrum_page::BlendMode::Saturation
| pdfrum_page::BlendMode::Color
| pdfrum_page::BlendMode::Luminosity
);
deep || match object {
PageObject::Form(f) => any_deep_blend(&f.object.objects),
_ => false,
}
})
}
any_deep_blend(&page.objects)
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the `is_finite` and `>= 1.0` guards run before the successful \
cast, and the `MAX_TARGET_DIMENSION` check runs after it; in \
the error arm `max(0.0)` floors the value and Rust's saturating \
float-to-int cast turns a huge or NaN size into a reported \
number rather than wrapping"
)]
pub fn target_size(page: &Page, opts: &RenderOptions) -> Result<(u32, u32), Error> {
let (pw, ph) = page.display_size();
let corners = opts
.transform
.transform_rect_bbox(Rect::new(0.0, 0.0, pw, ph));
let w = corners.width().trunc();
let h = corners.height().trunc();
if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 {
return Err(Error::TargetEmpty {
width: w.max(0.0) as u32,
height: h.max(0.0) as u32,
});
}
let (w, h) = (w as u32, h as u32);
if w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return Err(Error::TargetTooLarge {
width: w,
height: h,
limit: MAX_TARGET_DIMENSION,
});
}
Ok((w, h))
}
#[must_use]
pub fn page_matrix(page: &Page, opts: &RenderOptions) -> Affine {
let (page_w, page_h) = page.display_size();
let device =
target_size(page, opts).map_or((page_w, page_h), |(w, h)| (f64::from(w), f64::from(h)));
let (dev_w, dev_h) = device;
if page_w <= 0.0 || page_h <= 0.0 || !dev_w.is_finite() || !dev_h.is_finite() {
return opts.transform * page.rotate.display_matrix(page.crop_box);
}
let fit = Affine::new([dev_w / page_w, 0.0, 0.0, -dev_h / page_h, 0.0, dev_h]);
fit * page.rotate.display_matrix(page.crop_box)
}
#[expect(
clippy::too_many_arguments,
reason = "the walk threads context, device, backend, caches and the \
visibility describing this list"
)]
pub fn render_object_list<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
objects: &[PageObject],
visible: &Visibility,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
if !ctx.may_recurse() {
return;
}
let cull = cull_rect(to_device, device_box);
for (index, object) in objects.iter().enumerate() {
if ctx.out_of_time() {
return;
}
if !visible.visible(index) {
continue;
}
if let Some(cull) = cull
&& crate::walkprofile::phase(crate::walkprofile::Phase::Cull, || culled(object, cull))
{
continue;
}
render_object(
ctx,
device,
backend,
caches,
object,
&visible.children(index),
to_device,
device_box,
diags,
);
}
}
fn cull_rect(to_device: Affine, device_box: Rect) -> Option<Rect> {
let det = to_device.determinant();
(det != 0.0 && det.is_finite())
.then(|| to_device.inverse().transform_rect_bbox(device_box))
.filter(|r| r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())
}
fn culled(object: &PageObject, cull: Rect) -> bool {
if let PageObject::Path(p) = object {
if let Some((inner, outer)) = path_cull_bounds(&p.object.path, p.object.matrix) {
if !outside(inner, cull) {
return false;
}
if outside(outer, cull) {
return true;
}
}
return outside(path_bbox(&p.object.path, p.object.matrix), cull);
}
let Some(bbox) = object_bbox(object) else {
return false;
};
outside(bbox, cull)
}
fn outside(bbox: Rect, cull: Rect) -> bool {
bbox.x0 > cull.x1 || bbox.x1 < cull.x0 || bbox.y0 > cull.y1 || bbox.y1 < cull.y0
}
fn path_cull_bounds(path: &kurbo::BezPath, matrix: Affine) -> Option<(Rect, Rect)> {
if !matches!(path.elements().first(), Some(kurbo::PathEl::MoveTo(_))) {
return None;
}
let mut inner: Option<Rect> = None;
let mut outer: Option<Rect> = None;
let mut finite = true;
let mut add = |bounds: &mut Option<Rect>, p: kurbo::Point| {
let p = matrix * p;
finite &= p.x.is_finite() && p.y.is_finite();
*bounds = Some(match *bounds {
Some(r) => Rect::new(r.x0.min(p.x), r.y0.min(p.y), r.x1.max(p.x), r.y1.max(p.y)),
None => Rect::new(p.x, p.y, p.x, p.y),
});
};
for el in path.elements() {
match *el {
kurbo::PathEl::MoveTo(p) => add(&mut outer, p),
kurbo::PathEl::LineTo(p) => {
add(&mut inner, p);
add(&mut outer, p);
}
kurbo::PathEl::QuadTo(c, p) => {
add(&mut inner, p);
add(&mut outer, c);
add(&mut outer, p);
}
kurbo::PathEl::CurveTo(c1, c2, p) => {
add(&mut inner, p);
add(&mut outer, c1);
add(&mut outer, c2);
add(&mut outer, p);
}
kurbo::PathEl::ClosePath => {}
}
}
if !finite {
return None;
}
Some((inner?, outer?))
}
fn object_bbox(object: &PageObject) -> Option<Rect> {
match object {
PageObject::Path(p) => Some(path_bbox(&p.object.path, p.object.matrix)),
PageObject::Image(i) => Some(i.object.matrix.transform_rect_bbox(unit_rect())),
PageObject::Shading(s) => Some(s.object.bounds),
PageObject::Form(f) => f
.object
.bbox
.map(|b| f.object.matrix.transform_rect_bbox(b)),
PageObject::Text(_) => None,
}
}
fn path_bbox(path: &kurbo::BezPath, matrix: Affine) -> Rect {
let mut bbox: Option<Rect> = None;
for seg in path.segments() {
let seg_bb = (matrix * seg).bounding_box();
bbox = Some(match bbox {
Some(bb) => bb.union(seg_bb),
None => seg_bb,
});
}
bbox.unwrap_or_default()
}
fn unit_rect() -> Rect {
Rect::new(0.0, 0.0, 1.0, 1.0)
}
#[expect(
clippy::too_many_arguments,
reason = "the walk threads context, device, backend, caches and a form's \
child visibility"
)]
pub fn render_object<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &PageObject,
children: &Visibility,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let state = object.state();
let clips = crate::walkprofile::phase(crate::walkprofile::Phase::Clip, || {
clip::resolve(&state.clip, to_device, &mut caches.glyphs, &ctx.opts)
});
let pushed = clip::push(device, &clips);
let initial_alpha = ctx.initial_fill.map_or(1.0, |_| 1.0);
let inputs = GroupInputs::of(object, initial_alpha);
if needs_offscreen(inputs) && ctx.may_recurse() {
render_grouped(
ctx, device, backend, caches, object, children, inputs, to_device, device_box, diags,
);
} else {
render_direct(
ctx, device, backend, caches, object, children, to_device, device_box, diags,
);
}
clip::pop(device, pushed);
}
#[expect(
clippy::too_many_arguments,
reason = "the group path needs every input the direct one had"
)]
fn render_grouped<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &PageObject,
children: &Visibility,
inputs: GroupInputs,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let state = object.state();
let bbox = object_bbox(object)
.map_or(device_box, |b| to_device.transform_rect_bbox(b))
.intersect(device_box);
let rect = outer_rect(bbox).intersect(outer_rect(device_box));
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return;
}
let transparency = match object {
PageObject::Form(f) => f.object.transparency,
_ => ctx.transparency,
};
let (mut sub, initial_backdrop) = if needs_backdrop(transparency) {
let backdrop = backend.snapshot(device);
let cropped = crop(&backdrop, rect);
(backend.new_target_with_backdrop(&cropped), Some(cropped))
} else {
(backend.new_target(w, h, peniko::Color::TRANSPARENT), None)
};
let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
let inner_ctx = RenderCtx {
transparency,
in_group: true,
initial_fill: None,
initial_stroke: None,
..ctx.deeper()
};
let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
render_direct(
&inner_ctx,
&mut sub,
backend,
caches,
object,
children,
offset * to_device,
inner_box,
diags,
);
let mut pixels = backend.finish(sub);
if let Some(backdrop) = &initial_backdrop {
pixels.remove_backdrop(backdrop);
}
if let Some(mask) = &state.general.soft_mask {
let rendered = render_soft_mask(&inner_ctx, backend, caches, mask, rect, to_device, diags);
if let Some(m) = rendered {
pixels.multiply_alpha_mask(&m);
}
}
GroupFinish::of(inputs, transparency, ctx.in_group).apply(&mut pixels);
device.push_layer(state.general.blend, 1.0, None);
device.draw_image(
&pixels,
Affine::translate((f64::from(rect.left), f64::from(rect.top))),
ImageQuality::Nearest,
1.0,
);
device.pop();
}
fn render_soft_mask<B: RasterBackend>(
ctx: &RenderCtx<'_>,
backend: &B,
caches: &mut RenderCaches,
mask: &pdfrum_page::SoftMask,
rect: IntRect,
to_device: Affine,
diags: &mut Diagnostics,
) -> Option<crate::pixmap::AlphaMask> {
if !ctx.may_recurse() {
return None;
}
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return None;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return None;
}
let mut device = backend.new_target(w, h, crate::softmask::backdrop(mask));
if !mask.objects.is_empty() {
let inner = RenderCtx {
opts: RenderOptions {
color_mode: match mask.kind {
pdfrum_page::SoftMaskKind::Alpha => crate::options::ColorMode::Alpha,
pdfrum_page::SoftMaskKind::Luminosity => crate::options::ColorMode::Normal,
},
..ctx.opts.clone()
},
initial_fill: None,
initial_stroke: None,
type3: None,
in_group: true,
..ctx.deeper()
};
let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
render_object_list(
&inner,
&mut device,
backend,
caches,
&mask.objects,
&Visibility::all_visible(),
offset * to_device,
Rect::new(0.0, 0.0, f64::from(w), f64::from(h)),
diags,
);
}
let rendered = backend.finish(device);
Some(crate::softmask::readback(mask, &rendered))
}
fn crop(source: &Pixmap, rect: IntRect) -> Pixmap {
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return Pixmap::new(0, 0);
};
let mut out = Pixmap::new(w, h);
for y in 0..h {
for x in 0..w {
let (sx, sy) = (
i64::from(x) + i64::from(rect.left),
i64::from(y) + i64::from(rect.top),
);
let (Ok(sx), Ok(sy)) = (u32::try_from(sx), u32::try_from(sy)) else {
continue;
};
if let Some(px) = source.pixel(sx, sy) {
out.set_pixel(x, y, px);
}
}
}
out
}
#[expect(
clippy::too_many_arguments,
reason = "the walk threads context, device, backend and caches"
)]
fn render_direct<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &PageObject,
children: &Visibility,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
match object {
PageObject::Path(p) => render_path(
ctx, device, backend, caches, &p.object, &p.state, to_device, device_box, diags,
),
PageObject::Text(t) => render_text(
ctx, device, backend, caches, &t.object, &t.state, to_device, device_box, diags,
),
PageObject::Image(i) => render_image(
ctx, device, backend, caches, &i.object, &i.state, to_device, device_box, diags,
),
PageObject::Shading(s) => render_shading(
ctx, device, backend, &s.object, &s.state, to_device, device_box,
),
PageObject::Form(f) => {
let inner = RenderCtx {
opts: form_options(&ctx.opts, f.object.live_edit),
initial_fill: ctx.initial_fill,
initial_stroke: ctx.initial_stroke,
..ctx.deeper()
};
if f.object.transparency.knockout {
render_knockout_form(
&inner,
device,
backend,
caches,
&f.object.objects,
children,
to_device,
device_box,
diags,
);
return;
}
render_object_list(
&inner,
device,
backend,
caches,
&f.object.objects,
children,
to_device,
device_box,
diags,
);
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "the knockout path needs every input the ordinary walk had"
)]
fn render_knockout_form<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
objects: &[PageObject],
children: &Visibility,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let rect = outer_rect(device_box);
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
render_object_list(
ctx, device, backend, caches, objects, children, to_device, device_box, diags,
);
return;
}
let mut result: Option<crate::pixmap::Pixmap> = None;
let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
for (index, object) in objects.iter().enumerate() {
if !children.visible(index) {
continue;
}
let mut sub = backend.new_target(w, h, peniko::Color::TRANSPARENT);
render_object(
ctx,
&mut sub,
backend,
caches,
object,
children,
offset * to_device,
inner_box,
diags,
);
let drawn = backend.finish(sub);
match &mut result {
None => result = Some(drawn),
Some(acc) => acc.knockout_over(&drawn),
}
}
if let Some(pixels) = result {
device.draw_image(
&pixels,
Affine::translate((f64::from(rect.left), f64::from(rect.top))),
ImageQuality::Nearest,
1.0,
);
}
}
fn form_options(opts: &RenderOptions, live_edit: bool) -> RenderOptions {
if live_edit && opts.text_aa_override.is_none() {
opts.for_text_run(crate::options::TextAa::LcdSubpixel)
} else {
opts.clone()
}
}
fn colors(
ctx: &RenderCtx<'_>,
state: &pdfrum_page::GraphicsState,
kind: ObjectKind,
) -> (crate::color::Argb, crate::color::Argb) {
crate::walkprofile::phase(crate::walkprofile::Phase::Color, || {
colors_inner(ctx, state, kind)
})
}
fn colors_inner(
ctx: &RenderCtx<'_>,
state: &pdfrum_page::GraphicsState,
kind: ObjectKind,
) -> (crate::color::Argb, crate::color::Argb) {
let transfer = state
.general
.transfer
.as_ref()
.map(|t| TransferFunc::new(t));
let fill = match ctx.type3 {
Some(frame) if !frame.colored || state.fill.to_rgb().is_none() => frame.fill,
_ => resolve_argb(
&state.fill,
state.general.fill_alpha,
transfer.as_ref(),
ctx.initial_fill,
&ctx.opts,
kind,
false,
),
};
let stroke = resolve_argb(
&state.stroke,
state.general.stroke_alpha,
transfer.as_ref(),
ctx.initial_stroke,
&ctx.opts,
kind,
true,
);
(fill, stroke)
}
#[expect(
clippy::too_many_arguments,
reason = "painting a pattern needs the context, device, backend, caches, \
the object's state, which colour names it, its geometry, and \
the page transform"
)]
fn paint_pattern<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
state: &pdfrum_page::GraphicsState,
stroking: bool,
geometry: &PatternClip<'_>,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let color = if stroking { &state.stroke } else { &state.fill };
let Some(value) = color.pattern.as_ref() else {
return;
};
let Some(pattern) = value.loaded.as_ref() else {
return;
};
let alpha = if stroking {
state.general.stroke_alpha
} else {
state.general.fill_alpha
};
let colored_tiling = matches!(&**pattern, pdfrum_page::Pattern::Tiling(t) if t.colored);
let space = color
.space
.as_ref()
.map_or(&pdfrum_page::ColorSpace::DeviceGray, |s| &**s);
let rgb = pdfrum_page::uncolored_pattern_rgb(space, &value.components, colored_tiling);
let [r, g, b] = rgb.to_bytes();
let uncolored = Argb {
a: alpha_byte_truncating(alpha),
r,
g,
b,
};
crate::pattern::draw(
ctx, device, backend, caches, pattern, geometry, to_device, device_box, alpha, uncolored,
diags,
);
}
#[expect(
clippy::too_many_arguments,
reason = "the pattern arm needs the caches, device box and diagnostics the \
ordinary draw does not"
)]
fn render_path<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::PathObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let (fill, stroke) = colors(ctx, state, ObjectKind::Path);
let mut fills = object.fill_rule != pdfrum_page::FillRule::None;
let mut strokes = object.stroke;
let matrix = to_device * object.matrix;
if fills && state.fill.is_pattern() {
fills = false;
paint_pattern(
ctx,
device,
backend,
caches,
state,
false,
&PatternClip::Path {
path: &object.path,
to_device: matrix,
stroking: false,
rule: object.fill_rule.into(),
stroke: &state.stroke_params,
},
to_device,
device_box,
diags,
);
}
if strokes && state.stroke.is_pattern() {
strokes = false;
paint_pattern(
ctx,
device,
backend,
caches,
state,
true,
&PatternClip::Path {
path: &object.path,
to_device: matrix,
stroking: true,
rule: object.fill_rule.into(),
stroke: &state.stroke_params,
},
to_device,
device_box,
diags,
);
}
if !fills && !strokes {
return;
}
let (fills, strokes) = if matches!(ctx.opts.color_mode, crate::options::ColorMode::Forced(_))
&& ctx.opts.convert_fill_to_stroke
&& fills
{
(false, true)
} else {
(fills, strokes)
};
let paint = PathPaint {
fill: fills.then_some(fill),
stroke: strokes.then_some(stroke),
rule: object.fill_rule.into(),
text_mode: false,
};
draw_path(
device,
backend,
&object.path,
to_device * object.matrix,
paint,
&state.stroke_params,
&ctx.opts,
&mut caches.zero_area,
);
}
#[expect(
clippy::too_many_arguments,
reason = "a synthetic path object needs everything the real one does"
)]
fn render_pattern_text<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::TextObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let Some(rect) = crate::text::run_rect(object, state) else {
return;
};
let mut clip = pdfrum_page::ClipStack::new();
if clip
.push_text(vec![pdfrum_page::TextClipRun {
object: object.clone(),
char_space: state.text.char_space,
word_space: state.text.word_space,
}])
.is_err()
{
return;
}
let synthetic = pdfrum_page::GraphicsState {
clip: pdfrum_page::ClipStack::new(),
ctm: Affine::IDENTITY,
..state.clone()
};
let clips = clip::resolve(&clip, to_device, &mut caches.glyphs, &ctx.opts);
let pushed = clip::push(device, &clips);
render_path::<B>(
ctx,
device,
backend,
caches,
&pdfrum_page::PathObject {
path: kurbo::Shape::to_path(&rect, 0.1),
matrix: Affine::IDENTITY,
fill_rule: pdfrum_page::FillRule::Winding,
stroke: false,
},
&synthetic,
to_device,
device_box,
diags,
);
clip::pop(device, pushed);
}
#[expect(
clippy::too_many_arguments,
reason = "a synthetic path object needs everything the real one does, plus \
the paint kinds the run resolved to"
)]
fn render_pattern_text_stroked<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::TextObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
kinds: crate::text::TextPaintKinds,
) {
let mut glyphs = std::mem::take(&mut caches.placed_glyphs);
crate::text::place_glyphs_into(
&mut glyphs,
object,
state,
&mut caches.glyphs,
to_device,
&ctx.opts,
kinds,
);
let fill_rule = if kinds.fill {
pdfrum_page::FillRule::Winding
} else {
pdfrum_page::FillRule::None
};
for glyph in &glyphs {
let path = glyph.device_path();
render_path::<B>(
ctx,
device,
backend,
caches,
&pdfrum_page::PathObject {
path,
matrix: Affine::IDENTITY,
fill_rule,
stroke: true,
},
state,
to_device,
device_box,
diags,
);
}
caches.placed_glyphs = glyphs;
}
#[expect(
clippy::too_many_arguments,
reason = "the type-3 arm needs the device box and diagnostics the ordinary \
glyph draw does not"
)]
fn render_text<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::TextObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let Some((font, _)) = &object.font else {
return;
};
let Some(kinds) = paint_kinds(object.render_mode, has_face(font)) else {
return;
};
if !kinds.fill && !kinds.stroke {
return; }
if font.type3().is_some() {
render_type3_text(
ctx, device, backend, caches, object, state, to_device, device_box, diags,
);
return;
}
let pattern_run =
(kinds.stroke && state.stroke.is_pattern()) || (kinds.fill && state.fill.is_pattern());
if pattern_run {
if kinds.stroke {
render_pattern_text_stroked(
ctx, device, backend, caches, object, state, to_device, device_box, diags, kinds,
);
} else {
render_pattern_text(
ctx, device, backend, caches, object, state, to_device, device_box, diags,
);
}
return;
}
let (fill, stroke) = colors(ctx, state, ObjectKind::Text);
let mut glyphs = std::mem::take(&mut caches.placed_glyphs);
crate::walkprofile::phase(crate::walkprofile::Phase::Glyphs, || {
crate::text::place_glyphs_into(
&mut glyphs,
object,
state,
&mut caches.glyphs,
to_device,
&ctx.opts,
kinds,
);
});
let stroke_device = kinds
.stroke
.then(|| stroke_text_matrices(object, state, to_device).1);
for glyph in &glyphs {
if let Some(placement) = glyph.bitmap
&& kinds.fill
&& !kinds.stroke
{
draw_glyph_bitmap(
device,
GlyphBlitCaches {
bitmaps: &mut caches.glyph_bitmaps,
scratch: &mut caches.glyph_blit,
},
font,
glyph,
placement,
fill,
ctx.opts.effective_text_aa(),
);
continue;
}
let paint = PathPaint {
fill: kinds.fill.then_some(fill),
stroke: kinds.stroke.then_some(stroke),
rule: crate::device::FillRule::Winding,
text_mode: true,
};
if let Some(device_m) = stroke_device {
let path = glyph.device_path();
draw_path(
device,
backend,
&path,
device_m,
paint,
&state.stroke_params,
&ctx.opts,
&mut caches.zero_area,
);
} else {
draw_path(
device,
backend,
&glyph.outline,
glyph.matrix,
paint,
&state.stroke_params,
&ctx.opts,
&mut caches.zero_area,
);
}
}
caches.placed_glyphs = glyphs;
}
struct GlyphBlitCaches<'a> {
bitmaps: &'a mut crate::glyph::BitmapCache,
scratch: &'a mut crate::ctx::GlyphBlitScratch,
}
fn draw_glyph_bitmap(
device: &mut dyn RenderDevice,
caches: GlyphBlitCaches<'_>,
font: &pdfrum_font::Font,
glyph: &crate::text::PlacedGlyph,
placement: crate::text::BitmapPlacement,
fill: Argb,
text_aa: crate::options::TextAa,
) {
if fill.is_invisible() {
return;
}
let [a, b, c, d, _, _] = glyph.matrix.as_coeffs();
let shape = Affine::new([a, b, c, d, 0.0, 0.0]);
let key = crate::glyph::BitmapKey::new(glyph.key, shape);
let GlyphBlitCaches { bitmaps, scratch } = caches;
let Some(lcd) = bitmaps.get_or_insert(key, || {
let outline = font
.hinted_glyph_path(glyph.key.gid)
.unwrap_or_else(|| (*glyph.outline).clone());
crate::glyph::render_lcd(&(shape * outline))
}) else {
return;
};
let corner = |left: i32, top: i32| {
(
placement.origin.x + f64::from(left),
placement.origin.y + f64::from(top),
)
};
if matches!(text_aa, crate::options::TextAa::LcdSubpixel) {
let bitmap = lcd.to_subpixel(placement.phase);
if bitmap.is_empty() {
return;
}
device.draw_glyph_lcd(&bitmap, corner(bitmap.left, bitmap.top), fill.to_peniko());
return;
}
let (left, top) = (lcd.left, lcd.top);
if !crate::glyph::recolour_glyph_into(&lcd, placement.phase, fill.to_peniko(), scratch) {
return;
}
device.draw_image(
&scratch.pixels,
Affine::translate(corner(left, top)),
ImageQuality::Nearest,
1.0,
);
}
#[must_use]
fn sole_color_image(objects: &[PageObject]) -> bool {
matches!(objects, [PageObject::Image(i)] if !i.object.is_mask)
}
#[expect(
clippy::too_many_arguments,
reason = "a glyph procedure needs the context, device, backend, caches, \
the text object, its state, and the page transform"
)]
fn render_type3_text<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::TextObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let Some((font, _)) = &object.font else {
return;
};
if ctx.type3_font_is_active(font.id()) || !ctx.may_recurse() {
return;
}
let fill = resolve_argb(
&state.fill,
state.general.fill_alpha,
state
.general
.transfer
.as_ref()
.map(|t| TransferFunc::new(t))
.as_ref(),
ctx.initial_fill,
&ctx.opts,
ObjectKind::Text,
false,
);
if fill.is_invisible() {
return;
}
let mut ancestry: Vec<pdfrum_font::FontId> = ctx.type3_fonts.to_vec();
ancestry.push(font.id());
for placed in crate::text::place_type3_chars(object, state, to_device) {
let Some(metrics) = object.type3_metrics.get(&placed.code) else {
continue;
};
if metrics.objects.is_empty() || !is_available_matrix(placed.matrix) {
continue;
}
if !metrics.colored && sole_color_image(&metrics.objects) {
continue;
}
let inner = RenderCtx {
opts: ctx.opts.for_type3_char_proc(),
type3: Some(crate::ctx::Type3Frame {
fill,
colored: metrics.colored,
}),
type3_fonts: &ancestry,
initial_fill: Some(fill),
initial_stroke: Some(fill),
..ctx.deeper()
};
if fill.a == 255 {
render_object_list(
&inner,
device,
backend,
caches,
&metrics.objects,
&Visibility::all_visible(), placed.matrix,
device_box,
diags,
);
continue;
}
render_translucent_char_proc(
&inner, device, backend, caches, metrics, &placed, fill, device_box, diags,
);
}
}
#[expect(
clippy::too_many_arguments,
reason = "the same inputs the opaque path takes, plus the placed glyph and \
the colour its alpha is read from"
)]
fn render_translucent_char_proc<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
metrics: &pdfrum_page::Type3Metrics,
placed: &crate::text::PlacedType3Char,
fill: Argb,
device_box: Rect,
diags: &mut Diagnostics,
) {
let bbox = placed
.matrix
.transform_rect_bbox(metrics.painted)
.intersect(device_box);
let rect = outer_rect(bbox).intersect(outer_rect(device_box));
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return;
}
let mut sub = backend.new_target(w, h, peniko::Color::TRANSPARENT);
let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
let translucent = crate::ctx::Type3Frame {
fill,
colored: metrics.colored,
};
let inner = RenderCtx {
type3: Some(translucent),
initial_fill: Some(fill),
initial_stroke: Some(fill),
..ctx.clone()
};
render_object_list(
&inner,
&mut sub,
backend,
caches,
&metrics.objects,
&Visibility::all_visible(), offset * placed.matrix,
Rect::new(0.0, 0.0, f64::from(w), f64::from(h)),
diags,
);
let pixels = backend.finish(sub);
device.draw_image(
&pixels,
Affine::translate((f64::from(rect.left), f64::from(rect.top))),
ImageQuality::Nearest,
1.0,
);
}
#[expect(
clippy::cast_possible_truncation,
reason = "`image_value_fits` rejects a non-finite extent and anything at \
or above MAX_IMAGE_VALUE (2^28), so both rounded values are \
well inside i64"
)]
fn mask_quality(
image: &pdfrum_page::ImageData,
opts: &RenderOptions,
extent: Rect,
) -> ImageQuality {
if !crate::image::image_value_fits(extent.width())
|| !crate::image::image_value_fits(extent.height())
{
return ImageQuality::Nearest;
}
resample_quality(
image,
opts,
image.width,
image.height,
extent.width().round() as i64,
extent.height().round() as i64,
)
}
#[expect(
clippy::too_many_arguments,
reason = "the stencil path needs the context, device, backend, caches, the \
image, its state and the page transform"
)]
fn render_pattern_stencil<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::ImageObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
if !ctx.may_recurse() {
return;
}
let matrix = to_device * object.matrix;
let bbox = matrix
.transform_rect_bbox(unit_rect())
.intersect(device_box);
let rect = outer_rect(bbox).intersect(outer_rect(device_box));
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return;
}
let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
let inner_box = Rect::new(0.0, 0.0, f64::from(w), f64::from(h));
let mut pattern_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
let inner = RenderCtx { ..ctx.deeper() };
paint_pattern(
&inner,
&mut pattern_target,
backend,
caches,
state,
false,
&PatternClip::Rect(inner_box),
offset * to_device,
inner_box,
diags,
);
let mut pixels = backend.finish(pattern_target);
let stencil = to_pixmap(&object.image, Argb::opaque(255, 255, 255), None);
if stencil.width() == 0 || stencil.height() == 0 {
return;
}
let placement = offset
* matrix
* Affine::new([
1.0 / f64::from(object.image.width),
0.0,
0.0,
-1.0 / f64::from(object.image.height),
0.0,
1.0,
]);
let extent = placement.transform_rect_bbox(Rect::new(
0.0,
0.0,
f64::from(object.image.width),
f64::from(object.image.height),
));
let quality = mask_quality(&object.image, &ctx.opts, extent);
let (stencil, placement) =
match crate::stretch::prescale(&stencil, placement, extent.width(), extent.height()) {
Some((reduced, t)) => (reduced, t),
None => (stencil, placement),
};
let mut mask_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
mask_target.draw_image(
&stencil,
placement,
effective_quality(quality, placement),
1.0,
);
let mask = backend.finish(mask_target).alpha_mask();
pixels.multiply_alpha_mask(&mask);
let blend = overprint_blend(None, &state.general);
let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
if layered {
device.push_layer(blend, 1.0, None);
}
device.draw_image(
&pixels,
Affine::translate((f64::from(rect.left), f64::from(rect.top))),
ImageQuality::Nearest,
1.0,
);
if layered {
device.pop();
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "`image_value_fits` has already rejected a non-finite extent and \
anything at or above MAX_IMAGE_VALUE (2^28), so both rounded \
values are well inside i64"
)]
#[expect(
clippy::too_many_arguments,
reason = "the pattern-stencil arm needs the backend, caches, device box \
and diagnostics the ordinary image draw does not"
)]
fn render_image<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
object: &pdfrum_page::ImageObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
diags: &mut Diagnostics,
) {
let matrix = to_device * object.matrix;
if !is_available_matrix(matrix) {
return;
}
if object.is_mask && state.fill.is_pattern() {
render_pattern_stencil(
ctx, device, backend, caches, object, state, to_device, device_box, diags,
);
return;
}
if object
.image
.mask
.as_ref()
.is_some_and(|m| !crate::image::is_coregistered(m, &object.image))
{
render_masked_image(ctx, device, backend, object, state, to_device, device_box);
return;
}
let (fill, _) = colors(ctx, state, ObjectKind::Other);
let image = &object.image;
let transfer = state
.general
.transfer
.as_ref()
.map(|t| TransferFunc::new(t));
if image.width == 0 || image.height == 0 {
return;
}
let placement = matrix
* Affine::new([
1.0 / f64::from(image.width),
0.0,
0.0,
-1.0 / f64::from(image.height),
0.0,
1.0,
]);
let corners = placement.transform_rect_bbox(Rect::new(
0.0,
0.0,
f64::from(image.width),
f64::from(image.height),
));
if !crate::image::image_value_fits(corners.width())
|| !crate::image::image_value_fits(corners.height())
{
return;
}
let quality = resample_quality(
image,
&ctx.opts,
image.width,
image.height,
corners.width().round() as i64,
corners.height().round() as i64,
);
let reduction = crate::stretch::reduction(
placement,
image.width,
image.height,
corners.width(),
corners.height(),
ctx.type3.is_none(),
);
let (out_w, out_h) = reduction.size(image.width, image.height);
let placement = reduction.transform();
let pixels = crate::walkprofile::phase(crate::walkprofile::Phase::Image, || {
let key = crate::imagecache::PixmapRequest::for_image(
image,
fill,
transfer.as_ref(),
out_w,
out_h,
);
caches.images.get_or_render(object.source, key, || {
if reduction.filters() {
crate::stretch::convert_and_reduce(image, fill, transfer.as_ref(), out_w, out_h)
} else {
to_pixmap(image, fill, transfer.as_ref())
}
})
});
let pixels = &*pixels;
let blend = overprint_blend(None, &state.general);
let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
if layered {
device.push_layer(blend, 1.0, None);
}
let placed = image_placement(placement, pixels, ctx.type3.is_some());
device.draw_image(
pixels,
placed.transform_for(pixels.width(), pixels.height()),
placed.quality(effective_quality(quality, placement)),
state.general.fill_alpha,
);
if layered {
device.pop();
}
}
fn image_placement(
placement: Affine,
pixels: &crate::pixmap::Pixmap,
in_type3: bool,
) -> crate::stretch::Placement {
match crate::stretch::placement_for(placement, pixels.width(), pixels.height()) {
crate::stretch::Placement::Snapped(_) if in_type3 => {
crate::stretch::Placement::Filtered(placement)
}
other => other,
}
}
fn render_masked_image<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
object: &pdfrum_page::ImageObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
) {
let image = &object.image;
let Some(mask) = image.mask.as_ref() else {
return;
};
let Some((mask_dict, mask_plane)) = crate::image::separate_mask(mask) else {
return;
};
let matrix = to_device * object.matrix;
let bbox = matrix
.transform_rect_bbox(unit_rect())
.intersect(device_box);
let rect = outer_rect(bbox).intersect(outer_rect(device_box));
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return;
}
let offset = Affine::translate((-f64::from(rect.left), -f64::from(rect.top)));
let (fill, _) = colors(ctx, state, ObjectKind::Other);
let transfer = state
.general
.transfer
.as_ref()
.map(|t| TransferFunc::new(t));
let base = to_pixmap(image, fill, transfer.as_ref());
if base.width() == 0 || base.height() == 0 {
return;
}
let mut base_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
let base_placement = offset * matrix * sample_grid(image.width, image.height);
let base_extent = base_placement.transform_rect_bbox(Rect::new(
0.0,
0.0,
f64::from(image.width),
f64::from(image.height),
));
let base_quality = mask_quality(image, &ctx.opts, base_extent);
let (base, base_placement) = match crate::stretch::prescale(
&base,
base_placement,
base_extent.width(),
base_extent.height(),
) {
Some((reduced, t)) => (reduced, t),
None => (base, base_placement),
};
base_target.draw_image(
&base,
base_placement,
effective_quality(base_quality, base_placement),
1.0,
);
let mut pixels = backend.finish(base_target);
let mask_placement = offset * matrix * sample_grid(mask_dict.width, mask_dict.height);
let mask_extent = mask_placement.transform_rect_bbox(Rect::new(
0.0,
0.0,
f64::from(mask_dict.width),
f64::from(mask_dict.height),
));
let mask_q = mask_quality(&mask_dict, &ctx.opts, mask_extent);
let (mask_pixels, mask_placement) = crate::image::reduced_mask_pixmap(
&mask_plane,
mask_dict.width,
mask_dict.height,
mask_placement,
mask_extent.width(),
mask_extent.height(),
);
let mut mask_target = backend.new_target(w, h, peniko::Color::TRANSPARENT);
mask_target.draw_image(
&mask_pixels,
mask_placement,
effective_quality(mask_q, mask_placement),
1.0,
);
pixels.multiply_alpha_mask(&backend.finish(mask_target).alpha_mask());
let blend = overprint_blend(None, &state.general);
let layered = !matches!(blend, pdfrum_page::BlendMode::Normal);
if layered {
device.push_layer(blend, 1.0, None);
}
device.draw_image(
&pixels,
Affine::translate((f64::from(rect.left), f64::from(rect.top))),
ImageQuality::Nearest,
state.general.fill_alpha,
);
if layered {
device.pop();
}
}
fn sample_grid(width: u32, height: u32) -> Affine {
Affine::new([
1.0 / f64::from(width),
0.0,
0.0,
-1.0 / f64::from(height),
0.0,
1.0,
])
}
fn render_shading<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
object: &pdfrum_page::ShadingObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
device_box: Rect,
) {
let matrix = to_device * object.matrix;
if !is_available_matrix(matrix) {
return;
}
let alpha = alpha_byte_rounding(state.general.fill_alpha);
let mut bbox = if object.bounds.is_zero_area() {
device_box
} else {
to_device.transform_rect_bbox(object.bounds)
};
if let Some(b) = object.shading.bbox {
bbox = bbox.intersect(matrix.transform_rect_bbox(b));
}
let rect = outer_rect(bbox.intersect(device_box));
if !rect.is_valid() {
return;
}
draw_shading_into(ctx, device, backend, &object.shading, rect, matrix, alpha);
}
pub(crate) fn draw_shading_into<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
shading: &pdfrum_page::Shading,
rect: IntRect,
matrix: Affine,
alpha: u8,
) {
let at = Affine::translate((f64::from(rect.left), f64::from(rect.top)));
match shading.kind() {
pdfrum_page::ShadingKind::CoonsMesh | pdfrum_page::ShadingKind::TensorMesh => {
let tensor = shading.kind() == pdfrum_page::ShadingKind::TensorMesh;
let (Ok(w), Ok(h)) = (u32::try_from(rect.width()), u32::try_from(rect.height())) else {
return;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return;
}
let mut scratch = backend.new_target(w, h, peniko::Color::TRANSPARENT);
crate::walkprofile::phase(crate::walkprofile::Phase::Patches, || {
shading::draw_patches(&mut scratch, shading, rect, matrix, tensor);
});
let pixels = backend.finish(scratch);
device.draw_image(&pixels, at, ImageQuality::Nearest, f32::from(alpha) / 255.0);
}
_ => {
let Some(pixels) =
crate::walkprofile::phase(crate::walkprofile::Phase::Shading, || {
shading::draw_to_pixmap(shading, rect, matrix, alpha, &ctx.opts)
})
else {
return;
};
device.draw_image(&pixels, at, ImageQuality::Nearest, 1.0);
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use kurbo::BezPath;
use pdfrum_page::ContentMarks;
use pdfrum_page::{Content, GraphicsState, PathObject};
use super::*;
fn path_object(path: BezPath, state: GraphicsState) -> PageObject {
PageObject::Path(Box::new(Content {
object: PathObject {
path,
matrix: Affine::IDENTITY,
fill_rule: pdfrum_page::FillRule::Winding,
stroke: false,
},
state,
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))
}
fn path_object_with_matrix(path: BezPath, matrix: Affine) -> PageObject {
PageObject::Path(Box::new(Content {
object: PathObject {
path,
matrix,
fill_rule: pdfrum_page::FillRule::Winding,
stroke: false,
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}))
}
fn rect(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
}
#[test]
fn cull_uses_strict_inequalities() {
let cull = Rect::new(0.0, 0.0, 10.0, 10.0);
let touching = path_object(rect(10.0, 0.0, 20.0, 5.0), GraphicsState::default());
assert!(!culled(&touching, cull));
let beyond = path_object(rect(10.1, 0.0, 20.0, 5.0), GraphicsState::default());
assert!(culled(&beyond, cull));
}
#[test]
fn the_cull_bracket_never_changes_the_answer() {
let mut bulging = BezPath::new();
bulging.move_to((0.0, 0.0));
bulging.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
let mut two_moves = BezPath::new();
two_moves.move_to((100.0, 100.0)); two_moves.move_to((0.0, 0.0));
two_moves.line_to((1.0, 1.0));
let mut bare_move = BezPath::new();
bare_move.move_to((50.0, 50.0));
let mut move_close = BezPath::new();
move_close.move_to((50.0, 50.0));
move_close.close_path();
let shapes = [
rect(0.0, 0.0, 10.0, 10.0),
bulging,
two_moves,
bare_move,
move_close,
BezPath::new(),
];
let matrices = [
Affine::IDENTITY,
Affine::translate((3.0, -4.0)),
Affine::new([2.0, 0.5, -0.5, 2.0, 1.0, 1.0]),
];
for shape in &shapes {
for matrix in matrices {
let object = path_object_with_matrix(shape.clone(), matrix);
let exact = path_bbox(shape, matrix);
let mut x = -12.0;
while x < 14.0 {
let mut y = -12.0;
while y < 14.0 {
let cull = Rect::new(x, y, x + 4.0, y + 4.0);
assert_eq!(
culled(&object, cull),
outside(exact, cull),
"bracket disagreed at {cull:?} on {shape:?} under {matrix:?}"
);
y += 0.5;
}
x += 0.5;
}
}
}
}
#[test]
fn a_degenerate_clip_and_a_non_finite_point_agree_with_the_exact_answer() {
let mut nan_endpoint = BezPath::new();
nan_endpoint.move_to((100.0, 100.0));
nan_endpoint.line_to((f64::NAN, 130.0));
let mut nan_midpoint = BezPath::new();
nan_midpoint.move_to((100.0, 100.0));
nan_midpoint.line_to((f64::NAN, 130.0));
nan_midpoint.line_to((105.0, 105.0));
let mut nan_control = BezPath::new();
nan_control.move_to((100.0, 100.0));
nan_control.curve_to((f64::NAN, 110.0), (120.0, 110.0), (130.0, 100.0));
let mut infinite_endpoint = BezPath::new();
infinite_endpoint.move_to((100.0, 100.0));
infinite_endpoint.line_to((f64::INFINITY, 100.0));
let mut ordinary = BezPath::new();
ordinary.move_to((100.0, 100.0));
ordinary.line_to((130.0, 130.0));
let shapes = [
nan_endpoint.clone(),
nan_midpoint,
nan_control.clone(),
infinite_endpoint.clone(),
ordinary.clone(),
rect(0.0, 0.0, 2.0, 2.0),
];
for shape in &shapes {
for matrix in [
Affine::IDENTITY,
Affine::new([2.0, 0.5, -0.5, 2.0, 1.0, 1.0]),
] {
let object = path_object_with_matrix(shape.clone(), matrix);
let exact = path_bbox(shape, matrix);
let mut x = -20.0;
while x < 140.0 {
let mut y = 90.0;
while y < 140.0 {
for (w, h) in [(0.0, 0.0), (30.0, 30.0)] {
let cull = Rect::new(x, y, x + w, y + h);
assert_eq!(
culled(&object, cull),
outside(exact, cull),
"bracket disagreed at {cull:?} on {shape:?} under {matrix:?}"
);
}
y += 2.5;
}
x += 2.5;
}
let degenerate = Rect::new(1.0, 1.0, 1.0, 1.0);
assert_eq!(
culled(&object, degenerate),
outside(exact, degenerate),
"bracket disagreed on the degenerate clip"
);
}
}
for declined in [&nan_endpoint, &nan_control, &infinite_endpoint] {
assert!(
path_cull_bounds(declined, Affine::IDENTITY).is_none(),
"a non-finite coordinate must decline the bracket: {declined:?}"
);
}
assert!(
path_cull_bounds(&ordinary, Affine::IDENTITY).is_some(),
"a finite path must still take the bracket"
);
assert!(
path_cull_bounds(&ordinary, Affine::translate((f64::NAN, 0.0))).is_none(),
"a non-finite matrix must decline the bracket"
);
}
#[test]
fn the_allocation_free_bbox_reproduces_the_transformed_clone_exactly() {
let mut curved = BezPath::new();
curved.move_to((0.0, 0.0));
curved.curve_to((10.0, 40.0), (30.0, -20.0), (40.0, 10.0));
let mut quad = BezPath::new();
quad.move_to((-3.0, 2.0));
quad.quad_to((50.0, 60.0), (7.0, -8.0));
let mut bare_move = BezPath::new();
bare_move.move_to((5.0, 7.0));
let paths = [
rect(1.0, 2.0, 3.0, 4.0),
rect(-9.0, -9.0, -1.0, -1.0),
curved,
quad,
bare_move,
BezPath::new(),
];
let matrices = [
Affine::IDENTITY,
Affine::scale(2.5),
Affine::translate((13.0, -7.0)),
Affine::rotate(0.7),
Affine::scale_non_uniform(-1.0, 3.0),
];
for path in &paths {
for m in matrices {
let want = (m * path.clone()).bounding_box();
let got = path_bbox(path, m);
assert_eq!(want, got, "path {path:?} under {m:?}");
}
}
}
#[test]
fn a_text_object_is_never_culled() {
let obj = PageObject::Text(Box::new(Content {
object: pdfrum_page::TextObject {
segments: Box::new([]),
position: kurbo::Point::ZERO,
matrix: Affine::IDENTITY,
font: None,
font_source: None,
render_mode: pdfrum_page::TextRenderMode::Fill,
type3_metrics: BTreeMap::default(),
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(0),
dirty: false,
active: true,
}));
assert!(!culled(&obj, Rect::new(1000.0, 1000.0, 1001.0, 1001.0)));
}
#[test]
fn a_degenerate_matrix_yields_no_cull_rect() {
assert!(cull_rect(Affine::new([0.0; 6]), Rect::new(0.0, 0.0, 10.0, 10.0)).is_none());
}
#[test]
fn target_size_rejects_an_empty_page() {
let mut page = Page::empty();
page.crop_box = Rect::new(0.0, 0.0, 0.0, 0.0);
page.media_box = page.crop_box;
let err = target_size(&page, &RenderOptions::default()).expect_err("empty");
assert!(matches!(err, Error::TargetEmpty { .. }));
}
#[test]
fn target_size_truncates_rather_than_rounding_up() {
let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
let page = Page {
media_box: a4,
crop_box: a4,
..Page::empty()
};
assert_eq!(
target_size(&page, &RenderOptions::default()).expect("renderable"),
(595, 841)
);
}
#[test]
fn the_page_matrix_fits_the_page_to_the_truncated_device_box() {
let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
let page = Page {
media_box: a4,
crop_box: a4,
..Page::empty()
};
let m = page_matrix(&page, &RenderOptions::default());
let bottom = m * kurbo::Point::new(0.0, 0.0);
let top = m * kurbo::Point::new(595.276, 841.89);
assert!((bottom.y - 841.0).abs() < 1e-9, "page bottom at {bottom:?}");
assert!((top.y - 0.0).abs() < 1e-9, "page top at {top:?}");
assert!((top.x - 595.0).abs() < 1e-9, "page right at {top:?}");
let midpage = m * kurbo::Point::new(0.0, 420.945);
assert!((midpage.y - 420.5).abs() < 1e-9, "midpage at {midpage:?}");
}
#[test]
fn a_scaled_render_fits_the_page_to_its_own_truncated_box() {
let a4 = Rect::new(0.0, 0.0, 595.276, 841.89);
let page = Page {
media_box: a4,
crop_box: a4,
..Page::empty()
};
let opts = RenderOptions {
transform: Affine::scale(2.0),
..RenderOptions::default()
};
let (w, h) = target_size(&page, &opts).expect("renderable");
assert_eq!((w, h), (1190, 1683));
let m = page_matrix(&page, &opts);
let corner = m * kurbo::Point::new(595.276, 0.0);
assert!((corner.x - 1190.0).abs() < 1e-9, "{corner:?}");
assert!((corner.y - 1683.0).abs() < 1e-9, "{corner:?}");
}
#[test]
fn target_size_rejects_an_oversized_page() {
let opts = RenderOptions {
transform: Affine::scale(200.0),
..RenderOptions::default()
};
let err = target_size(&Page::empty(), &opts).expect_err("too large");
assert!(matches!(
err,
Error::TargetTooLarge {
limit: MAX_TARGET_DIMENSION,
..
}
));
}
#[test]
fn crop_lifts_a_sub_rectangle() {
let mut src = Pixmap::new(4, 4);
src.set_pixel(2, 3, [1, 2, 3, 255]);
let out = crop(
&src,
IntRect {
left: 2,
top: 2,
right: 4,
bottom: 4,
},
);
assert_eq!((out.width(), out.height()), (2, 2));
assert_eq!(out.pixel(0, 1), Some([1, 2, 3, 255]));
}
#[test]
fn crop_of_an_out_of_range_rect_is_transparent() {
let src = Pixmap::filled(2, 2, peniko::Color::from_rgba8(9, 9, 9, 255));
let out = crop(
&src,
IntRect {
left: 10,
top: 10,
right: 12,
bottom: 12,
},
);
assert_eq!(out.pixel(0, 0), Some([0, 0, 0, 0]));
}
#[test]
fn only_a_live_edits_form_turns_clear_type_on() {
use crate::options::TextAa;
let page = RenderOptions::default();
assert_eq!(
form_options(&page, false).effective_text_aa(),
TextAa::Grayscale
);
assert_eq!(
form_options(&page, true).effective_text_aa(),
TextAa::LcdSubpixel
);
assert_eq!(page.effective_text_aa(), TextAa::Grayscale);
}
#[test]
fn a_callers_own_text_aa_override_outranks_the_objects_flag() {
use crate::options::TextAa;
let forced = RenderOptions::default().for_text_run(TextAa::None);
assert_eq!(
form_options(&forced, true).effective_text_aa(),
TextAa::None
);
}
#[test]
fn the_live_edit_fold_changes_nothing_else_about_the_options() {
use crate::options::TextAa;
let page = RenderOptions {
color_mode: crate::options::ColorMode::Gray,
no_path_smooth: true,
background: Some(peniko::Color::BLACK),
..RenderOptions::default()
};
let inner = form_options(&page, true);
assert_eq!(inner.text_aa_override, Some(TextAa::LcdSubpixel));
assert_eq!(inner.color_mode, page.color_mode);
assert!(inner.no_path_smooth);
assert_eq!(inner.background, page.background);
assert_eq!(inner.text_aa, page.text_aa);
}
}