use kurbo::{BezPath, PathEl, Point};
#[derive(Debug, Clone, PartialEq)]
pub struct ZeroArea {
pub path: BezPath,
pub thin: bool,
pub identity: bool,
}
#[must_use]
pub fn snap_to_pixel_center(c: f64) -> f64 {
#[expect(
clippy::cast_possible_truncation,
reason = "the truncation toward zero *is* the ported operation — it is \
why -2.7 lands at -1.5 and not -2.5 — and Rust's saturating \
float-to-int cast turns an out-of-range or NaN coordinate \
into a bounded one rather than wrapping"
)]
let truncated = c as i32;
f64::from(truncated) + 0.5
}
#[derive(Debug, Default)]
pub struct Scratch {
points: Vec<Point>,
found: Vec<ZeroArea>,
}
pub fn scan_into<'a>(
scratch: &'a mut Scratch,
path: &BezPath,
transform: Option<kurbo::Affine>,
adjust: bool,
) -> &'a [ZeroArea] {
let started = crate::walkprofile::phase_start();
let out = scan_into_inner(scratch, path, transform, adjust);
started.end(crate::walkprofile::Phase::ZeroScan);
out
}
fn scan_into_inner<'a>(
scratch: &'a mut Scratch,
path: &BezPath,
transform: Option<kurbo::Affine>,
adjust: bool,
) -> &'a [ZeroArea] {
scratch.found.clear();
scratch.points.clear();
let mut has_curve = false;
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
if let Some(zero) = zero_area_path(&scratch.points, has_curve, transform, adjust) {
scratch.found.push(zero);
}
scratch.points.clear();
has_curve = false;
scratch.points.push(p);
}
PathEl::LineTo(p) => {
if !scratch.points.is_empty() {
scratch.points.push(p);
}
}
PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => {
if !scratch.points.is_empty() {
scratch.points.push(p);
has_curve = true;
}
}
PathEl::ClosePath => {}
}
}
if let Some(zero) = zero_area_path(&scratch.points, has_curve, transform, adjust) {
scratch.found.push(zero);
}
&scratch.found
}
fn check_simple_line(
points: &[Point],
transform: Option<kurbo::Affine>,
adjust: bool,
) -> Option<ZeroArea> {
if points.len() != 2 && points.len() != 3 {
return None;
}
let (Some(&p0), Some(&p1)) = (points.first(), points.get(1)) else {
return None;
};
if points.len() == 3 && points.get(2) != Some(&p0) {
return None;
}
if p0 == p1 {
return Some(ZeroArea {
path: BezPath::new(),
thin: false,
identity: false,
});
}
let mut out = BezPath::new();
let mut place = |p: Point, first: bool| {
let p = if adjust {
let p = transform.map_or(p, |m| m * p);
Point::new(snap_to_pixel_center(p.x), snap_to_pixel_center(p.y))
} else {
p
};
if first {
out.move_to(p);
} else {
out.line_to(p);
}
};
place(p0, true);
place(p1, false);
Some(ZeroArea {
path: out,
thin: true,
identity: adjust && transform.is_some(),
})
}
fn check_palindromic(points: &[Point], has_curve: bool) -> Option<ZeroArea> {
if points.len() <= 3 || points.len().is_multiple_of(2) || has_curve {
return None;
}
let mid = points.len() / 2;
let mut out = BezPath::new();
for i in 0..mid {
let (Some(&left), Some(&right)) = (points.get(mid - i - 1), points.get(mid + i + 1)) else {
return None;
};
if left != right {
return None;
}
let &pivot = points.get(mid - i)?;
out.move_to(pivot);
out.line_to(left);
}
Some(ZeroArea {
path: out,
thin: true,
identity: false,
})
}
#[expect(
clippy::float_cmp,
reason = "upstream's fold tests are bit-exact coordinate equality. A path \
doubles back on itself only when the coordinates are literally \
the same value; a tolerance would rewrite near-collinear paths \
into hairlines PDFium fills normally"
)]
fn folding_vertical(a: Point, b: Point, c: Point) -> bool {
a.x == b.x && b.x == c.x && (b.y - a.y) * (b.y - c.y) > 0.0
}
#[expect(
clippy::float_cmp,
reason = "see `folding_vertical`: bit-exact equality is the ported test"
)]
fn folding_horizontal(a: Point, b: Point, c: Point) -> bool {
a.y == b.y && b.y == c.y && (b.x - a.x) * (b.x - c.x) > 0.0
}
#[expect(
clippy::float_cmp,
reason = "the cross-product equality is upstream's collinearity test, \
computed and compared in exactly this order; an epsilon would \
admit near-collinear triples PDFium rejects"
)]
fn folding_diagonal(a: Point, b: Point, c: Point) -> bool {
a.x != b.x
&& c.x != b.x
&& a.y != b.y
&& c.y != b.y
&& (a.y - b.y) * (c.x - b.x) == (c.y - b.y) * (a.x - b.x)
}
fn check_folding(points: &[Point], has_curve: bool) -> Option<ZeroArea> {
if has_curve || points.len() < 2 {
return None;
}
let mut out = BezPath::new();
for i in 1..points.len() {
let next_index = (i + 1) % points.len();
let (Some(&prev), Some(&cur), Some(&next)) =
(points.get(i - 1), points.get(i), points.get(next_index))
else {
continue;
};
let use_prev = if folding_vertical(prev, cur, next) {
(cur.y - prev.y).abs() < (cur.y - next.y).abs()
} else if folding_horizontal(prev, cur, next) || folding_diagonal(prev, cur, next) {
(cur.x - prev.x).abs() < (cur.x - next.x).abs()
} else {
continue;
};
let (start, end) = if use_prev { (prev, cur) } else { (cur, next) };
out.move_to(start);
out.line_to(end);
}
if out.elements().is_empty() {
return None;
}
Some(ZeroArea {
path: out,
thin: points.len() > 3,
identity: false,
})
}
#[must_use]
pub fn zero_area_path(
points: &[Point],
has_curve: bool,
transform: Option<kurbo::Affine>,
adjust: bool,
) -> Option<ZeroArea> {
if points.len() < 2 {
return None;
}
check_simple_line(points, transform, adjust)
.or_else(|| check_palindromic(points, has_curve))
.or_else(|| check_folding(points, has_curve))
}
#[must_use]
pub fn thin_alpha(fill_alpha: u8) -> u8 {
fill_alpha >> 2
}
#[cfg(test)]
mod tests {
fn zero_area_sub_paths(
path: &BezPath,
transform: Option<kurbo::Affine>,
adjust: bool,
) -> Vec<ZeroArea> {
let mut scratch = Scratch::default();
scan_into(&mut scratch, path, transform, adjust).to_vec()
}
use kurbo::{Affine, Shape};
use super::*;
fn pts(v: &[(f64, f64)]) -> Vec<Point> {
v.iter().map(|&(x, y)| Point::new(x, y)).collect()
}
#[test]
#[expect(
clippy::float_cmp,
reason = "the snap emits `(int)c + 0.5`, which is exactly representable; \
exact equality is what distinguishes truncation from floor, \
the whole point of the test"
)]
fn simple_line_snap_truncates_toward_zero() {
assert_eq!(snap_to_pixel_center(2.7), 2.5);
assert_eq!(snap_to_pixel_center(-2.7), -1.5);
assert_eq!(snap_to_pixel_center(0.9), 0.5);
assert_eq!(snap_to_pixel_center(-0.9), 0.5);
}
#[test]
fn simple_line_is_snapped_and_identity() {
let z = zero_area_path(
&pts(&[(1.2, 3.8), (7.9, 3.8)]),
false,
Some(Affine::IDENTITY),
true,
)
.expect("a two-point path is a simple line");
assert!(z.thin);
assert!(z.identity, "a snapped path is already in device space");
let bbox = z.path.bounding_box();
assert_eq!((bbox.x0, bbox.y0), (1.5, 3.5));
assert_eq!((bbox.x1, bbox.y1), (7.5, 3.5));
}
#[test]
fn identical_points_draw_nothing() {
let z = zero_area_path(&pts(&[(4.0, 4.0), (4.0, 4.0)]), false, None, true)
.expect("matches, with an empty replacement");
assert!(z.path.elements().is_empty());
assert!(!z.thin);
}
#[test]
fn three_point_there_and_back_matches() {
let z = zero_area_path(
&pts(&[(0.0, 0.0), (9.0, 0.0), (0.0, 0.0)]),
false,
None,
false,
)
.expect("A->B->A is a simple line path");
assert!(z.thin);
assert!(!z.identity);
}
#[test]
fn three_point_that_does_not_return_falls_through_to_the_fold_scan() {
let p = pts(&[(0.0, 0.0), (9.0, 0.0), (1.0, 0.0)]);
assert!(check_simple_line(&p, None, false).is_none());
let z = zero_area_path(&p, false, None, false).expect("the fold scan matches");
assert!(
!z.thin,
"three points never earn the quarter-alpha reduction"
);
}
#[test]
fn palindromic_needs_an_odd_count_above_three() {
let p = pts(&[(0.0, 0.0), (5.0, 0.0), (9.0, 0.0), (5.0, 0.0), (0.0, 0.0)]);
let z = zero_area_path(&p, false, None, false).expect("palindromic");
assert!(z.thin);
let even = pts(&[(0.0, 0.0), (5.0, 0.0), (5.0, 0.0), (0.0, 0.0)]);
let z = zero_area_path(&even, false, None, false);
assert!(z.is_none() || !matches!(z, Some(ref v) if v.path.elements().len() == 4));
}
#[test]
fn palindromic_rejects_beziers() {
let p = pts(&[(0.0, 0.0), (5.0, 0.0), (9.0, 0.0), (5.0, 0.0), (0.0, 0.0)]);
assert!(check_palindromic(&p, true).is_none());
}
#[test]
fn folding_vertical_picks_by_y_distance() {
let a = Point::new(0.0, 0.0);
let b = Point::new(0.0, 10.0);
let c = Point::new(0.0, 7.0);
assert!(folding_vertical(a, b, c));
let z = check_folding(&[a, b, c], false).expect("folds");
let bbox = z.path.bounding_box();
assert_eq!((bbox.y0, bbox.y1), (7.0, 10.0));
}
#[test]
fn folding_horizontal_and_diagonal_pick_by_x_distance() {
let a = Point::new(0.0, 3.0);
let b = Point::new(10.0, 3.0);
let c = Point::new(7.0, 3.0);
assert!(folding_horizontal(a, b, c));
assert!(!folding_vertical(a, b, c));
let a = Point::new(0.0, 0.0);
let b = Point::new(10.0, 10.0);
let c = Point::new(7.0, 7.0);
assert!(folding_diagonal(a, b, c));
assert!(folding_diagonal(
Point::new(0.0, 0.0),
Point::new(5.0, 5.0),
Point::new(9.0, 9.0)
));
assert!(!folding_diagonal(
a,
Point::new(0.0, 5.0),
Point::new(0.0, 3.0)
));
}
#[test]
fn folding_thin_only_above_three_points() {
let z = check_folding(&pts(&[(0.0, 0.0), (0.0, 10.0), (0.0, 7.0)]), false).expect("folds");
assert!(!z.thin, "points.len() > 3 is required for thin");
}
#[test]
fn thin_alpha_quarter_is_a_shift() {
assert_eq!(thin_alpha(255), 63);
assert_eq!(thin_alpha(127), 31);
assert_eq!(thin_alpha(3), 0);
}
#[test]
fn sub_paths_are_detected_independently() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((10.0, 0.0)); p.move_to((20.0, 20.0));
p.line_to((30.0, 20.0));
p.line_to((30.0, 30.0));
p.line_to((20.0, 30.0));
p.close_path(); let found = zero_area_sub_paths(&p, None, false);
assert_eq!(found.len(), 1);
}
#[test]
fn the_reused_scratch_finds_exactly_what_a_fresh_one_finds() {
fn allocating_reference(
path: &BezPath,
transform: Option<kurbo::Affine>,
adjust: bool,
) -> Vec<ZeroArea> {
struct SubPath {
points: Vec<Point>,
has_curve: bool,
}
let mut subs: Vec<SubPath> = Vec::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => subs.push(SubPath {
points: vec![p],
has_curve: false,
}),
PathEl::LineTo(p) => {
if let Some(last) = subs.last_mut() {
last.points.push(p);
}
}
PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => {
if let Some(last) = subs.last_mut() {
last.points.push(p);
last.has_curve = true;
}
}
PathEl::ClosePath => {}
}
}
subs.into_iter()
.filter_map(|sp| zero_area_path(&sp.points, sp.has_curve, transform, adjust))
.collect()
}
let mut boundaries = BezPath::new();
boundaries.move_to((0.0, 0.0));
boundaries.line_to((9.0, 0.0));
boundaries.close_path();
boundaries.move_to((1.0, 1.0));
boundaries.move_to((2.0, 2.0));
boundaries.line_to((2.0, 8.0));
let mut several = BezPath::new();
several.move_to((0.0, 0.0));
several.line_to((10.0, 0.0));
several.move_to((20.0, 20.0));
several.line_to((30.0, 20.0));
several.line_to((30.0, 30.0));
several.line_to((20.0, 30.0));
several.close_path();
several.move_to((40.0, 40.0));
several.line_to((50.0, 50.0));
several.line_to((40.0, 40.0));
let mut curved = BezPath::new();
curved.move_to((0.0, 0.0));
curved.curve_to((1.0, 1.0), (2.0, 2.0), (3.0, 3.0));
let mut bare = BezPath::new();
bare.move_to((7.0, 7.0));
let paths = [boundaries, several, curved, bare, BezPath::new()];
let mut scratch = Scratch::default();
for path in &paths {
for adjust in [false, true] {
for transform in [None, Some(kurbo::Affine::scale(2.0))] {
let fresh = allocating_reference(path, transform, adjust);
let reused = scan_into(&mut scratch, path, transform, adjust);
assert_eq!(fresh.as_slice(), reused, "{path:?} adjust={adjust}");
}
}
}
}
}