use kurbo::{Affine, BezPath, Rect, Shape};
use pdfrum_common::Diagnostics;
use pdfrum_page::{Pattern, TilingPattern};
use crate::clip;
use crate::color::Argb;
use crate::ctx::{RenderCaches, RenderCtx};
use crate::device::{ImageQuality, MAX_TARGET_DIMENSION, RasterBackend, RenderDevice};
use crate::path::{IntRect, is_available_matrix, outer_rect};
use crate::pixmap::{AlphaMask, Pixmap, alpha_byte_rounding};
pub const MIN_CELL_AREA: i64 = 16;
pub const ENLARGED_CELL: u32 = 8;
#[must_use]
pub fn render_size(width: u32, height: u32) -> (u32, u32) {
if i64::from(width) * i64::from(height) < MIN_CELL_AREA {
(ENLARGED_CELL, ENLARGED_CELL)
} else {
(width, height)
}
}
#[derive(Debug, Clone)]
pub enum PatternClip<'a> {
Path {
path: &'a BezPath,
to_device: Affine,
stroking: bool,
rule: crate::device::FillRule,
stroke: &'a pdfrum_page::StrokeParams,
},
Rect(Rect),
}
impl PatternClip<'_> {
fn device_bounds(&self) -> Rect {
match self {
Self::Path {
path,
to_device,
stroking,
stroke,
..
} => {
let mut b = to_device.transform_rect_bbox(path.bounding_box());
if *stroking {
let scale = to_device
.as_coeffs()
.iter()
.take(4)
.fold(0.0f64, |m, c| m.max(c.abs()));
let pad = f64::from(stroke.width).abs() * scale + 1.0;
b = b.inflate(pad, pad);
}
b
}
Self::Rect(r) => *r,
}
}
fn push(&self, device: &mut dyn RenderDevice) -> usize {
match self {
Self::Path {
path,
to_device,
stroking,
rule,
stroke,
} => {
if *stroking {
let outline = crate::stroke::outline(path, *to_device, stroke);
device.push_clip(&outline, crate::device::FillRule::Winding);
} else {
device.push_clip(&(*to_device * (*path).clone()), *rule);
}
1
}
Self::Rect(r) => {
device.push_clip_rect(outer_rect(*r).to_rect());
1
}
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "painting a pattern needs the context, device, backend, caches, \
the pattern, its clip geometry, the page transform and the \
uncoloured colour"
)]
pub fn draw<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
pattern: &Pattern,
geometry: &PatternClip<'_>,
to_device: Affine,
device_box: Rect,
alpha: f32,
uncolored: Argb,
diags: &mut Diagnostics,
) {
if !ctx.may_recurse() {
return;
}
let clip_rect = geometry.device_bounds().intersect(device_box);
let rect = outer_rect(clip_rect);
if !rect.is_valid() {
return;
}
let pushed = geometry.push(device);
match pattern {
Pattern::Shading(p) => {
let matrix = to_device * p.matrix;
if is_available_matrix(matrix) {
let mut bounded = clip_rect;
if let Some(b) = p.shading.bbox {
bounded = bounded.intersect(matrix.transform_rect_bbox(b));
}
let rect = outer_rect(bounded);
if !rect.is_valid() {
clip::pop(device, pushed);
return;
}
let a = alpha_byte_rounding(alpha);
crate::walk::draw_shading_into(ctx, device, backend, &p.shading, rect, matrix, a);
}
}
Pattern::Tiling(p) => {
draw_tiling(
ctx, device, backend, caches, p, rect, to_device, uncolored, diags,
);
}
_ => {}
}
clip::pop(device, pushed);
}
#[expect(
clippy::too_many_arguments,
reason = "tiling needs the context, device, backend, caches, the pattern, \
the clip rect, the page transform and the uncoloured colour"
)]
fn draw_tiling<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
pattern: &TilingPattern,
clip: IntRect,
to_device: Affine,
uncolored: Argb,
diags: &mut Diagnostics,
) {
let pattern_to_device = to_device * pattern.matrix;
if !is_available_matrix(pattern_to_device) {
return;
}
let Some((cell_w, cell_h)) = pattern.cell_size(to_device) else {
return;
};
if i64::from(cell_h) > i64::from(i32::MAX) / i64::from(cell_w) {
return;
}
let det = pattern_to_device.determinant();
if det == 0.0 || !det.is_finite() {
return;
}
let clip_in_pattern = pattern_to_device
.inverse()
.transform_rect_bbox(clip.to_rect());
if !clip_in_pattern.x0.is_finite() || !clip_in_pattern.x1.is_finite() {
return;
}
let Some(range) = pattern.tile_range(clip_in_pattern, diags) else {
return;
};
let (Ok(clip_w), Ok(clip_h)) = (u32::try_from(clip.width()), u32::try_from(clip.height()))
else {
return;
};
if clip_w == 0 || clip_h == 0 || clip_w > MAX_TARGET_DIMENSION || clip_h > MAX_TARGET_DIMENSION
{
return;
}
if tiles_one_at_a_time((cell_w, cell_h), (clip.width(), clip.height())) {
draw_tiling_per_tile(
ctx,
device,
backend,
caches,
pattern,
&range,
pattern_to_device,
uncolored,
clip,
diags,
);
return;
}
let cell = render_cell(
ctx,
backend,
caches,
pattern,
(cell_w, cell_h),
to_device,
uncolored,
diags,
);
let Some(cell) = cell else {
return;
};
let mut screen = Screen::new(clip_w, clip_h);
let cell_bbox = pattern_to_device.transform_rect_bbox(pattern.bbox);
let [_, _, _, _, e, f] = pattern_to_device.as_coeffs();
let left_offset = cell_bbox.x0 - e;
let top_offset = cell_bbox.y0 - f;
for row in range.min_row..=range.max_row {
for col in range.min_col..=range.max_col {
let origin = pattern_to_device
* kurbo::Point::new(
f64::from(col) * f64::from(pattern.x_step),
f64::from(row) * f64::from(pattern.y_step),
);
let (Some(x), Some(y)) = (
checked_start(origin.x + left_offset, clip.left),
checked_start(origin.y + top_offset, clip.top),
) else {
return;
};
screen.blit(&cell, x, y);
}
}
device.draw_image(
&screen.into_pixmap(),
Affine::translate((f64::from(clip.left), f64::from(clip.top))),
ImageQuality::Nearest,
1.0,
);
}
#[must_use]
fn tiles_one_at_a_time(cell: (i32, i32), clip: (i32, i32)) -> bool {
cell.0 > clip.0
|| cell.1 > clip.1
|| i64::from(cell.0) * i64::from(cell.1) > i64::from(clip.0) * i64::from(clip.1)
}
#[expect(
clippy::too_many_arguments,
reason = "the per-tile path needs everything the cached one does, minus \
the cell buffer and plus the tile range"
)]
fn draw_tiling_per_tile<B: RasterBackend>(
ctx: &RenderCtx<'_>,
device: &mut B::Device,
backend: &B,
caches: &mut RenderCaches,
pattern: &TilingPattern,
range: &pdfrum_page::TileRange,
pattern_to_device: Affine,
uncolored: Argb,
clip: IntRect,
diags: &mut Diagnostics,
) {
let opts = if pattern.colored {
crate::options::RenderOptions {
force_halftone: true,
..ctx.opts.clone()
}
} else {
ctx.opts.for_uncolored_tile()
};
let inner = RenderCtx {
opts,
initial_fill: (!pattern.colored).then_some(uncolored),
initial_stroke: (!pattern.colored).then_some(uncolored),
..ctx.deeper()
};
let target = clip.to_rect();
for row in range.min_row..=range.max_row {
for col in range.min_col..=range.max_col {
let offset = Affine::translate((
f64::from(col) * f64::from(pattern.x_step),
f64::from(row) * f64::from(pattern.y_step),
));
crate::walk::render_object_list(
&inner,
device,
backend,
caches,
&pattern.objects,
&pdfrum_page::Visibility::all_visible(),
pattern_to_device * offset,
target,
diags,
);
}
}
}
fn checked_start(position: f64, clip_edge: i32) -> Option<i32> {
let rounded = position.round();
if !rounded.is_finite() || rounded < f64::from(i32::MIN) || rounded > f64::from(i32::MAX) {
return None;
}
#[expect(
clippy::cast_possible_truncation,
reason = "the range check above is the C++'s checked conversion"
)]
let start = rounded as i32;
start.checked_sub(clip_edge)
}
#[expect(
clippy::too_many_arguments,
reason = "rendering a cell needs the context, backend, caches, the \
pattern, its size, the page transform and the uncoloured colour"
)]
fn render_cell<B: RasterBackend>(
ctx: &RenderCtx<'_>,
backend: &B,
caches: &mut RenderCaches,
pattern: &TilingPattern,
size: (i32, i32),
to_device: Affine,
uncolored: Argb,
diags: &mut Diagnostics,
) -> Option<Cell> {
let (Ok(w), Ok(h)) = (u32::try_from(size.0), u32::try_from(size.1)) else {
return None;
};
if w == 0 || h == 0 || w > MAX_TARGET_DIMENSION || h > MAX_TARGET_DIMENSION {
return None;
}
let (render_w, render_h) = render_size(w, h);
let enlarged = (render_w, render_h) != (w, h);
let cell_bbox = (to_device * pattern.matrix).transform_rect_bbox(pattern.bbox);
if !(cell_bbox.width() > 0.0 && cell_bbox.height() > 0.0) {
return None;
}
let adjust = Affine::new([
f64::from(render_w) / cell_bbox.width(),
0.0,
0.0,
f64::from(render_h) / cell_bbox.height(),
-cell_bbox.x0 * f64::from(render_w) / cell_bbox.width(),
-cell_bbox.y0 * f64::from(render_h) / cell_bbox.height(),
]);
let opts = if pattern.colored {
crate::options::RenderOptions {
force_halftone: true,
..ctx.opts.clone()
}
} else {
ctx.opts.for_uncolored_tile()
};
let inner = RenderCtx {
opts,
initial_fill: (!pattern.colored).then_some(uncolored),
initial_stroke: (!pattern.colored).then_some(uncolored),
..ctx.deeper()
};
let mut cell_device = backend.new_target(render_w, render_h, peniko::Color::TRANSPARENT);
let target = Rect::new(0.0, 0.0, f64::from(render_w), f64::from(render_h));
crate::walk::render_object_list(
&inner,
&mut cell_device,
backend,
caches,
&pattern.objects,
&pdfrum_page::Visibility::all_visible(),
adjust * to_device,
target,
diags,
);
let rendered = backend.finish(cell_device);
if !pattern.colored {
return Some(Cell::Mask {
coverage: coverage_of(&rendered, w, h),
color: uncolored,
});
}
Some(Cell::Colored(if enlarged {
scale_down(&rendered, w, h)
} else {
rendered
}))
}
enum Cell {
Colored(Pixmap),
Mask { coverage: AlphaMask, color: Argb },
}
fn coverage_of(cell: &Pixmap, w: u32, h: u32) -> AlphaMask {
let scaled = if cell.width() == w && cell.height() == h {
cell.clone()
} else {
scale_down(cell, w, h)
};
let mut out = AlphaMask::new(w, h);
let plane = out.data_mut();
for y in 0..h {
for x in 0..w {
if let Some(px) = scaled.pixel(x, y)
&& let Some(&coverage) = px.get(3)
&& let Some(slot) = plane.get_mut((y as usize) * (w as usize) + x as usize)
{
*slot = coverage;
}
}
}
out
}
fn scale_down(cell: &Pixmap, w: u32, h: u32) -> Pixmap {
let mut out = Pixmap::new(w, h);
if w == 0 || h == 0 || cell.width() == 0 || cell.height() == 0 {
return out;
}
for y in 0..h {
for x in 0..w {
let (x0, x1) = span(x, w, cell.width());
let (y0, y1) = span(y, h, cell.height());
let mut acc = [0u32; 4];
let mut n = 0u32;
for sy in y0..y1 {
for sx in x0..x1 {
let Some(px) = cell.pixel(sx, sy) else {
continue;
};
for (slot, &v) in acc.iter_mut().zip(px.iter()) {
*slot += u32::from(v);
}
n += 1;
}
}
if n == 0 {
continue;
}
let mut px = [0u8; 4];
for (slot, &v) in px.iter_mut().zip(acc.iter()) {
*slot = u8::try_from(v / n).unwrap_or(255);
}
out.set_pixel(x, y, px);
}
}
out
}
fn span(index: u32, dest: u32, src: u32) -> (u32, u32) {
let lo = u64::from(index) * u64::from(src) / u64::from(dest);
let hi = (u64::from(index) + 1) * u64::from(src) / u64::from(dest);
let lo = u32::try_from(lo).unwrap_or(0).min(src.saturating_sub(1));
let hi = u32::try_from(hi).unwrap_or(src).clamp(lo + 1, src);
(lo, hi)
}
struct Screen {
width: u32,
height: u32,
data: Vec<u8>,
}
impl Screen {
fn new(width: u32, height: u32) -> Self {
let len = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
Self {
width,
height,
data: vec![0; len],
}
}
fn at(&mut self, x: u32, y: u32) -> Option<&mut [u8]> {
let i = (y as usize)
.checked_mul(self.width as usize)?
.checked_add(x as usize)?
.checked_mul(4)?;
self.data.get_mut(i..i.checked_add(4)?)
}
fn blit(&mut self, cell: &Cell, x: i32, y: i32) {
let (cell_w, cell_h) = match cell {
Cell::Colored(pixels) => (pixels.width(), pixels.height()),
Cell::Mask { coverage, .. } => (coverage.width(), coverage.height()),
};
for sy in 0..cell_h {
for sx in 0..cell_w {
let (Ok(dx), Ok(dy)) = (
u32::try_from(i64::from(x) + i64::from(sx)),
u32::try_from(i64::from(y) + i64::from(sy)),
) else {
continue;
};
if dx >= self.width || dy >= self.height {
continue;
}
let (src, src_alpha) = match cell {
Cell::Colored(pixels) => {
let Some([red, green, blue, alpha]) = pixels.pixel(sx, sy) else {
continue;
};
(
crate::pixmap::unpremultiply_rgb(red, green, blue, alpha),
alpha,
)
}
Cell::Mask { coverage, color } => {
let cov = coverage
.data()
.get((sy as usize) * (coverage.width() as usize) + sx as usize)
.copied()
.unwrap_or(0);
(
[color.r, color.g, color.b],
crate::pixmap::mul255(color.a, cov),
)
}
};
let Some(dest) = self.at(dx, dy) else {
continue;
};
let back_alpha = dest.get(3).copied().unwrap_or(0);
if back_alpha == 0 {
dest.copy_from_slice(&[src[0], src[1], src[2], src_alpha]);
continue;
}
if src_alpha == 0 {
continue;
}
let dest_alpha = crate::pixmap::alpha_union(back_alpha, src_alpha);
let ratio = u8::try_from((u32::from(src_alpha) * 255) / u32::from(dest_alpha))
.unwrap_or(u8::MAX);
for (channel, &value) in src.iter().enumerate() {
if let Some(slot) = dest.get_mut(channel) {
*slot = crate::pixmap::alpha_merge(*slot, value, ratio);
}
}
if let Some(a) = dest.get_mut(3) {
*a = dest_alpha;
}
}
}
}
fn into_pixmap(self) -> Pixmap {
let mut out = Pixmap::new(self.width, self.height);
for y in 0..self.height {
for x in 0..self.width {
let base = ((y as usize) * (self.width as usize) + x as usize) * 4;
let Some(&[red, green, blue, alpha]) = self
.data
.get(base..base + 4)
.and_then(|bytes| <&[u8; 4]>::try_from(bytes).ok())
else {
continue;
};
out.set_pixel(
x,
y,
[
crate::pixmap::mul255(red, alpha),
crate::pixmap::mul255(green, alpha),
crate::pixmap::mul255(blue, alpha),
alpha,
],
);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cell_under_sixteen_pixels_renders_enlarged() {
assert_eq!(render_size(3, 5), (ENLARGED_CELL, ENLARGED_CELL));
assert_eq!(render_size(1, 1), (ENLARGED_CELL, ENLARGED_CELL));
assert_eq!(render_size(4, 4), (4, 4));
assert_eq!(render_size(1, 20), (1, 20));
assert_eq!(render_size(64, 64), (64, 64));
}
#[test]
fn a_cell_bigger_than_its_clip_is_tiled_one_at_a_time() {
assert!(!tiles_one_at_a_time((100, 100), (200, 200)));
assert!(!tiles_one_at_a_time((200, 200), (200, 200)));
assert!(tiles_one_at_a_time((201, 10), (200, 200)));
assert!(tiles_one_at_a_time((10, 201), (200, 200)));
assert!(!tiles_one_at_a_time((400, 30), (400, 30)));
assert!(!tiles_one_at_a_time((399, 29), (400, 30)));
assert!(tiles_one_at_a_time((102_400, 12_800), (200, 200)));
}
#[test]
fn a_tile_offset_beyond_i32_aborts_rather_than_wrapping() {
assert_eq!(checked_start(1e30, 0), None);
assert_eq!(checked_start(f64::NAN, 0), None);
assert_eq!(checked_start(10.6, 4), Some(7));
}
#[test]
fn a_blit_clips_to_the_screen_rather_than_wrapping() {
let mut screen = Screen::new(2, 2);
let cell = Cell::Colored(Pixmap::filled(
2,
2,
peniko::Color::from_rgba8(255, 0, 0, 255),
));
screen.blit(&cell, -1, -1);
let out = screen.into_pixmap();
assert_eq!(out.pixel(0, 0), Some([255, 0, 0, 255]));
assert_eq!(out.pixel(1, 1), Some([0, 0, 0, 0]));
}
#[test]
fn a_blit_composites_source_over_rather_than_replacing() {
let mut screen = Screen::new(1, 1);
screen.blit(
&Cell::Colored(Pixmap::filled(
1,
1,
peniko::Color::from_rgba8(0, 0, 255, 255),
)),
0,
0,
);
screen.blit(
&Cell::Colored(Pixmap::filled(
1,
1,
peniko::Color::from_rgba8(255, 0, 0, 128),
)),
0,
0,
);
let px = screen.into_pixmap().pixel(0, 0).expect("a pixel");
assert!(px[0] > 100, "red arrived: {px:?}");
assert!(px[2] > 50, "blue survived: {px:?}");
assert_eq!(px[3], 255, "an opaque backdrop stays opaque");
}
#[test]
fn an_uncolored_cell_takes_its_colour_from_the_operands() {
let cell = Pixmap::filled(1, 1, peniko::Color::from_rgba8(0, 255, 0, 128));
let Some(Cell::Mask { coverage, color }) = Some(Cell::Mask {
coverage: coverage_of(&cell, 1, 1),
color: Argb::opaque(255, 0, 0),
}) else {
unreachable!()
};
assert_eq!(coverage.data(), &[128], "the coverage is the cell's alpha");
assert_eq!(color.g, 0, "the cell's own green never reaches the blit");
let mut screen = Screen::new(1, 1);
screen.blit(&Cell::Mask { coverage, color }, 0, 0);
let px = screen.into_pixmap().pixel(0, 0).expect("a pixel");
assert_eq!(px[3], 128, "the coverage becomes the alpha");
assert!(px[0] > 100, "the operand colour is what paints: {px:?}");
}
#[test]
fn overlapping_uncolored_tiles_keep_one_flat_colour() {
let color = Argb {
r: 0,
g: 0,
b: 255,
a: 255,
};
let mut screen = Screen::new(1, 1);
for _ in 0..8 {
screen.blit(
&Cell::Mask {
coverage: AlphaMask::filled(1, 1, 128),
color,
},
0,
0,
);
}
let straight = screen.data.clone();
assert_eq!(
straight.get(..3),
Some(&[0u8, 0, 255][..]),
"eight overlaps and the colour has not moved a count"
);
let mut a = 128u8;
for _ in 1..8 {
a = crate::pixmap::alpha_union(a, 128);
}
assert_eq!(straight.get(3), Some(&a));
}
#[test]
fn a_single_uncolored_tile_lands_on_the_oracles_exact_value() {
let mut screen = Screen::new(1, 1);
screen.blit(
&Cell::Mask {
coverage: AlphaMask::filled(1, 1, 128),
color: Argb {
r: 0,
g: 0,
b: 255,
a: 255,
},
},
0,
0,
);
assert_eq!(screen.data, vec![0, 0, 255, 128]);
assert_eq!(crate::pixmap::alpha_merge(255, 0, 128), 127);
}
#[test]
fn scaling_down_averages_rather_than_dropping_samples() {
let mut cell = Pixmap::new(8, 8);
for y in 0..8 {
for x in 0..8 {
if (x + y) % 2 == 0 {
cell.set_pixel(x, y, [255, 255, 255, 255]);
}
}
}
let out = scale_down(&cell, 2, 2);
let px = out.pixel(0, 0).expect("a pixel");
assert_eq!(px[3], 127, "eight of sixteen samples covered");
}
#[test]
fn a_span_never_empties() {
for i in 0..8 {
let (lo, hi) = span(i, 8, 3);
assert!(hi > lo, "span {i} of 8 over 3 is empty");
assert!(hi <= 3);
}
}
}