use kurbo::{Affine, BezPath, Dashes, Shape, Stroke};
use pdfrum_page::{LineCap, LineJoin, StrokeParams};
pub const MIN_DASH_CYCLE: f64 = 0.1;
pub const DASH_ZERO_SUBSTITUTE: f64 = 0.1;
pub const MAX_DASHES: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StrokeMatrices {
pub pre: Affine,
pub post: Affine,
pub scale: f64,
}
fn x_unit(m: Affine) -> f64 {
let [a, b, ..] = m.as_coeffs();
if b == 0.0 {
a.abs()
} else if a == 0.0 {
b.abs()
} else {
a.hypot(b)
}
}
fn y_unit(m: Affine) -> f64 {
let [_, _, c, d, _, _] = m.as_coeffs();
if d == 0.0 {
c.abs()
} else if c == 0.0 {
d.abs()
} else {
c.hypot(d)
}
}
#[must_use]
#[expect(
clippy::many_single_char_names,
reason = "a..d are the affine matrix coefficients, named as in the PDF `cm` operands"
)]
pub fn split_for_stroke(m: Affine) -> StrokeMatrices {
let [a, b, c, d, _, _] = m.as_coeffs();
let scale = a.abs().max(b.abs());
if scale == 0.0 || !scale.is_finite() {
return StrokeMatrices {
pre: Affine::IDENTITY,
post: m,
scale: 1.0,
};
}
let post = Affine::new([a / scale, b / scale, c / scale, d / scale, 0.0, 0.0]);
let pre = match post.inverse() {
inv if inv.as_coeffs().iter().all(|v| v.is_finite()) => inv * m,
_ => Affine::scale(scale),
};
StrokeMatrices { pre, post, scale }
}
#[must_use]
#[expect(
clippy::manual_midpoint,
reason = "`(x + y) / 2.0` is upstream's own spelling of the mean unit; \
`f64::midpoint` rounds once where this rounds twice, and the \
result feeds the one-device-pixel floor that every hairline in \
the corpus lands on exactly"
)]
pub fn device_width(line_width: f32, matrices: StrokeMatrices) -> f64 {
let mean_unit = (x_unit(matrices.post) + y_unit(matrices.post)) / 2.0;
let unit = if mean_unit > 0.0 && mean_unit.is_finite() {
1.0 / mean_unit
} else {
1.0
};
let scaled = f64::from(line_width) * matrices.scale;
if scaled.is_finite() {
scaled.max(unit)
} else {
unit
}
}
#[must_use]
pub fn hairline_matrices(m: Affine) -> StrokeMatrices {
StrokeMatrices {
pre: m,
post: Affine::IDENTITY,
scale: 1.0,
}
}
#[must_use]
pub fn normalize_dashes(params: &StrokeParams, scale: f64) -> Option<(Dashes, f64)> {
if params.dash.is_empty() {
return None;
}
if params.dash.iter().any(|v| !v.is_finite()) {
return None;
}
let cycle: f64 = params.dash.iter().map(|v| f64::from(*v).max(0.0)).sum();
if cycle * scale < MIN_DASH_CYCLE {
return None;
}
let mut lengths: Dashes = params
.dash
.iter()
.take(MAX_DASHES)
.map(|v| {
let e = if f64::from(*v) <= 0.000_001 {
DASH_ZERO_SUBSTITUTE
} else {
f64::from(*v)
};
(e * scale).abs()
})
.collect();
if lengths.len() % 2 == 1 {
let doubled = lengths.clone();
lengths.extend(doubled);
}
if lengths.is_empty() || lengths.iter().sum::<f64>() <= 0.0 {
return None;
}
let total: f64 = lengths.iter().sum();
let mut phase = f64::from(params.dash_phase) * scale;
if phase < 0.0 && total > 0.0 {
let two_s = 2.0 * total;
phase += (-phase / two_s).ceil() * two_s;
}
Some((lengths, phase.max(0.0)))
}
#[must_use]
pub fn resolve_stroke(params: &StrokeParams, matrices: StrokeMatrices) -> Stroke {
let mut stroke = Stroke::new(device_width(params.width, matrices))
.with_caps(match params.cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
})
.with_join(match params.join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
})
.with_miter_limit(f64::from(params.miter_limit));
if let Some((dashes, phase)) = normalize_dashes(params, matrices.scale) {
stroke = stroke.with_dashes(phase, dashes);
}
stroke
}
#[must_use]
pub fn outline(path: &BezPath, to_device: Affine, params: &StrokeParams) -> BezPath {
let matrices = split_for_stroke(to_device);
let resolved = resolve_stroke(params, matrices);
let expanded = kurbo::stroke(
(matrices.pre * path.clone()).path_elements(STROKE_OUTLINE_TOLERANCE),
&resolved,
&kurbo::StrokeOpts::default(),
STROKE_OUTLINE_TOLERANCE,
);
matrices.post * expanded
}
const STROKE_OUTLINE_TOLERANCE: f64 = 0.1;
#[cfg(test)]
mod tests {
use smallvec::smallvec;
use super::*;
fn params(width: f32) -> StrokeParams {
StrokeParams {
width,
..StrokeParams::default()
}
}
#[test]
#[expect(
clippy::float_cmp,
reason = "under the identity the floor is the literal 1.0 and the pass \
-through the literal 3.0; a tolerance here would stop the \
test from catching a floor that drifted by an ulp"
)]
fn min_width_is_one_device_pixel() {
for m in [
Affine::IDENTITY,
Affine::scale(2.0),
Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0]),
] {
let split = split_for_stroke(m);
for w in [0.0f32, 0.01, 0.001] {
let dw = device_width(w, split);
assert!(dw > 0.0, "width must never collapse to zero");
assert!(
(dw * split.scale / split.scale - dw).abs() < 1e-9,
"the floor is expressed in matrix1 space"
);
}
}
assert_eq!(device_width(0.0, split_for_stroke(Affine::IDENTITY)), 1.0);
assert_eq!(device_width(0.5, split_for_stroke(Affine::IDENTITY)), 1.0);
assert_eq!(device_width(3.0, split_for_stroke(Affine::IDENTITY)), 3.0);
}
#[test]
fn matrix_split_recomposes() {
for m in [
Affine::rotate(0.7),
Affine::new([2.0, 0.0, 1.0, 3.0, 5.0, 6.0]),
Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 10.0]),
] {
let split = split_for_stroke(m);
let recomposed = split.post * split.pre;
for (a, b) in recomposed.as_coeffs().iter().zip(m.as_coeffs().iter()) {
assert!((a - b).abs() < 1e-9, "{recomposed:?} != {m:?}");
}
}
}
#[test]
fn matrix_split_takes_the_larger_diagonal() {
let split = split_for_stroke(Affine::new([3.0, -7.0, 0.0, 1.0, 0.0, 0.0]));
assert!((split.scale - 7.0).abs() < 1e-9);
}
#[test]
fn degenerate_matrix_does_not_divide_by_zero() {
let split = split_for_stroke(Affine::new([0.0, 0.0, 1.0, 1.0, 0.0, 0.0]));
assert!(split.scale.is_finite());
assert!(device_width(1.0, split).is_finite());
}
#[test]
fn dash_tiny_cycle_is_solid() {
let p = StrokeParams {
dash: smallvec![0.02, 0.02],
..params(1.0)
};
assert!(
normalize_dashes(&p, 1.0).is_none(),
"cycle 0.04 < 0.1 is solid"
);
assert!(normalize_dashes(&p, 10.0).is_some());
}
#[test]
fn dash_nonfinite_is_solid() {
let p = StrokeParams {
dash: smallvec![f32::NAN, 3.0],
..params(1.0)
};
assert!(normalize_dashes(&p, 1.0).is_none());
let p = StrokeParams {
dash: smallvec![f32::INFINITY, 3.0],
..params(1.0)
};
assert!(normalize_dashes(&p, 1.0).is_none());
}
#[test]
fn dash_zero_entry_becomes_point_one() {
let p = StrokeParams {
dash: smallvec![0.0, 5.0],
..params(1.0)
};
let (lengths, _) = normalize_dashes(&p, 1.0).expect("not solid");
assert!((lengths.first().copied().unwrap_or(0.0) - 0.1).abs() < 1e-9);
}
#[test]
fn dash_odd_array_doubles_the_cycle() {
let p = StrokeParams {
dash: smallvec![5.0, 2.0, 1.0],
..params(1.0)
};
let (lengths, _) = normalize_dashes(&p, 1.0).expect("not solid");
assert_eq!(lengths.len(), 6);
assert_eq!(&lengths[..], &[5.0, 2.0, 1.0, 5.0, 2.0, 1.0]);
}
#[test]
fn dash_over_32_entries_truncated() {
let p = StrokeParams {
dash: (0..40).map(|_| 3.0f32).collect(),
..params(1.0)
};
let (lengths, _) = normalize_dashes(&p, 1.0).expect("not solid");
assert_eq!(
lengths.len(),
MAX_DASHES,
"silently truncated, not rejected"
);
}
#[test]
fn dash_negative_phase_is_folded_forward() {
let p = StrokeParams {
dash: smallvec![4.0, 4.0],
dash_phase: -30.0,
..params(1.0)
};
let (_, phase) = normalize_dashes(&p, 1.0).expect("not solid");
assert!(
phase >= 0.0,
"a negative phase is incremented, not clamped away"
);
assert!((phase - 2.0).abs() < 1e-9, "phase = {phase}");
}
#[test]
fn resolve_stroke_never_hands_a_backend_a_zero_width() {
let s = resolve_stroke(¶ms(0.0), split_for_stroke(Affine::IDENTITY));
assert!(
s.width > 0.0,
"the backends' own hairline paths must stay dead code"
);
}
#[test]
#[expect(
clippy::float_cmp,
reason = "the hairline split leaves the post matrix the identity, so \
the width is the literal 1.0 the floor writes"
)]
fn hairline_matrices_leave_the_path_in_device_space() {
let m = Affine::new([2.0, 0.0, 0.0, 3.0, 4.0, 5.0]);
let h = hairline_matrices(m);
assert_eq!(h.pre, m);
assert_eq!(h.post, Affine::IDENTITY);
assert_eq!(device_width(0.0, h), 1.0, "exactly one device pixel");
}
}