use crate::ops::{LineCap, LineJoin};
use pdfrum_common::{DiagKind, Diagnostics, Severity};
use smallvec::SmallVec;
pub const MIN_DASH_CYCLE: f32 = 0.1;
const DASH_EPSILON: f32 = 0.000_001;
#[derive(Debug, Clone, PartialEq)]
pub struct StrokeParams {
pub width: f32,
pub cap: LineCap,
pub join: LineJoin,
pub miter_limit: f32,
pub dash: SmallVec<[f32; 4]>,
pub dash_phase: f32,
}
impl Default for StrokeParams {
fn default() -> Self {
Self {
width: 1.0,
cap: LineCap::Butt,
join: LineJoin::Miter,
miter_limit: 10.0,
dash: SmallVec::new(),
dash_phase: 0.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct NormalizedDash {
pub lengths: SmallVec<[f32; 4]>,
pub phase: f32,
}
impl StrokeParams {
#[must_use]
pub fn normalized_dash(&self, scale: f32, diags: &mut Diagnostics) -> NormalizedDash {
if self.dash.is_empty() {
return NormalizedDash::default();
}
if self.dash.iter().any(|v| !v.is_finite()) {
diags.record(Severity::Recovered, DiagKind::DashPatternDropped, None);
return NormalizedDash::default();
}
let cycle: f32 = self.dash.iter().map(|v| v.max(0.0)).sum();
if cycle * scale < MIN_DASH_CYCLE {
diags.record(Severity::Recovered, DiagKind::DashPatternDropped, None);
return NormalizedDash::default();
}
let mut clamped = false;
let lengths = self
.dash
.iter()
.map(|v| {
let len = if *v <= DASH_EPSILON {
clamped = true;
MIN_DASH_CYCLE
} else {
*v
};
(len * scale).abs()
})
.collect();
if clamped {
diags.record(Severity::Recovered, DiagKind::DashElementClamped, None);
}
NormalizedDash {
lengths,
phase: self.dash_phase * scale,
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{MIN_DASH_CYCLE, StrokeParams};
use crate::ops::{LineCap, LineJoin};
use pdfrum_common::{DiagKind, Diagnostics};
use smallvec::SmallVec;
fn params(dash: &[f32]) -> StrokeParams {
StrokeParams {
dash: SmallVec::from_slice(dash),
..StrokeParams::default()
}
}
#[test]
fn the_defaults_are_pdfiums() {
let p = StrokeParams::default();
assert!((p.width - 1.0).abs() < 1e-6);
assert!((p.miter_limit - 10.0).abs() < 1e-6);
assert_eq!(p.cap, LineCap::Butt);
assert_eq!(p.join, LineJoin::Miter);
assert!(p.dash.is_empty());
assert!(p.dash_phase.abs() < 1e-6);
}
#[test]
fn an_empty_array_is_a_solid_line() {
let mut diags = Diagnostics::default();
assert!(
params(&[])
.normalized_dash(1.0, &mut diags)
.lengths
.is_empty()
);
assert!(diags.is_empty(), "a solid line is not a recovery");
}
#[test]
fn a_single_non_finite_element_abandons_the_whole_pattern() {
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut diags = Diagnostics::default();
let got = params(&[3.0, bad, 3.0]).normalized_dash(1.0, &mut diags);
assert!(got.lengths.is_empty(), "for {bad}");
assert!(diags.contains(&DiagKind::DashPatternDropped));
}
}
#[test]
fn a_sub_threshold_cycle_is_a_solid_line() {
let mut diags = Diagnostics::default();
let got = params(&[0.01, 0.01]).normalized_dash(1.0, &mut diags);
assert!(got.lengths.is_empty());
assert!(diags.contains(&DiagKind::DashPatternDropped));
let mut diags = Diagnostics::default();
let got = params(&[0.01, 0.01]).normalized_dash(10.0, &mut diags);
assert!(!got.lengths.is_empty());
}
#[test]
fn zeros_and_negatives_both_become_one_tenth() {
let mut diags = Diagnostics::default();
let got = params(&[0.0, 3.0]).normalized_dash(1.0, &mut diags);
assert!((got.lengths[0] - MIN_DASH_CYCLE).abs() < 1e-6);
assert!((got.lengths[1] - 3.0).abs() < 1e-6);
assert!(diags.contains(&DiagKind::DashElementClamped));
let mut diags = Diagnostics::default();
let got = params(&[-2.0, 3.0]).normalized_dash(1.0, &mut diags);
assert!(
(got.lengths[0] - MIN_DASH_CYCLE).abs() < 1e-6,
"got {}, expected 0.1 rather than 2.0",
got.lengths[0]
);
}
#[test]
fn an_odd_length_array_is_used_as_given() {
let mut diags = Diagnostics::default();
let got = params(&[3.0]).normalized_dash(1.0, &mut diags);
assert_eq!(got.lengths.len(), 1);
assert!((got.lengths[0] - 3.0).abs() < 1e-6);
}
#[test]
fn the_phase_is_scaled_and_unclamped() {
let mut diags = Diagnostics::default();
let p = StrokeParams {
dash: SmallVec::from_slice(&[3.0, 3.0]),
dash_phase: -5.0,
..StrokeParams::default()
};
let got = p.normalized_dash(2.0, &mut diags);
assert!((got.phase + 10.0).abs() < 1e-6, "negatives survive here");
}
}