#[cfg(test)]
use kurbo::Shape;
use kurbo::{Affine, BezPath, Point, Rect};
use pdfrum_page::Patch;
use pdfrum_page::Rgb;
use crate::color::Argb;
use crate::device::{AntiAlias, Brush, FillRule, RenderDevice};
use crate::shading::steps::{ColorSteps, component_to_shading_index};
pub const COLOR_THRESHOLD: i32 = 4;
pub const SMALL_PATCH: f64 = 2.0;
pub const MAX_DEPTH: u32 = 32;
type IntColor = [i32; 3];
fn to_int_color(c: Rgb, range: Option<[f32; 2]>) -> IntColor {
if let Some([lo, hi]) = range {
#[expect(
clippy::cast_possible_truncation,
reason = "the C++ takes `static_cast<int32_t>` of the same value; \
the index is clamped into 0..=255 where it is used as a \
ramp subscript"
)]
let index = component_to_shading_index(c.r, lo, hi) as i32;
[index, 0, 0]
} else {
let [r, g, b] = c.to_bytes_truncating();
[i32::from(r), i32::from(g), i32::from(b)]
}
}
fn interpolate(c0: i32, c1: i32, delta1: i32, delta2: i32) -> Option<i32> {
if delta2 == 0 {
return Some(c0);
}
c1.checked_sub(c0)?
.checked_mul(delta1)?
.checked_div(delta2)?
.checked_add(c0)
}
fn bilinear(
colors: &[IntColor; 4],
left: i32,
bottom: i32,
x_scale: i32,
y_scale: i32,
) -> Option<IntColor> {
let [[r0, g0, b0], [r1, g1, b1], [r2, g2, b2], [r3, g3, b3]] = *colors;
let blend = |c0, c1, c2, c3| {
let bottom_edge = interpolate(c0, c3, left, x_scale)?;
let top_edge = interpolate(c1, c2, left, x_scale)?;
interpolate(bottom_edge, top_edge, bottom, y_scale)
};
Some([
blend(r0, r1, r2, r3)?,
blend(g0, g1, g2, g3)?,
blend(b0, b1, b2, b3)?,
])
}
fn distance(a: IntColor, b: IntColor) -> i32 {
let ([ar, ag, ab], [br, bg, bb]) = (a, b);
(ar - br).abs().max((ag - bg).abs()).max((ab - bb).abs())
}
#[derive(Debug, Clone, Copy)]
struct Points {
grid: [[Point; 4]; 4],
}
#[expect(
clippy::manual_midpoint,
reason = "`(a + b) / 2.0` is the De Casteljau step as PDFium writes it. \
`f64::midpoint` is not the same function — it is correctly \
rounded where this rounds twice — and swapping it in would move \
subdivided patch cells off the oracle's pixels. The overflow \
the lint warns about needs a coordinate near f64::MAX, which \
`all_finite` on the resulting points already rejects."
)]
#[expect(
clippy::many_single_char_names,
reason = "a..f are De Casteljau's intermediate points in the order the \
construction names them; p0..p3 are the input control points"
)]
fn split_cubic(p: [Point; 4]) -> ([Point; 4], [Point; 4]) {
let mid = |a: Point, b: Point| Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
let (p0, p1, p2, p3) = (p[0], p[1], p[2], p[3]);
let a = mid(p0, p1);
let b = mid(p1, p2);
let c = mid(p2, p3);
let d = mid(a, b);
let e = mid(b, c);
let f = mid(d, e);
([p0, a, d, f], [f, e, c, p3])
}
#[derive(Debug, Clone, Copy)]
struct Survey {
finite: bool,
x0: f64,
y0: f64,
x1: f64,
y1: f64,
}
impl Survey {
fn is_small(self) -> bool {
self.x1 - self.x0 < SMALL_PATCH && self.y1 - self.y0 < SMALL_PATCH
}
}
impl Points {
fn from_boundary(boundary: &[Point]) -> Option<Self> {
if boundary.len() < 12 {
return None;
}
let g = |i: usize| boundary.get(i).copied();
let interior = pdfrum_page::coons_interior(boundary);
let grid = [
[g(0)?, g(1)?, g(2)?, g(3)?],
[g(11)?, interior[0], interior[1], g(4)?],
[g(10)?, interior[2], interior[3], g(5)?],
[g(9)?, g(8)?, g(7)?, g(6)?],
];
Some(Self { grid })
}
fn from_tensor(points: &[Point]) -> Option<Self> {
if points.len() < 16 {
return Self::from_boundary(points);
}
let g = |i: usize| points.get(i).copied();
let grid = [
[g(0)?, g(1)?, g(2)?, g(3)?],
[g(11)?, g(12)?, g(13)?, g(4)?],
[g(10)?, g(15)?, g(14)?, g(5)?],
[g(9)?, g(8)?, g(7)?, g(6)?],
];
Some(Self { grid })
}
fn survey(&self) -> Survey {
let mut s = Survey {
finite: true,
x0: f64::INFINITY,
y0: f64::INFINITY,
x1: f64::NEG_INFINITY,
y1: f64::NEG_INFINITY,
};
for p in self.grid.iter().flatten() {
s.finite &= p.x.is_finite() && p.y.is_finite();
s.x0 = s.x0.min(p.x);
s.y0 = s.y0.min(p.y);
s.x1 = s.x1.max(p.x);
s.y1 = s.y1.max(p.y);
}
s
}
fn all_finite(&self) -> bool {
self.survey().finite
}
fn bbox(&self) -> Rect {
let s = self.survey();
Rect::new(s.x0, s.y0, s.x1, s.y1)
}
fn split_along_columns(&self) -> (Self, Self) {
let mut first = self.grid;
let mut second = self.grid;
for (i, row) in self.grid.iter().enumerate() {
let (l, r) = split_cubic(*row);
if let (Some(ls), Some(rs)) = (first.get_mut(i), second.get_mut(i)) {
*ls = l;
*rs = r;
}
}
(Self { grid: first }, Self { grid: second })
}
fn split_along_rows(&self) -> (Self, Self) {
let mut bottom = self.grid;
let mut top = self.grid;
for col in 0..4 {
let column = [
self.grid
.first()
.and_then(|r| r.get(col))
.copied()
.unwrap_or(Point::ZERO),
self.grid
.get(1)
.and_then(|r| r.get(col))
.copied()
.unwrap_or(Point::ZERO),
self.grid
.get(2)
.and_then(|r| r.get(col))
.copied()
.unwrap_or(Point::ZERO),
self.grid
.get(3)
.and_then(|r| r.get(col))
.copied()
.unwrap_or(Point::ZERO),
];
let (b, t) = split_cubic(column);
for i in 0..4 {
if let (Some(slot), Some(&v)) =
(bottom.get_mut(i).and_then(|r| r.get_mut(col)), b.get(i))
{
*slot = v;
}
if let (Some(slot), Some(&v)) =
(top.get_mut(i).and_then(|r| r.get_mut(col)), t.get(i))
{
*slot = v;
}
}
}
(Self { grid: bottom }, Self { grid: top })
}
fn write_boundary_path(&self, into: &mut BezPath) {
let [top, upper, lower, bottom] = self.grid;
into.truncate(0);
into.move_to(top[0]);
into.curve_to(top[1], top[2], top[3]);
into.curve_to(upper[3], lower[3], bottom[3]);
into.curve_to(bottom[2], bottom[1], bottom[0]);
into.curve_to(lower[0], upper[0], top[0]);
into.close_path();
}
}
struct Cells<'a> {
dest: &'a mut dyn RenderDevice,
colors: &'a [IntColor; 4],
steps: Option<&'a ColorSteps>,
path: BezPath,
}
#[derive(Debug, Clone, Copy)]
struct Lattice {
x_scale: i32,
y_scale: i32,
left: i32,
bottom: i32,
}
impl Lattice {
const WHOLE: Self = Self {
x_scale: 1,
y_scale: 1,
left: 0,
bottom: 0,
};
fn color_at(self, colors: &[IntColor; 4]) -> Option<IntColor> {
bilinear(colors, self.left, self.bottom, self.x_scale, self.y_scale)
}
fn color_offset(self, colors: &[IntColor; 4], dx: i32, dy: i32) -> Option<IntColor> {
bilinear(
colors,
self.left.saturating_add(dx),
self.bottom.saturating_add(dy),
self.x_scale,
self.y_scale,
)
}
fn halve_vertically(self) -> (Self, Self) {
let ys = self.y_scale.saturating_mul(2);
let bb = self.bottom.saturating_mul(2);
(
Self {
y_scale: ys,
bottom: bb,
..self
},
Self {
y_scale: ys,
bottom: bb.saturating_add(1),
..self
},
)
}
fn halve_horizontally(self) -> (Self, Self) {
let xs = self.x_scale.saturating_mul(2);
let ll = self.left.saturating_mul(2);
(
Self {
x_scale: xs,
left: ll,
..self
},
Self {
x_scale: xs,
left: ll.saturating_add(1),
..self
},
)
}
}
#[expect(
clippy::cast_sign_loss,
reason = "every colour component is clamped to 0..=255 immediately before \
its cast, so no negative value reaches one"
)]
fn subdivide(cells: &mut Cells<'_>, points: Points, at: Lattice, depth: u32) {
let survey = points.survey();
if !survey.finite {
return; }
let small = survey.is_small();
let Some(c0) = at.color_at(cells.colors) else {
return;
};
let flat = small || depth >= MAX_DEPTH || {
let (Some(c1), Some(c2), Some(c3)) = (
at.color_offset(cells.colors, 0, 1),
at.color_offset(cells.colors, 1, 1),
at.color_offset(cells.colors, 1, 0),
) else {
return;
};
let d_bottom = distance(c3, c0);
let d_left = distance(c1, c0);
let d_top = distance(c1, c2);
let d_right = distance(c2, c3);
if d_bottom < COLOR_THRESHOLD
&& d_left < COLOR_THRESHOLD
&& d_top < COLOR_THRESHOLD
&& d_right < COLOR_THRESHOLD
{
true
} else {
let vertical_only = d_bottom < COLOR_THRESHOLD && d_top < COLOR_THRESHOLD;
let horizontal_only = d_left < COLOR_THRESHOLD && d_right < COLOR_THRESHOLD;
let next = depth.saturating_add(1);
if vertical_only {
let (b, t) = points.split_along_columns();
let (lo, hi) = at.halve_vertically();
subdivide(cells, b, lo, next);
subdivide(cells, t, hi, next);
} else if horizontal_only {
let (l, r) = points.split_along_rows();
let (lo, hi) = at.halve_horizontally();
subdivide(cells, l, lo, next);
subdivide(cells, r, hi, next);
} else {
let (near, far) = points.split_along_columns();
let (below, above) = at.halve_vertically();
for (half, band) in [(near, below), (far, above)] {
let (lo, hi) = half.split_along_rows();
let (l, r) = band.halve_horizontally();
subdivide(cells, lo, l, next);
subdivide(cells, hi, r, next);
}
}
return;
}
};
if !flat {
return;
}
let color = match cells.steps {
Some(ramp) => {
let index = c0.first().copied().unwrap_or(0).clamp(0, 255) as usize;
match ramp.entry(index) {
Some(c) => c.with_alpha(255),
None => return,
}
}
None => Argb {
a: 255,
r: c0.first().copied().unwrap_or(0).clamp(0, 255) as u8,
g: c0.get(1).copied().unwrap_or(0).clamp(0, 255) as u8,
b: c0.get(2).copied().unwrap_or(0).clamp(0, 255) as u8,
},
};
points.write_boundary_path(&mut cells.path);
cells.dest.fill_path(
&cells.path,
Affine::IDENTITY,
&Brush::Solid(color.to_peniko()),
FillRule::Winding,
AntiAlias::FullCover,
);
}
pub fn draw_patch(
dest: &mut dyn RenderDevice,
patch: &Patch,
steps: Option<&ColorSteps>,
component_range: [f32; 2],
to_bitmap: Affine,
tensor: bool,
) {
let transformed: Vec<Point> = patch.points.iter().map(|&p| to_bitmap * p).collect();
let Some(points) = (if tensor {
Points::from_tensor(&transformed)
} else {
Points::from_boundary(&transformed)
}) else {
return;
};
if !points.all_finite() {
return;
}
let bbox = points.bbox();
if bbox.x1 <= 0.0 || bbox.y1 <= 0.0 {
return;
}
let range = steps.map(|_| component_range);
let colors = patch.colors.map(|c| to_int_color(c, range));
let mut cells = Cells {
dest,
colors: &colors,
steps,
path: BezPath::new(),
};
subdivide(&mut cells, points, Lattice::WHOLE, 0);
}
#[must_use]
pub fn patch_is_offscreen(patch: &Patch, to_bitmap: Affine, width: u32, height: u32) -> bool {
let mut bbox: Option<Rect> = None;
for &p in &patch.points {
let q = to_bitmap * p;
let cell = Rect::new(q.x, q.y, q.x, q.y);
bbox = Some(match bbox {
Some(acc) => acc.union(cell),
None => cell,
});
}
let Some(b) = bbox else { return true };
b.x1 <= 0.0 || b.x0 >= f64::from(width) || b.y1 <= 0.0 || b.y0 >= f64::from(height)
}
#[cfg(test)]
mod tests {
use super::*;
fn patch_outline(patch: &Patch, to_bitmap: Affine) -> BezPath {
let transformed: Vec<Point> = patch.points.iter().map(|&p| to_bitmap * p).collect();
Points::from_boundary(&transformed)
.map(|points| {
let mut out = BezPath::new();
points.write_boundary_path(&mut out);
out
})
.unwrap_or_default()
}
fn to_int_color_plain(c: Rgb) -> IntColor {
to_int_color(c, None)
}
fn square_patch(size: f64, colors: [Rgb; 4]) -> Patch {
let s = size;
let t = s / 3.0;
let pts = vec![
Point::new(0.0, 0.0),
Point::new(0.0, t),
Point::new(0.0, 2.0 * t),
Point::new(0.0, s),
Point::new(t, s),
Point::new(2.0 * t, s),
Point::new(s, s),
Point::new(s, 2.0 * t),
Point::new(s, t),
Point::new(s, 0.0),
Point::new(2.0 * t, 0.0),
Point::new(t, 0.0),
];
Patch {
points: pts.into_boxed_slice(),
colors,
}
}
#[test]
fn each_split_advances_the_lattice_axis_it_walks() {
let p = Points::from_boundary(&square_patch(12.0, [Rgb::BLACK; 4]).points).expect("built");
let (rows_lo, rows_hi) = p.split_along_rows();
let (cols_lo, cols_hi) = p.split_along_columns();
assert_eq!(rows_lo.grid[0], p.grid[0], "the first row is untouched");
assert_eq!(rows_hi.grid[3], p.grid[3], "and so is the last");
assert_ne!(rows_lo.grid[3], rows_hi.grid[3]);
assert_eq!(cols_lo.grid[0][0], p.grid[0][0]);
assert_eq!(cols_hi.grid[0][3], p.grid[0][3]);
assert_eq!(
cols_lo.grid[0][3], cols_hi.grid[0][0],
"the halves share the split point"
);
assert_eq!(
rows_lo.grid[3][0], rows_hi.grid[0][0],
"and so do the other axis's"
);
}
#[test]
fn halving_a_lattice_doubles_the_scale_and_indexes_the_half() {
let (lo, hi) = Lattice::WHOLE.halve_vertically();
assert_eq!((lo.y_scale, lo.bottom), (2, 0));
assert_eq!((hi.y_scale, hi.bottom), (2, 1));
assert_eq!(
(lo.x_scale, lo.left, hi.x_scale, hi.left),
(1, 0, 1, 0),
"the other axis is untouched"
);
let (lo, hi) = Lattice::WHOLE.halve_horizontally();
assert_eq!((lo.x_scale, lo.left), (2, 0));
assert_eq!((hi.x_scale, hi.left), (2, 1));
assert_eq!(
(lo.y_scale, lo.bottom, hi.y_scale, hi.bottom),
(1, 0, 1, 0),
"and so is this one"
);
let (below, above) = Lattice::WHOLE.halve_vertically();
let quadrants: Vec<(i32, i32)> = [below, above]
.into_iter()
.flat_map(|band| {
let (l, r) = band.halve_horizontally();
[(l.left, l.bottom), (r.left, r.bottom)]
})
.collect();
assert_eq!(quadrants, vec![(0, 0), (1, 0), (0, 1), (1, 1)]);
assert_eq!(above.y_scale, 2);
}
#[test]
fn coons_interiors_land_in_the_tensor_slots() {
let pts: Vec<Point> = [
(0.0, 0.0),
(1.0, 4.0),
(2.0, 8.0),
(3.0, 12.0),
(7.0, 13.0),
(11.0, 14.0),
(15.0, 15.0),
(14.0, 11.0),
(13.0, 7.0),
(12.0, 3.0),
(8.0, 2.0),
(4.0, 1.0),
]
.into_iter()
.map(|(x, y)| Point::new(x, y))
.collect();
let coons = Points::from_boundary(&pts).expect("built");
let interior = pdfrum_page::coons_interior(&pts);
assert_ne!(
interior[1], interior[2],
"the fixture separates p12 and p21"
);
let mut tensor_pts = pts;
tensor_pts.extend([interior[0], interior[1], interior[3], interior[2]]);
let tensor = Points::from_tensor(&tensor_pts).expect("built");
assert_eq!(coons.grid, tensor.grid);
}
#[test]
fn a_ramp_corner_maps_across_the_meshs_decode_range() {
let t = |v: f32| Rgb {
r: v,
g: 0.0,
b: 0.0,
};
assert_eq!(to_int_color(t(0.0), Some([0.0, 1.0])), [0, 0, 0]);
assert_eq!(to_int_color(t(1.0), Some([0.0, 1.0])), [255, 0, 0]);
assert_eq!(to_int_color(t(1.0), Some([1.0, 2.0])), [0, 0, 0]);
assert_eq!(to_int_color(t(2.0), Some([1.0, 2.0])), [255, 0, 0]);
assert_eq!(to_int_color(t(1.5), Some([1.0, 2.0])), [127, 0, 0]);
assert_eq!(to_int_color(t(255.0), Some([0.0, 255.0])), [255, 0, 0]);
assert_eq!(to_int_color(t(9.0), Some([3.0, 3.0])), [0, 0, 0]);
assert_eq!(
to_int_color(
Rgb {
r: 0.5,
g: 1.0,
b: 0.0
},
None
),
[127, 255, 0]
);
}
#[test]
fn a_nan_control_point_leaves_the_extent_finite_and_the_flag_false() {
let mut patch = square_patch(10.0, [Rgb::BLACK; 4]);
#[expect(
clippy::indexing_slicing,
reason = "the fixture is a square patch with all twelve boundary \
points present, so index 5 exists by construction"
)]
{
patch.points[5] = Point::new(f64::NAN, f64::NAN);
}
let s = Points::from_boundary(&patch.points)
.expect("built")
.survey();
assert!(!s.finite, "the flag catches it");
assert!(
s.x0.is_finite() && s.y0.is_finite() && s.x1.is_finite() && s.y1.is_finite(),
"and the extent does not: min/max swallowed the NaN"
);
}
#[test]
fn is_small_is_a_two_device_unit_bbox() {
let p = Points::from_boundary(&square_patch(1.5, [Rgb::BLACK; 4]).points).expect("built");
assert!(p.survey().is_small());
let p = Points::from_boundary(&square_patch(3.0, [Rgb::BLACK; 4]).points).expect("built");
assert!(!p.survey().is_small());
}
#[test]
fn color_threshold_stops_subdivision() {
let near = [
Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
},
Rgb {
r: 1.0 / 255.0,
g: 0.0,
b: 0.0,
},
Rgb {
r: 2.0 / 255.0,
g: 0.0,
b: 0.0,
},
Rgb {
r: 3.0 / 255.0,
g: 0.0,
b: 0.0,
},
];
let colors = near.map(|c| to_int_color(c, None));
let d = distance(colors[0], colors[3]);
assert!(d < COLOR_THRESHOLD, "delta {d} must be under the threshold");
}
#[test]
fn integer_interpolate_detects_overflow() {
assert_eq!(interpolate(0, 10, 1, 2), Some(5));
assert_eq!(
interpolate(7, 7, 5, 0),
Some(7),
"a zero span keeps the endpoint"
);
assert_eq!(
interpolate(0, i32::MAX, i32::MAX, 1),
None,
"overflow aborts the cell"
);
}
#[test]
fn distance_is_the_max_component_delta() {
assert_eq!(distance([0, 0, 0], [3, 9, 1]), 9);
assert_eq!(distance([10, 10, 10], [10, 10, 10]), 0);
}
#[test]
fn subdivision_axis_choice_follows_the_varying_edges() {
let vertical = [
to_int_color_plain(Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
}),
to_int_color_plain(Rgb {
r: 1.0,
g: 1.0,
b: 1.0,
}),
to_int_color_plain(Rgb {
r: 1.0,
g: 1.0,
b: 1.0,
}),
to_int_color_plain(Rgb {
r: 0.0,
g: 0.0,
b: 0.0,
}),
];
let c0 = bilinear(&vertical, 0, 0, 1, 1).expect("interpolates");
let c1 = bilinear(&vertical, 0, 1, 1, 1).expect("interpolates");
let c3 = bilinear(&vertical, 1, 0, 1, 1).expect("interpolates");
assert!(
distance(c3, c0) < COLOR_THRESHOLD,
"the bottom edge is flat"
);
assert!(distance(c1, c0) >= COLOR_THRESHOLD, "the left edge is not");
}
#[test]
fn non_finite_control_points_drop_the_patch() {
let mut patch = square_patch(10.0, [Rgb::BLACK; 4]);
#[expect(
clippy::indexing_slicing,
reason = "the fixture is a square patch with all twelve boundary \
points present, so index 3 exists by construction"
)]
{
patch.points[3] = Point::new(f64::NAN, 0.0);
}
let outline = patch_outline(&patch, Affine::IDENTITY);
assert!(!outline.elements().is_empty());
let points = Points::from_boundary(&patch.points).expect("built");
assert!(!points.all_finite());
}
#[test]
fn a_patch_left_of_the_target_is_offscreen() {
let patch = square_patch(4.0, [Rgb::BLACK; 4]);
assert!(patch_is_offscreen(
&patch,
Affine::translate((-50.0, 0.0)),
20,
20
));
assert!(!patch_is_offscreen(&patch, Affine::IDENTITY, 20, 20));
}
#[test]
fn split_cubic_halves_a_straight_line_at_its_midpoint() {
let line = [
Point::new(0.0, 0.0),
Point::new(1.0, 0.0),
Point::new(2.0, 0.0),
Point::new(3.0, 0.0),
];
let (l, r) = split_cubic(line);
assert!((l[3].x - 1.5).abs() < 1e-9);
assert!((r[0].x - 1.5).abs() < 1e-9);
assert!((r[3].x - 3.0).abs() < 1e-9);
}
#[test]
fn boundary_path_uses_the_twelve_outer_points_only() {
let patch = square_patch(9.0, [Rgb::BLACK; 4]);
let outline = patch_outline(&patch, Affine::IDENTITY);
let bbox = outline.bounding_box();
assert!((bbox.x0 - 0.0).abs() < 1e-9);
assert!((bbox.x1 - 9.0).abs() < 1e-9);
assert!(outline.area().abs() > 0.0);
}
}