use alloc::{string::String, vec::Vec};
use azul_css::props::basic::{SvgCubicCurve, SvgPoint, SvgQuadraticCurve};
use crate::svg::{SvgLine, SvgMultiPolygon, SvgPath, SvgPathElement, SvgPathElementVec, SvgPathVec};
const KAPPA: f32 = 0.552_284_8;
const POINT_EPSILON: f32 = 1e-6;
const CLOSEPATH_EPSILON: f32 = 0.001;
const ZERO_LENGTH_EPSILON: f32 = 1e-10;
const ARC_SPLIT_FUDGE: f32 = 0.001;
fn char_at(input: &[u8], pos: usize) -> char {
input
.get(pos..)
.and_then(|rest| core::str::from_utf8(rest).ok())
.and_then(|s| s.chars().next())
.unwrap_or(char::REPLACEMENT_CHARACTER)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SvgPathParseError {
EmptyPath,
UnexpectedChar { pos: usize, ch: char },
ExpectedNumber { pos: usize },
InvalidArcFlag { pos: usize },
}
impl core::fmt::Display for SvgPathParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::EmptyPath => write!(f, "empty path"),
Self::UnexpectedChar { pos, ch } => {
write!(f, "unexpected char '{ch}' at byte {pos}")
}
Self::ExpectedNumber { pos } => write!(f, "expected number at byte {pos}"),
Self::InvalidArcFlag { pos } => write!(f, "invalid arc flag at byte {pos}"),
}
}
}
struct PathParser<'a> {
input: &'a [u8],
pos: usize,
current: SvgPoint,
subpath_start: SvgPoint,
last_control: Option<SvgPoint>,
last_command: u8,
}
impl<'a> PathParser<'a> {
const fn new(input: &'a [u8]) -> Self {
Self {
input,
pos: 0,
current: SvgPoint { x: 0.0, y: 0.0 },
subpath_start: SvgPoint { x: 0.0, y: 0.0 },
last_control: None,
last_command: 0,
}
}
const fn at_end(&self) -> bool {
self.pos >= self.input.len()
}
fn peek(&self) -> Option<u8> {
self.input.get(self.pos).copied()
}
fn skip_whitespace_and_commas(&mut self) {
while let Some(&b) = self.input.get(self.pos) {
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b',' {
self.pos += 1;
} else {
break;
}
}
}
fn skip_whitespace(&mut self) {
while let Some(&b) = self.input.get(self.pos) {
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
self.pos += 1;
} else {
break;
}
}
}
fn has_number(&self) -> bool {
match self.input.get(self.pos) {
Some(b'+' | b'-' | b'.') => true,
Some(b) if b.is_ascii_digit() => true,
_ => false,
}
}
fn parse_number(&mut self) -> Result<f32, SvgPathParseError> {
self.skip_whitespace_and_commas();
let start = self.pos;
if let Some(&b) = self.input.get(self.pos) {
if b == b'+' || b == b'-' {
self.pos += 1;
}
}
let mut has_digits = false;
while let Some(&b) = self.input.get(self.pos) {
if b.is_ascii_digit() {
self.pos += 1;
has_digits = true;
} else {
break;
}
}
if self.input.get(self.pos) == Some(&b'.') {
self.pos += 1;
while let Some(&b) = self.input.get(self.pos) {
if b.is_ascii_digit() {
self.pos += 1;
has_digits = true;
} else {
break;
}
}
}
if !has_digits {
return Err(SvgPathParseError::ExpectedNumber { pos: start });
}
if let Some(&b) = self.input.get(self.pos) {
if b == b'e' || b == b'E' {
self.pos += 1;
if let Some(&b) = self.input.get(self.pos) {
if b == b'+' || b == b'-' {
self.pos += 1;
}
}
while let Some(&b) = self.input.get(self.pos) {
if b.is_ascii_digit() {
self.pos += 1;
} else {
break;
}
}
}
}
let s = core::str::from_utf8(&self.input[start..self.pos])
.map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })?;
s.parse::<f32>()
.map_err(|_| SvgPathParseError::ExpectedNumber { pos: start })
}
fn parse_flag(&mut self) -> Result<bool, SvgPathParseError> {
self.skip_whitespace_and_commas();
match self.input.get(self.pos) {
Some(b'0') => {
self.pos += 1;
Ok(false)
}
Some(b'1') => {
self.pos += 1;
Ok(true)
}
_ => Err(SvgPathParseError::InvalidArcFlag { pos: self.pos }),
}
}
fn parse_coordinate_pair(&mut self) -> Result<(f32, f32), SvgPathParseError> {
let x = self.parse_number()?;
let y = self.parse_number()?;
Ok((x, y))
}
fn make_absolute(&self, x: f32, y: f32, relative: bool) -> SvgPoint {
if relative {
SvgPoint {
x: self.current.x + x,
y: self.current.y + y,
}
} else {
SvgPoint { x, y }
}
}
fn handle_line_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let (x, y) = self.parse_coordinate_pair()?;
let end = self.make_absolute(x, y, relative);
elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
self.current = end;
self.last_control = None;
Ok(())
}
fn handle_horizontal_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let x = self.parse_number()?;
let abs_x = if relative { self.current.x + x } else { x };
let end = SvgPoint { x: abs_x, y: self.current.y };
elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
self.current = end;
self.last_control = None;
Ok(())
}
fn handle_vertical_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let y = self.parse_number()?;
let abs_y = if relative { self.current.y + y } else { y };
let end = SvgPoint { x: self.current.x, y: abs_y };
elements.push(SvgPathElement::Line(SvgLine { start: self.current, end }));
self.current = end;
self.last_control = None;
Ok(())
}
#[allow(clippy::similar_names)] fn handle_cubic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let (c1x, c1y) = self.parse_coordinate_pair()?;
let (c2x, c2y) = self.parse_coordinate_pair()?;
let (ex, ey) = self.parse_coordinate_pair()?;
let ctrl_1 = self.make_absolute(c1x, c1y, relative);
let ctrl_2 = self.make_absolute(c2x, c2y, relative);
let end = self.make_absolute(ex, ey, relative);
elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: self.current, ctrl_1, ctrl_2, end,
}));
self.last_control = Some(ctrl_2);
self.current = end;
Ok(())
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::similar_names)] fn handle_smooth_cubic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let ctrl_1 = match self.last_control {
Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'C' | b'S') => {
SvgPoint {
x: 2.0 * self.current.x - lc.x,
y: 2.0 * self.current.y - lc.y,
}
}
_ => self.current,
};
let (c2x, c2y) = self.parse_coordinate_pair()?;
let (ex, ey) = self.parse_coordinate_pair()?;
let ctrl_2 = self.make_absolute(c2x, c2y, relative);
let end = self.make_absolute(ex, ey, relative);
elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: self.current, ctrl_1, ctrl_2, end,
}));
self.last_control = Some(ctrl_2);
self.current = end;
Ok(())
}
fn handle_quadratic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let (cx, cy) = self.parse_coordinate_pair()?;
let (ex, ey) = self.parse_coordinate_pair()?;
let ctrl = self.make_absolute(cx, cy, relative);
let end = self.make_absolute(ex, ey, relative);
elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
start: self.current, ctrl, end,
}));
self.last_control = Some(ctrl);
self.current = end;
Ok(())
}
#[allow(clippy::suboptimal_flops)] fn handle_smooth_quadratic_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let ctrl = match self.last_control {
Some(lc) if matches!(self.last_command.to_ascii_uppercase(), b'Q' | b'T') => {
SvgPoint {
x: 2.0 * self.current.x - lc.x,
y: 2.0 * self.current.y - lc.y,
}
}
_ => self.current,
};
let (ex, ey) = self.parse_coordinate_pair()?;
let end = self.make_absolute(ex, ey, relative);
elements.push(SvgPathElement::QuadraticCurve(SvgQuadraticCurve {
start: self.current, ctrl, end,
}));
self.last_control = Some(ctrl);
self.current = end;
Ok(())
}
fn handle_arc_to(&mut self, relative: bool, elements: &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError> {
let rx = self.parse_number()?.abs();
let ry = self.parse_number()?.abs();
let x_rotation = self.parse_number()?;
let large_arc = self.parse_flag()?;
let sweep = self.parse_flag()?;
let (ex, ey) = self.parse_coordinate_pair()?;
let end = self.make_absolute(ex, ey, relative);
arc_to_cubics(self.current, end, rx, ry, x_rotation, large_arc, sweep, elements);
self.current = end;
self.last_control = None;
Ok(())
}
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::too_many_lines)] pub fn parse_svg_path_d(d: &str) -> Result<SvgMultiPolygon, SvgPathParseError> {
let d = d.trim();
if d.is_empty() {
return Err(SvgPathParseError::EmptyPath);
}
let mut parser = PathParser::new(d.as_bytes());
let mut rings: Vec<SvgPath> = Vec::new();
let mut current_elements: Vec<SvgPathElement> = Vec::new();
parser.skip_whitespace();
while !parser.at_end() {
parser.skip_whitespace_and_commas();
if parser.at_end() {
break;
}
let b = parser.peek().unwrap();
let cmd = if b.is_ascii_alphabetic() {
parser.pos += 1;
b
} else if parser.last_command != 0 {
match parser.last_command {
b'M' => b'L',
b'm' => b'l',
b'Z' | b'z' => {
return Err(SvgPathParseError::UnexpectedChar {
pos: parser.pos,
ch: char_at(parser.input, parser.pos),
});
}
other => other,
}
} else {
return Err(SvgPathParseError::UnexpectedChar {
pos: parser.pos,
ch: char_at(parser.input, parser.pos),
});
};
let relative = cmd.is_ascii_lowercase();
let cmd_upper = cmd.to_ascii_uppercase();
match cmd_upper {
b'M' => {
if !current_elements.is_empty() {
rings.push(SvgPath {
items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
});
}
let (x, y) = parser.parse_coordinate_pair()?;
let pt = parser.make_absolute(x, y, relative);
parser.current = pt;
parser.subpath_start = pt;
parser.last_control = None;
parser.last_command = cmd;
}
b'L' => {
parser.handle_line_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'H' => {
parser.handle_horizontal_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'V' => {
parser.handle_vertical_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'C' => {
parser.handle_cubic_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'S' => {
parser.handle_smooth_cubic_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'Q' => {
parser.handle_quadratic_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'T' => {
parser.handle_smooth_quadratic_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'A' => {
parser.handle_arc_to(relative, &mut current_elements)?;
parser.last_command = cmd;
}
b'Z' => {
let dx = parser.current.x - parser.subpath_start.x;
let dy = parser.current.y - parser.subpath_start.y;
if dx * dx + dy * dy > CLOSEPATH_EPSILON * CLOSEPATH_EPSILON {
current_elements.push(SvgPathElement::Line(SvgLine {
start: parser.current,
end: parser.subpath_start,
}));
}
parser.current = parser.subpath_start;
parser.last_control = None;
parser.last_command = cmd;
if !current_elements.is_empty() {
rings.push(SvgPath {
items: SvgPathElementVec::from_vec(core::mem::take(&mut current_elements)),
});
}
}
_ => {
return Err(SvgPathParseError::UnexpectedChar {
pos: parser.pos - 1,
ch: cmd as char,
});
}
}
if cmd_upper != b'M' && cmd_upper != b'Z' {
loop {
parser.skip_whitespace_and_commas();
if parser.at_end() {
break;
}
let next = parser.peek().unwrap();
if next.is_ascii_alphabetic() {
break; }
if !parser.has_number() {
break;
}
match cmd_upper {
b'L' => parser.handle_line_to(relative, &mut current_elements)?,
b'H' => parser.handle_horizontal_to(relative, &mut current_elements)?,
b'V' => parser.handle_vertical_to(relative, &mut current_elements)?,
b'C' => parser.handle_cubic_to(relative, &mut current_elements)?,
b'S' => parser.handle_smooth_cubic_to(relative, &mut current_elements)?,
b'Q' => parser.handle_quadratic_to(relative, &mut current_elements)?,
b'T' => parser.handle_smooth_quadratic_to(relative, &mut current_elements)?,
b'A' => parser.handle_arc_to(relative, &mut current_elements)?,
_ => break,
}
}
}
}
if !current_elements.is_empty() {
rings.push(SvgPath {
items: SvgPathElementVec::from_vec(current_elements),
});
}
Ok(SvgMultiPolygon {
rings: SvgPathVec::from_vec(rings),
})
}
#[allow(clippy::suboptimal_flops)] #[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
#[allow(clippy::similar_names)] fn arc_to_cubics(
start: SvgPoint,
end: SvgPoint,
mut rx: f32,
mut ry: f32,
x_rotation_deg: f32,
large_arc: bool,
sweep: bool,
out: &mut Vec<SvgPathElement>,
) {
if (start.x - end.x).abs() < POINT_EPSILON && (start.y - end.y).abs() < POINT_EPSILON {
return;
}
if rx < POINT_EPSILON || ry < POINT_EPSILON {
out.push(SvgPathElement::Line(SvgLine { start, end }));
return;
}
let phi = x_rotation_deg.to_radians();
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let dx = (start.x - end.x) / 2.0;
let dy = (start.y - end.y) / 2.0;
let x1p = cos_phi * dx + sin_phi * dy;
let y1p = -sin_phi * dx + cos_phi * dy;
let x1p2 = x1p * x1p;
let y1p2 = y1p * y1p;
let mut rx2 = rx * rx;
let mut ry2 = ry * ry;
let lambda = x1p2 / rx2 + y1p2 / ry2;
if lambda > 1.0 {
let sqrt_lambda = lambda.sqrt();
rx *= sqrt_lambda;
ry *= sqrt_lambda;
rx2 = rx * rx;
ry2 = ry * ry;
}
let num = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2).max(0.0);
let den = rx2 * y1p2 + ry2 * x1p2;
let sq = if den > 0.0 {
(num / den).sqrt()
} else {
0.0
};
let sign = if large_arc == sweep { -1.0 } else { 1.0 };
let cxp = sign * sq * (rx * y1p / ry);
let cyp = sign * sq * -(ry * x1p / rx);
let mx = f32::midpoint(start.x, end.x);
let my = f32::midpoint(start.y, end.y);
let cx = cos_phi * cxp - sin_phi * cyp + mx;
let cy = sin_phi * cxp + cos_phi * cyp + my;
let theta1 = angle_between(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry);
let mut dtheta = angle_between(
(x1p - cxp) / rx,
(y1p - cyp) / ry,
(-x1p - cxp) / rx,
(-y1p - cyp) / ry,
);
if !sweep && dtheta > 0.0 {
dtheta -= core::f32::consts::TAU;
} else if sweep && dtheta < 0.0 {
dtheta += core::f32::consts::TAU;
}
let n_segs = (dtheta.abs() / (core::f32::consts::FRAC_PI_2 + ARC_SPLIT_FUDGE)).ceil() as usize;
let n_segs = n_segs.max(1);
let seg_angle = dtheta / n_segs as f32;
let mut prev = start;
for i in 0..n_segs {
let t1 = theta1 + seg_angle * i as f32;
let t2 = theta1 + seg_angle * (i + 1) as f32;
let (c1, c2, ep) =
arc_segment_to_cubic(cx, cy, rx, ry, cos_phi, sin_phi, t1, t2);
let seg_end = if i + 1 == n_segs { end } else { ep };
out.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: prev,
ctrl_1: c1,
ctrl_2: c2,
end: seg_end,
}));
prev = seg_end;
}
}
#[allow(clippy::suboptimal_flops)] fn angle_between(ux: f32, uy: f32, vx: f32, vy: f32) -> f32 {
let dot = ux * vx + uy * vy;
let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt();
if len < ZERO_LENGTH_EPSILON {
return 0.0;
}
let cos_val = (dot / len).clamp(-1.0, 1.0);
let angle = cos_val.acos();
if ux * vy - uy * vx < 0.0 {
-angle
} else {
angle
}
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::similar_names)] fn arc_segment_to_cubic(
cx: f32,
cy: f32,
rx: f32,
ry: f32,
cos_phi: f32,
sin_phi: f32,
theta1: f32,
theta2: f32,
) -> (SvgPoint, SvgPoint, SvgPoint) {
let alpha = 4.0 / 3.0 * ((theta2 - theta1) / 4.0).tan();
let cos1 = theta1.cos();
let sin1 = theta1.sin();
let cos2 = theta2.cos();
let sin2 = theta2.sin();
let dx1 = rx * (cos1 - alpha * sin1);
let dy1 = ry * (sin1 + alpha * cos1);
let dx2 = rx * (cos2 + alpha * sin2);
let dy2 = ry * (sin2 - alpha * cos2);
let dx3 = rx * cos2;
let dy3 = ry * sin2;
let c1 = SvgPoint {
x: cos_phi * dx1 - sin_phi * dy1 + cx,
y: sin_phi * dx1 + cos_phi * dy1 + cy,
};
let c2 = SvgPoint {
x: cos_phi * dx2 - sin_phi * dy2 + cx,
y: sin_phi * dx2 + cos_phi * dy2 + cy,
};
let ep = SvgPoint {
x: cos_phi * dx3 - sin_phi * dy3 + cx,
y: sin_phi * dx3 + cos_phi * dy3 + cy,
};
(c1, c2, ep)
}
#[must_use]
pub fn svg_circle_to_paths(cx: f32, cy: f32, r: f32) -> SvgPath {
let k = r * KAPPA;
let elements = vec![
SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx, y: cy - r },
ctrl_1: SvgPoint {
x: cx + k,
y: cy - r,
},
ctrl_2: SvgPoint {
x: cx + r,
y: cy - k,
},
end: SvgPoint { x: cx + r, y: cy },
}),
SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx + r, y: cy },
ctrl_1: SvgPoint {
x: cx + r,
y: cy + k,
},
ctrl_2: SvgPoint {
x: cx + k,
y: cy + r,
},
end: SvgPoint { x: cx, y: cy + r },
}),
SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx, y: cy + r },
ctrl_1: SvgPoint {
x: cx - k,
y: cy + r,
},
ctrl_2: SvgPoint {
x: cx - r,
y: cy + k,
},
end: SvgPoint { x: cx - r, y: cy },
}),
SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx - r, y: cy },
ctrl_1: SvgPoint {
x: cx - r,
y: cy - k,
},
ctrl_2: SvgPoint {
x: cx - k,
y: cy - r,
},
end: SvgPoint { x: cx, y: cy - r },
}),
];
SvgPath {
items: SvgPathElementVec::from_vec(elements),
}
}
#[must_use]
#[allow(clippy::vec_init_then_push)]
#[allow(clippy::too_many_lines)] pub fn svg_rect_to_path(x: f32, y: f32, w: f32, h: f32, rx: f32, ry: f32) -> SvgPath {
let rx = rx.min(w / 2.0);
let ry = ry.min(h / 2.0);
if rx < CLOSEPATH_EPSILON && ry < CLOSEPATH_EPSILON {
let tl = SvgPoint { x, y };
let tr = SvgPoint { x: x + w, y };
let br = SvgPoint { x: x + w, y: y + h };
let bl = SvgPoint { x, y: y + h };
let elements = vec![
SvgPathElement::Line(SvgLine { start: tl, end: tr }),
SvgPathElement::Line(SvgLine { start: tr, end: br }),
SvgPathElement::Line(SvgLine {
start: br,
end: bl,
}),
SvgPathElement::Line(SvgLine { start: bl, end: tl }),
];
return SvgPath {
items: SvgPathElementVec::from_vec(elements),
};
}
let kx = rx * KAPPA;
let ky = ry * KAPPA;
let mut elements = Vec::with_capacity(8);
elements.push(SvgPathElement::Line(SvgLine {
start: SvgPoint { x: x + rx, y },
end: SvgPoint { x: x + w - rx, y },
}));
elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: x + w - rx, y },
ctrl_1: SvgPoint {
x: x + w - rx + kx,
y,
},
ctrl_2: SvgPoint {
x: x + w,
y: y + ry - ky,
},
end: SvgPoint {
x: x + w,
y: y + ry,
},
}));
elements.push(SvgPathElement::Line(SvgLine {
start: SvgPoint {
x: x + w,
y: y + ry,
},
end: SvgPoint {
x: x + w,
y: y + h - ry,
},
}));
elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint {
x: x + w,
y: y + h - ry,
},
ctrl_1: SvgPoint {
x: x + w,
y: y + h - ry + ky,
},
ctrl_2: SvgPoint {
x: x + w - rx + kx,
y: y + h,
},
end: SvgPoint {
x: x + w - rx,
y: y + h,
},
}));
elements.push(SvgPathElement::Line(SvgLine {
start: SvgPoint {
x: x + w - rx,
y: y + h,
},
end: SvgPoint { x: x + rx, y: y + h },
}));
elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: x + rx, y: y + h },
ctrl_1: SvgPoint {
x: x + rx - kx,
y: y + h,
},
ctrl_2: SvgPoint {
x,
y: y + h - ry + ky,
},
end: SvgPoint { x, y: y + h - ry },
}));
elements.push(SvgPathElement::Line(SvgLine {
start: SvgPoint { x, y: y + h - ry },
end: SvgPoint { x, y: y + ry },
}));
elements.push(SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x, y: y + ry },
ctrl_1: SvgPoint {
x,
y: y + ry - ky,
},
ctrl_2: SvgPoint {
x: x + rx - kx,
y,
},
end: SvgPoint { x: x + rx, y },
}));
SvgPath {
items: SvgPathElementVec::from_vec(elements),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn m0_0z5_does_not_hang() {
let err = parse_svg_path_d("M0 0Z5").unwrap_err();
match err {
SvgPathParseError::UnexpectedChar { ch, .. } => assert_eq!(ch, '5'),
other => panic!("expected UnexpectedChar, got {other:?}"),
}
}
#[test]
fn stray_byte_after_closepath_rejected() {
for s in ["M0 0Z9", "m0 0z-", "M0 0Z."] {
assert!(
matches!(
parse_svg_path_d(s),
Err(SvgPathParseError::UnexpectedChar { .. })
),
"expected UnexpectedChar for {s:?}"
);
}
}
#[test]
fn error_char_is_unicode_not_byte() {
let err = parse_svg_path_d("ü10 10").unwrap_err();
match err {
SvgPathParseError::UnexpectedChar { ch, pos } => {
assert_eq!(ch, 'ü');
assert_eq!(pos, 0);
}
other => panic!("expected UnexpectedChar, got {other:?}"),
}
}
#[test]
fn valid_closepath_then_command_ok() {
let parsed = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z");
assert!(parsed.is_ok(), "valid multi-subpath path should parse");
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)] mod autotest_generated {
use alloc::format;
use super::*;
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
fn all_points(path: &SvgPath) -> Vec<SvgPoint> {
let mut out = Vec::new();
for e in path.items.as_ref() {
match e {
SvgPathElement::Line(l) => {
out.push(l.start);
out.push(l.end);
}
SvgPathElement::QuadraticCurve(q) => {
out.push(q.start);
out.push(q.ctrl);
out.push(q.end);
}
SvgPathElement::CubicCurve(c) => {
out.push(c.start);
out.push(c.ctrl_1);
out.push(c.ctrl_2);
out.push(c.end);
}
}
}
out
}
fn assert_contiguous(items: &[SvgPathElement], what: &str) {
for w in items.windows(2) {
assert_eq!(
w[0].get_end(),
w[1].get_start(),
"{what}: element chain is not contiguous"
);
}
}
#[test]
fn char_at_zero_and_ascii() {
assert_eq!(char_at(b"M0 0", 0), 'M');
assert_eq!(char_at(b"M0 0", 3), ' ');
}
#[test]
fn char_at_empty_input_is_replacement() {
assert_eq!(char_at(b"", 0), char::REPLACEMENT_CHARACTER);
}
#[test]
fn char_at_past_end_is_replacement_not_panic() {
assert_eq!(char_at(b"abc", 3), char::REPLACEMENT_CHARACTER);
assert_eq!(char_at(b"abc", 4), char::REPLACEMENT_CHARACTER);
}
#[test]
fn char_at_usize_max_is_replacement() {
assert_eq!(char_at(b"abc", usize::MAX), char::REPLACEMENT_CHARACTER);
assert_eq!(char_at(b"", usize::MAX), char::REPLACEMENT_CHARACTER);
}
#[test]
fn char_at_decodes_multibyte_not_latin1() {
assert_eq!(char_at("ü".as_bytes(), 0), 'ü');
assert_eq!(char_at("€".as_bytes(), 0), '€');
assert_eq!(char_at("\u{1F600}".as_bytes(), 0), '\u{1F600}');
}
#[test]
fn char_at_mid_codepoint_offset_is_replacement() {
let bytes = "😀".as_bytes(); for pos in 1..bytes.len() {
assert_eq!(
char_at(bytes, pos),
char::REPLACEMENT_CHARACTER,
"mid-codepoint offset {pos} must not panic"
);
}
}
#[test]
fn char_at_invalid_utf8_tail_is_replacement() {
assert_eq!(char_at(&[b'A', 0xFF], 0), char::REPLACEMENT_CHARACTER);
assert_eq!(char_at(&[0xFF], 0), char::REPLACEMENT_CHARACTER);
assert_eq!(char_at(&[b'A', b'B'], 0), 'A');
}
#[test]
fn error_display_is_non_empty_for_every_variant() {
let variants = [
SvgPathParseError::EmptyPath,
SvgPathParseError::UnexpectedChar { pos: 0, ch: 'x' },
SvgPathParseError::ExpectedNumber { pos: 0 },
SvgPathParseError::InvalidArcFlag { pos: 0 },
];
for v in variants {
let s = format!("{v}");
assert!(!s.is_empty(), "Display for {v:?} must not be empty");
assert!(!format!("{v:?}").is_empty(), "Debug must not be empty");
}
}
#[test]
fn error_display_edge_values_do_not_panic() {
let s = format!(
"{}",
SvgPathParseError::UnexpectedChar {
pos: usize::MAX,
ch: char::REPLACEMENT_CHARACTER,
}
);
assert!(s.contains(&format!("{}", usize::MAX)));
assert!(s.contains(char::REPLACEMENT_CHARACTER));
for ch in ['\0', '\u{1F600}', '\u{0301}'] {
let s = format!("{}", SvgPathParseError::UnexpectedChar { pos: 0, ch });
assert!(!s.is_empty());
}
assert!(!format!("{}", SvgPathParseError::ExpectedNumber { pos: usize::MAX }).is_empty());
assert!(!format!("{}", SvgPathParseError::InvalidArcFlag { pos: usize::MAX }).is_empty());
}
#[test]
fn parser_new_invariants_hold() {
let p = PathParser::new(b"M0 0");
assert_eq!(p.pos, 0);
assert_eq!(p.current, SvgPoint { x: 0.0, y: 0.0 });
assert_eq!(p.subpath_start, SvgPoint { x: 0.0, y: 0.0 });
assert!(p.last_control.is_none());
assert_eq!(p.last_command, 0);
assert_eq!(p.input.len(), 4);
}
#[test]
fn parser_new_on_empty_input_does_not_panic() {
let p = PathParser::new(b"");
assert!(p.at_end(), "empty input is immediately at_end");
assert_eq!(p.peek(), None);
assert!(!p.has_number());
}
#[test]
fn at_end_and_peek_agree_across_the_whole_input() {
let mut p = PathParser::new(b"ab");
assert!(!p.at_end());
assert_eq!(p.peek(), Some(b'a'));
p.pos = 1;
assert!(!p.at_end());
assert_eq!(p.peek(), Some(b'b'));
p.pos = 2;
assert!(p.at_end());
assert_eq!(p.peek(), None);
}
#[test]
fn peek_and_at_end_at_extreme_positions() {
let mut p = PathParser::new(b"abc");
p.pos = usize::MAX;
assert!(p.at_end());
assert_eq!(p.peek(), None);
assert!(!p.has_number());
}
#[test]
fn skip_whitespace_and_commas_consumes_all_separators() {
let mut p = PathParser::new(b" \t\r\n,,, \tX");
p.skip_whitespace_and_commas();
assert_eq!(p.peek(), Some(b'X'));
}
#[test]
fn skip_whitespace_stops_at_comma() {
let mut p = PathParser::new(b" ,1");
p.skip_whitespace();
assert_eq!(p.peek(), Some(b','));
assert_eq!(p.pos, 2);
}
#[test]
fn skip_on_empty_and_all_separator_input_terminates() {
let mut p = PathParser::new(b"");
p.skip_whitespace();
p.skip_whitespace_and_commas();
assert!(p.at_end());
let all_ws = " \t\r\n,".repeat(20_000);
let mut p = PathParser::new(all_ws.as_bytes());
p.skip_whitespace_and_commas();
assert!(p.at_end(), "a huge run of separators must be fully consumed");
assert_eq!(p.pos, all_ws.len());
}
#[test]
fn skip_is_a_no_op_on_non_separator() {
let mut p = PathParser::new("😀".as_bytes());
p.skip_whitespace_and_commas();
assert_eq!(p.pos, 0);
let mut p = PathParser::new(b"\x0c\x0b1");
p.skip_whitespace();
assert_eq!(p.pos, 0);
}
#[test]
fn has_number_true_for_number_starters() {
for s in ["0", "9", "+", "-", ".", "5.5", "-.5"] {
assert!(
PathParser::new(s.as_bytes()).has_number(),
"{s:?} should look like a number start"
);
}
}
#[test]
fn has_number_false_for_non_number_starters() {
for s in ["", " ", ",", "M", "z", "e", "E", "😀", "\u{0301}"] {
assert!(
!PathParser::new(s.as_bytes()).has_number(),
"{s:?} should not look like a number start"
);
}
}
fn num(s: &str) -> Result<f32, SvgPathParseError> {
PathParser::new(s.as_bytes()).parse_number()
}
#[test]
fn parse_number_valid_minimal() {
assert_eq!(num("0").unwrap(), 0.0);
assert_eq!(num("5").unwrap(), 5.0);
assert_eq!(num("+5").unwrap(), 5.0);
assert_eq!(num("-5").unwrap(), -5.0);
assert!(approx(num("12.34").unwrap(), 12.34));
assert!(approx(num(".5").unwrap(), 0.5));
assert_eq!(num("5.").unwrap(), 5.0);
assert!(approx(num("1e2").unwrap(), 100.0));
assert!(approx(num("1E-2").unwrap(), 0.01));
assert!(approx(num(" ,, 7").unwrap(), 7.0), "leading separators skipped");
}
#[test]
fn parse_number_empty_input_is_err() {
assert_eq!(num(""), Err(SvgPathParseError::ExpectedNumber { pos: 0 }));
}
#[test]
fn parse_number_whitespace_only_is_err() {
assert_eq!(num(" "), Err(SvgPathParseError::ExpectedNumber { pos: 3 }));
assert_eq!(num("\t\n"), Err(SvgPathParseError::ExpectedNumber { pos: 2 }));
assert_eq!(num(" , "), Err(SvgPathParseError::ExpectedNumber { pos: 3 }));
}
#[test]
fn parse_number_garbage_is_err_never_panics() {
for s in ["abc", "@", "#$%", "-", "+", ".", "-.", "+.", "e5", "NaN", "inf", "-inf"] {
assert!(
matches!(num(s), Err(SvgPathParseError::ExpectedNumber { .. })),
"{s:?} must be rejected, got {:?}",
num(s)
);
}
}
#[test]
fn parse_number_dangling_exponent_is_err() {
for s in ["1e", "1E", "1e+", "1e-", "1.5e"] {
assert!(
matches!(num(s), Err(SvgPathParseError::ExpectedNumber { pos: 0 })),
"{s:?} must be rejected"
);
}
}
#[test]
fn parse_number_unicode_does_not_panic() {
for s in ["😀", "\u{0301}", "ü", "€1", "1"] {
assert!(matches!(num(s), Err(_)), "{s:?} must be rejected");
}
let mut p = PathParser::new("1😀".as_bytes());
assert_eq!(p.parse_number().unwrap(), 1.0);
assert_eq!(p.pos, 1);
}
#[test]
fn parse_number_boundary_values_saturate() {
assert!(num("-0").unwrap().is_sign_negative(), "-0 keeps its sign bit");
assert_eq!(num("-0").unwrap(), -0.0);
assert!(num("1e999").unwrap().is_infinite());
assert!(num("1e999").unwrap().is_sign_positive());
assert!(num("-1e999").unwrap().is_infinite());
assert!(num("-1e999").unwrap().is_sign_negative());
assert_eq!(num("1e-999").unwrap(), 0.0, "underflow flushes to zero");
assert!(num("9223372036854775807").unwrap().is_finite());
assert!(num("340282350000000000000000000000000000000").unwrap().is_finite());
assert!(num("1e39").unwrap().is_infinite());
}
#[test]
fn parse_number_extremely_long_input_terminates() {
let huge = "9".repeat(20_000);
assert!(num(&huge).unwrap().is_infinite());
let long_frac = format!("0.{}", "0".repeat(20_000));
assert_eq!(num(&long_frac).unwrap(), 0.0);
let long_zeros = format!("{}1", "0".repeat(20_000));
assert_eq!(num(&long_zeros).unwrap(), 1.0);
}
#[test]
fn parse_number_stops_at_trailing_junk() {
let mut p = PathParser::new(b"1.2.3");
assert!(approx(p.parse_number().unwrap(), 1.2));
assert_eq!(p.pos, 3, "second '.' must not be consumed");
let mut p = PathParser::new(b"5;garbage");
assert_eq!(p.parse_number().unwrap(), 5.0);
assert_eq!(p.peek(), Some(b';'));
}
#[test]
fn parse_number_never_overruns_the_buffer() {
for s in ["", "-", ".", "1e", "1e+", "1.", "+.e", "1e-", "999"] {
let mut p = PathParser::new(s.as_bytes());
let _ = p.parse_number();
assert!(p.pos <= s.len(), "{s:?}: pos {} > len {}", p.pos, s.len());
}
}
fn flag(s: &str) -> Result<bool, SvgPathParseError> {
PathParser::new(s.as_bytes()).parse_flag()
}
#[test]
fn parse_flag_valid_minimal() {
assert_eq!(flag("0").unwrap(), false);
assert_eq!(flag("1").unwrap(), true);
assert_eq!(flag(" , 1").unwrap(), true, "separators are skipped first");
}
#[test]
fn parse_flag_consumes_exactly_one_byte() {
let mut p = PathParser::new(b"10");
assert_eq!(p.parse_flag().unwrap(), true);
assert_eq!(p.pos, 1);
assert_eq!(p.parse_flag().unwrap(), false);
assert_eq!(p.pos, 2);
}
#[test]
fn parse_flag_empty_and_whitespace_only_are_err() {
assert_eq!(flag(""), Err(SvgPathParseError::InvalidArcFlag { pos: 0 }));
assert_eq!(flag(" "), Err(SvgPathParseError::InvalidArcFlag { pos: 3 }));
}
#[test]
fn parse_flag_garbage_is_err_and_does_not_advance() {
for s in ["2", "9", "-1", "+1", "x", ".", "😀", "0.5"] {
let mut p = PathParser::new(s.as_bytes());
let before = p.pos;
match p.parse_flag() {
Err(SvgPathParseError::InvalidArcFlag { pos }) => {
assert_eq!(pos, p.pos, "{s:?}: reported pos must be the cursor");
assert_eq!(p.pos, before, "{s:?}: rejected flag must not advance");
}
Ok(v) => assert!(s == "0.5" && !v, "{s:?} unexpectedly parsed as {v}"),
other => panic!("{s:?}: unexpected {other:?}"),
}
}
}
#[test]
fn parse_flag_extremely_long_separator_run_terminates() {
let s = " ".repeat(100_000);
assert_eq!(
flag(&s),
Err(SvgPathParseError::InvalidArcFlag { pos: 100_000 })
);
}
fn pair(s: &str) -> Result<(f32, f32), SvgPathParseError> {
PathParser::new(s.as_bytes()).parse_coordinate_pair()
}
#[test]
fn parse_coordinate_pair_valid_minimal() {
assert_eq!(pair("1 2").unwrap(), (1.0, 2.0));
assert_eq!(pair("1,2").unwrap(), (1.0, 2.0));
assert_eq!(pair(" 1 , 2 ").unwrap(), (1.0, 2.0));
assert_eq!(pair("-1-2").unwrap(), (-1.0, -2.0));
}
#[test]
fn parse_coordinate_pair_splits_on_second_dot() {
let (x, y) = pair("1.5.5").unwrap();
assert!(approx(x, 1.5) && approx(y, 0.5), "got ({x}, {y})");
}
#[test]
fn parse_coordinate_pair_empty_and_partial_are_err() {
assert!(pair("").is_err());
assert!(pair(" ").is_err());
assert!(pair("1").is_err(), "a lone x with no y must be rejected");
assert!(pair("1 ").is_err());
assert!(pair("1,").is_err());
}
#[test]
fn parse_coordinate_pair_garbage_and_unicode_are_err() {
for s in ["abc", "1 abc", "😀 1", "1 😀", ";;", "1;2"] {
assert!(pair(s).is_err(), "{s:?} must be rejected");
}
}
#[test]
fn parse_coordinate_pair_boundary_values() {
let (x, y) = pair("1e999 -1e999").unwrap();
assert!(x.is_infinite() && x.is_sign_positive());
assert!(y.is_infinite() && y.is_sign_negative());
let (x, y) = pair("-0 0").unwrap();
assert!(x.is_sign_negative() && y.is_sign_positive());
}
#[test]
fn parse_coordinate_pair_extremely_long_input_terminates() {
let s = format!("{} {}", "9".repeat(10_000), "9".repeat(10_000));
let (x, y) = pair(&s).unwrap();
assert!(x.is_infinite() && y.is_infinite());
}
#[test]
fn make_absolute_zero_and_absolute_mode_is_identity() {
let mut p = PathParser::new(b"");
p.current = SvgPoint { x: 7.0, y: -3.0 };
assert_eq!(p.make_absolute(0.0, 0.0, false), SvgPoint { x: 0.0, y: 0.0 });
assert_eq!(p.make_absolute(1.0, 2.0, false), SvgPoint { x: 1.0, y: 2.0 });
assert_eq!(p.make_absolute(0.0, 0.0, true), SvgPoint { x: 7.0, y: -3.0 });
assert_eq!(p.make_absolute(-7.0, 3.0, true), SvgPoint { x: 0.0, y: 0.0 });
}
#[test]
fn make_absolute_negative_inputs() {
let mut p = PathParser::new(b"");
p.current = SvgPoint { x: -10.0, y: -10.0 };
assert_eq!(p.make_absolute(-5.0, -5.0, true), SvgPoint { x: -15.0, y: -15.0 });
assert_eq!(p.make_absolute(-5.0, -5.0, false), SvgPoint { x: -5.0, y: -5.0 });
}
#[test]
fn make_absolute_overflow_saturates_to_infinity() {
let mut p = PathParser::new(b"");
p.current = SvgPoint {
x: f32::MAX,
y: f32::MIN,
};
let r = p.make_absolute(f32::MAX, f32::MIN, true);
assert!(r.x.is_infinite() && r.x.is_sign_positive());
assert!(r.y.is_infinite() && r.y.is_sign_negative());
}
#[test]
fn make_absolute_nan_and_inf_are_defined_not_panics() {
let mut p = PathParser::new(b"");
p.current = SvgPoint {
x: f32::INFINITY,
y: 0.0,
};
let r = p.make_absolute(f32::NEG_INFINITY, f32::NAN, true);
assert!(r.x.is_nan(), "inf + -inf must be NaN");
assert!(r.y.is_nan());
let r = p.make_absolute(f32::NAN, f32::INFINITY, false);
assert!(r.x.is_nan());
assert!(r.y.is_infinite());
}
fn run_handler<F>(
input: &str,
current: SvgPoint,
last_command: u8,
last_control: Option<SvgPoint>,
f: F,
) -> (Result<(), SvgPathParseError>, Vec<SvgPathElement>, SvgPoint)
where
F: FnOnce(&mut PathParser<'_>, &mut Vec<SvgPathElement>) -> Result<(), SvgPathParseError>,
{
let mut p = PathParser::new(input.as_bytes());
p.current = current;
p.last_command = last_command;
p.last_control = last_control;
let mut els = Vec::new();
let r = f(&mut p, &mut els);
(r, els, p.current)
}
const ORIGIN: SvgPoint = SvgPoint { x: 0.0, y: 0.0 };
#[test]
fn handle_line_to_absolute_and_relative() {
let start = SvgPoint { x: 10.0, y: 10.0 };
let (r, els, cur) = run_handler("5 5", start, b'L', None, |p, e| p.handle_line_to(false, e));
assert!(r.is_ok());
assert_eq!(els.len(), 1);
assert_eq!(els[0].get_start(), start);
assert_eq!(els[0].get_end(), SvgPoint { x: 5.0, y: 5.0 });
assert_eq!(cur, SvgPoint { x: 5.0, y: 5.0 });
let (r, els, cur) = run_handler("5 5", start, b'l', None, |p, e| p.handle_line_to(true, e));
assert!(r.is_ok());
assert_eq!(els[0].get_end(), SvgPoint { x: 15.0, y: 15.0 });
assert_eq!(cur, SvgPoint { x: 15.0, y: 15.0 });
}
#[test]
fn handle_horizontal_and_vertical_preserve_the_other_axis() {
let start = SvgPoint { x: 3.0, y: 4.0 };
let (_, els, _) = run_handler("9", start, b'H', None, |p, e| p.handle_horizontal_to(false, e));
assert_eq!(els[0].get_end(), SvgPoint { x: 9.0, y: 4.0 });
let (_, els, _) = run_handler("9", start, b'V', None, |p, e| p.handle_vertical_to(false, e));
assert_eq!(els[0].get_end(), SvgPoint { x: 3.0, y: 9.0 });
let (_, els, _) = run_handler("1e999", start, b'h', None, |p, e| p.handle_horizontal_to(true, e));
let end = els[0].get_end();
assert!(end.x.is_infinite(), "relative H by +inf saturates");
assert_eq!(end.y, 4.0, "y must be untouched");
}
#[test]
fn handlers_reject_empty_and_garbage_input_without_panicking() {
for input in ["", " ", "abc", "😀", ";", "1"] {
let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_line_to(false, e));
assert!(r.is_err(), "line_to({input:?}) must be Err");
let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_cubic_to(false, e));
assert!(r.is_err(), "cubic_to({input:?}) must be Err");
let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_quadratic_to(false, e));
assert!(r.is_err(), "quadratic_to({input:?}) must be Err");
let (r, _, _) = run_handler(input, ORIGIN, 0, None, |p, e| p.handle_arc_to(false, e));
assert!(r.is_err(), "arc_to({input:?}) must be Err");
if input != "1" {
let (r, _, _) =
run_handler(input, ORIGIN, 0, None, |p, e| p.handle_horizontal_to(false, e));
assert!(r.is_err(), "horizontal_to({input:?}) must be Err");
let (r, _, _) =
run_handler(input, ORIGIN, 0, None, |p, e| p.handle_vertical_to(false, e));
assert!(r.is_err(), "vertical_to({input:?}) must be Err");
}
}
}
#[test]
fn handle_cubic_to_records_second_control_point() {
let (r, els, _) = run_handler("1 1 2 2 3 3", ORIGIN, b'C', None, |p, e| {
p.handle_cubic_to(false, e)
});
assert!(r.is_ok());
match els[0] {
SvgPathElement::CubicCurve(c) => {
assert_eq!(c.start, ORIGIN);
assert_eq!(c.ctrl_1, SvgPoint { x: 1.0, y: 1.0 });
assert_eq!(c.ctrl_2, SvgPoint { x: 2.0, y: 2.0 });
assert_eq!(c.end, SvgPoint { x: 3.0, y: 3.0 });
}
other => panic!("expected CubicCurve, got {other:?}"),
}
}
#[test]
fn handle_smooth_cubic_reflects_only_after_c_or_s() {
let cur = SvgPoint { x: 10.0, y: 10.0 };
let lc = Some(SvgPoint { x: 8.0, y: 6.0 });
let (_, els, _) = run_handler("1 1 2 2", cur, b'C', lc, |p, e| p.handle_smooth_cubic_to(false, e));
match els[0] {
SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, SvgPoint { x: 12.0, y: 14.0 }),
other => panic!("expected CubicCurve, got {other:?}"),
}
let (_, els, _) = run_handler("1 1 2 2", cur, b'L', lc, |p, e| p.handle_smooth_cubic_to(false, e));
match els[0] {
SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, cur),
other => panic!("expected CubicCurve, got {other:?}"),
}
let (_, els, _) = run_handler("1 1 2 2", cur, b'S', None, |p, e| p.handle_smooth_cubic_to(false, e));
match els[0] {
SvgPathElement::CubicCurve(c) => assert_eq!(c.ctrl_1, cur),
other => panic!("expected CubicCurve, got {other:?}"),
}
}
#[test]
fn handle_smooth_quadratic_reflects_only_after_q_or_t() {
let cur = SvgPoint { x: 10.0, y: 10.0 };
let lc = Some(SvgPoint { x: 8.0, y: 6.0 });
let (_, els, _) = run_handler("2 2", cur, b'Q', lc, |p, e| p.handle_smooth_quadratic_to(false, e));
match els[0] {
SvgPathElement::QuadraticCurve(q) => assert_eq!(q.ctrl, SvgPoint { x: 12.0, y: 14.0 }),
other => panic!("expected QuadraticCurve, got {other:?}"),
}
let (_, els, _) = run_handler("2 2", cur, b'M', lc, |p, e| p.handle_smooth_quadratic_to(false, e));
match els[0] {
SvgPathElement::QuadraticCurve(q) => assert_eq!(q.ctrl, cur),
other => panic!("expected QuadraticCurve, got {other:?}"),
}
}
#[test]
fn handle_arc_to_takes_abs_of_radii() {
let (r, els, cur) = run_handler("-5 -5 0 0 1 10 0", ORIGIN, b'A', None, |p, e| {
p.handle_arc_to(false, e)
});
assert!(r.is_ok());
assert!(!els.is_empty(), "negative radii must still produce an arc");
assert!(
els.iter().all(|e| matches!(e, SvgPathElement::CubicCurve(_))),
"abs() of the radii keeps this a real arc, not a line fallback"
);
assert_eq!(cur, SvgPoint { x: 10.0, y: 0.0 });
}
#[test]
fn handle_arc_to_zero_radius_degenerates_to_line() {
let (r, els, _) = run_handler("0 0 0 0 1 10 0", ORIGIN, b'A', None, |p, e| {
p.handle_arc_to(false, e)
});
assert!(r.is_ok());
assert_eq!(els.len(), 1);
assert!(matches!(els[0], SvgPathElement::Line(_)));
assert_eq!(els[0].get_end(), SvgPoint { x: 10.0, y: 0.0 });
}
#[test]
fn handle_arc_to_rejects_out_of_range_flags() {
for input in ["5 5 0 2 1 10 0", "5 5 0 1 2 10 0", "5 5 0 x 1 10 0", "5 5 0"] {
let (r, _, _) = run_handler(input, ORIGIN, b'A', None, |p, e| p.handle_arc_to(false, e));
assert!(r.is_err(), "arc flags in {input:?} must be rejected");
}
let (r, _, _) = run_handler("5 5 0 2 1 10 0", ORIGIN, b'A', None, |p, e| {
p.handle_arc_to(false, e)
});
assert!(matches!(r, Err(SvgPathParseError::InvalidArcFlag { .. })));
}
#[test]
fn handle_arc_to_infinite_radii_is_bounded() {
let (r, els, _) = run_handler("1e999 1e999 0 0 1 10 10", ORIGIN, b'A', None, |p, e| {
p.handle_arc_to(false, e)
});
assert!(r.is_ok());
assert!(
els.len() <= 4,
"a single arc must never expand past 4 cubics, got {}",
els.len()
);
}
#[test]
fn parse_path_empty_and_whitespace_only_is_empty_path_err() {
for s in ["", " ", "\t\n", "\r\n \t"] {
assert_eq!(
parse_svg_path_d(s),
Err(SvgPathParseError::EmptyPath),
"{s:?} must be EmptyPath"
);
}
}
#[test]
fn parse_path_valid_minimal() {
let mp = parse_svg_path_d("M10 20 L30 40").unwrap();
let rings = mp.rings.as_ref();
assert_eq!(rings.len(), 1);
let items = rings[0].items.as_ref();
assert_eq!(items.len(), 1);
assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 20.0 });
assert_eq!(items[0].get_end(), SvgPoint { x: 30.0, y: 40.0 });
}
#[test]
fn parse_path_relative_accumulates() {
let mp = parse_svg_path_d("m10 20 l30 40").unwrap();
let items_owner = &mp.rings.as_ref()[0];
let items = items_owner.items.as_ref();
assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 20.0 });
assert_eq!(items[0].get_end(), SvgPoint { x: 40.0, y: 60.0 });
}
#[test]
fn parse_path_moveto_only_yields_no_rings() {
let mp = parse_svg_path_d("M10 10").unwrap();
assert_eq!(mp.rings.as_ref().len(), 0);
}
#[test]
fn parse_path_implicit_lineto_after_moveto() {
let mp = parse_svg_path_d("M0 0 10 0 20 0").unwrap();
let items_owner = &mp.rings.as_ref()[0];
let items = items_owner.items.as_ref();
assert_eq!(items.len(), 2, "two implicit L commands");
assert_eq!(items[0].get_end(), SvgPoint { x: 10.0, y: 0.0 });
assert_eq!(items[1].get_end(), SvgPoint { x: 20.0, y: 0.0 });
}
#[test]
fn parse_path_garbage_is_err_never_panics() {
for s in ["@#$", "hello", "?", "-", ".", ",", "0 0", "5", ";;;", "\u{0}"] {
assert!(parse_svg_path_d(s).is_err(), "{s:?} must be rejected");
}
}
#[test]
fn parse_path_unicode_does_not_panic() {
assert_eq!(
parse_svg_path_d("\u{1F600}"),
Err(SvgPathParseError::UnexpectedChar {
pos: 0,
ch: '\u{1F600}',
})
);
assert_eq!(
parse_svg_path_d("\u{0301}M0 0"),
Err(SvgPathParseError::UnexpectedChar {
pos: 0,
ch: '\u{0301}',
})
);
assert_eq!(
parse_svg_path_d("M0 0L1 1ü"),
Err(SvgPathParseError::ExpectedNumber { pos: 8 })
);
}
#[test]
fn parse_path_trailing_junk_is_rejected() {
assert!(parse_svg_path_d("M0 0 L1 1;garbage").is_err());
assert!(parse_svg_path_d("M0 0 L").is_err(), "command with no args");
assert!(parse_svg_path_d("M0 0 L1").is_err(), "half a coordinate pair");
assert!(parse_svg_path_d("M0 0 X10 10").is_err(), "unknown command letter");
assert!(parse_svg_path_d(" \n M0 0 L1 1 \t ").is_ok());
}
#[test]
fn parse_path_unknown_command_reports_its_offset() {
assert_eq!(
parse_svg_path_d("M0 0 X10 10"),
Err(SvgPathParseError::UnexpectedChar { pos: 5, ch: 'X' })
);
}
#[test]
fn parse_path_closepath_epsilon_boundary() {
let mp = parse_svg_path_d("M0 0 L1 0 Z").unwrap();
assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 2);
let mp = parse_svg_path_d("M0 0 L0.001 0 Z").unwrap();
assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 1);
let mp = parse_svg_path_d("M0 0 L0.0005 0 Z").unwrap();
assert_eq!(mp.rings.as_ref()[0].items.as_ref().len(), 1);
assert_eq!(parse_svg_path_d("M0 0 Z").unwrap().rings.as_ref().len(), 0);
}
#[test]
fn parse_path_multiple_subpaths_produce_multiple_rings() {
let mp = parse_svg_path_d("M0 0 L10 0 Z M20 20 L30 20 Z M40 40 L50 40").unwrap();
assert_eq!(mp.rings.as_ref().len(), 3);
}
#[test]
fn parse_path_rings_are_contiguous_chains() {
let d = "M0 0 L10 0 H20 V10 C25 15 30 20 35 20 S45 25 50 20 \
Q55 15 60 20 T70 20 A5 5 0 1 1 80 30 Z \
m100 100 l10 0 z";
let mp = parse_svg_path_d(d).unwrap();
assert!(mp.rings.as_ref().len() >= 2);
for (i, ring) in mp.rings.as_ref().iter().enumerate() {
assert_contiguous(ring.items.as_ref(), &format!("ring {i}"));
assert!(!ring.items.as_ref().is_empty(), "ring {i} must not be empty");
}
}
#[test]
fn parse_path_closed_ring_returns_to_subpath_start() {
let mp = parse_svg_path_d("M0 0 L10 0 L10 10 Z").unwrap();
let ring = &mp.rings.as_ref()[0];
let items = ring.items.as_ref();
assert_eq!(items.last().unwrap().get_end(), SvgPoint { x: 0.0, y: 0.0 });
assert_eq!(items.first().unwrap().get_start(), SvgPoint { x: 0.0, y: 0.0 });
}
#[test]
fn parse_path_boundary_numbers_saturate() {
let mp = parse_svg_path_d("M1e999 -1e999 L1e-999 0").unwrap();
let items_owner = &mp.rings.as_ref()[0];
let start = items_owner.items.as_ref()[0].get_start();
assert!(start.x.is_infinite() && start.x.is_sign_positive());
assert!(start.y.is_infinite() && start.y.is_sign_negative());
let mp = parse_svg_path_d("M3.4e38 0 l3.4e38 0").unwrap();
let items_owner = &mp.rings.as_ref()[0];
assert!(items_owner.items.as_ref()[0].get_end().x.is_infinite());
assert!(parse_svg_path_d("M NaN 0").is_err());
assert!(parse_svg_path_d("M inf 0").is_err());
}
#[test]
fn parse_path_extremely_long_input_terminates() {
let mut d = String::from("M0 0");
for _ in 0..5_000 {
d.push_str(" L1 1");
}
let mp = parse_svg_path_d(&d).unwrap();
let items_owner = &mp.rings.as_ref()[0];
assert_eq!(items_owner.items.as_ref().len(), 5_000);
let mut d = String::new();
for _ in 0..5_000 {
d.push_str("M0 0 L1 1 Z ");
}
assert_eq!(parse_svg_path_d(&d).unwrap().rings.as_ref().len(), 5_000);
}
#[test]
fn parse_path_adversarial_fragments_all_terminate() {
let fragments = [
"Z", "z", "ZZZ", "M0 0ZZ", "M0 0Z0", "M0 0zZ5", "M0 0Z Z Z",
"M", "M0", "M0 0 C", "M0 0 A", "M0 0 A1", "M0 0 A1 1 0 0 0 0",
"M0 0 S", "M0 0 T", "M0 0 H", "M0 0 V", "M0 0 Q1",
"M0 0 L1 1 1", "M0 0 L1 1 1 1 1", "M0 0 A0 0 0 0 0 0 0",
"M0 0 l-.5-.5-.5-.5", "M0 0 t1 1 2 2", "M0 0 s1 1 2 2 3 3 4 4",
"M0,0,1,1", "M0 0e", "M0 0 1e1e1", "M.5.5.5.5",
"M0 0 A1 1 0 11 10 10", "M0 0 A1 1 0 1 1 10 10 A1 1 0 0 0 0 0",
"M0 0 h1e999 v1e999 h-1e999", "M-0-0-0-0",
];
for f in fragments {
let r = parse_svg_path_d(f);
assert!(r.is_ok() || r.is_err(), "{f:?} must return, not panic");
}
}
#[test]
fn parse_path_every_command_produces_its_element_kind() {
let cases: [(&str, usize); 10] = [
("M0 0 L1 1", 1),
("M0 0 l1 1", 1),
("M0 0 H1", 1),
("M0 0 V1", 1),
("M0 0 C1 1 2 2 3 3", 1),
("M0 0 S1 1 2 2", 1),
("M0 0 Q1 1 2 2", 1),
("M0 0 T1 1", 1),
("M0 0 L1 0 Z", 2), ("M0 0 A5 5 0 0 1 10 0", 2), ];
for (d, expected) in cases {
let mp = parse_svg_path_d(d).unwrap_or_else(|e| panic!("{d:?} failed: {e:?}"));
let rings = mp.rings.as_ref();
assert_eq!(rings.len(), 1, "{d:?}");
assert_eq!(rings[0].items.as_ref().len(), expected, "{d:?}");
}
}
#[test]
fn arc_to_cubics_coincident_endpoints_emit_nothing() {
let mut out = Vec::new();
arc_to_cubics(ORIGIN, ORIGIN, 5.0, 5.0, 0.0, true, true, &mut out);
assert!(out.is_empty(), "a zero-length arc is dropped per SVG F.6.2");
let near = SvgPoint { x: 1e-9, y: 1e-9 };
arc_to_cubics(ORIGIN, near, 5.0, 5.0, 0.0, false, false, &mut out);
assert!(out.is_empty());
}
#[test]
fn arc_to_cubics_zero_radius_emits_a_line() {
let end = SvgPoint { x: 10.0, y: 10.0 };
for (rx, ry) in [(0.0, 5.0), (5.0, 0.0), (0.0, 0.0)] {
let mut out = Vec::new();
arc_to_cubics(ORIGIN, end, rx, ry, 0.0, false, true, &mut out);
assert_eq!(out.len(), 1, "rx={rx} ry={ry}");
assert!(matches!(out[0], SvgPathElement::Line(_)));
assert_eq!(out[0].get_start(), ORIGIN);
assert_eq!(out[0].get_end(), end);
}
}
#[test]
fn arc_to_cubics_endpoints_are_exact_for_all_flag_combos() {
let start = SvgPoint { x: 0.0, y: 0.0 };
let end = SvgPoint { x: 10.0, y: 10.0 };
for large_arc in [false, true] {
for sweep in [false, true] {
let mut out = Vec::new();
arc_to_cubics(start, end, 8.0, 6.0, 30.0, large_arc, sweep, &mut out);
assert!(
(1..=4).contains(&out.len()),
"large_arc={large_arc} sweep={sweep}: got {} cubics",
out.len()
);
assert_eq!(out[0].get_start(), start);
assert_eq!(out.last().unwrap().get_end(), end);
assert_contiguous(&out, "arc");
for p in out.iter().flat_map(|e| [e.get_start(), e.get_end()]) {
assert!(p.x.is_finite() && p.y.is_finite(), "arc produced {p:?}");
}
}
}
}
#[test]
fn arc_to_cubics_undersized_radii_are_scaled_up() {
let start = ORIGIN;
let end = SvgPoint { x: 100.0, y: 0.0 };
let mut out = Vec::new();
arc_to_cubics(start, end, 1.0, 1.0, 0.0, false, true, &mut out);
assert!(!out.is_empty());
assert_eq!(out.last().unwrap().get_end(), end);
for p in all_points(&SvgPath {
items: SvgPathElementVec::from_vec(out),
}) {
assert!(p.x.is_finite() && p.y.is_finite(), "scaled arc produced {p:?}");
}
}
#[test]
fn arc_to_cubics_nan_and_inf_inputs_are_bounded() {
let end = SvgPoint { x: 10.0, y: 10.0 };
let bad = [
(f32::NAN, 5.0, 0.0),
(5.0, f32::NAN, 0.0),
(f32::INFINITY, f32::INFINITY, 0.0),
(5.0, 5.0, f32::NAN),
(5.0, 5.0, f32::INFINITY),
(f32::MAX, f32::MAX, 360.0),
];
for (rx, ry, rot) in bad {
let mut out = Vec::new();
arc_to_cubics(ORIGIN, end, rx, ry, rot, true, false, &mut out);
assert!(
out.len() <= 4,
"rx={rx} ry={ry} rot={rot}: {} elements (segment loop ran away)",
out.len()
);
}
}
#[test]
fn arc_to_cubics_extreme_endpoints_do_not_panic() {
let mut out = Vec::new();
arc_to_cubics(
SvgPoint { x: f32::MIN, y: f32::MIN },
SvgPoint { x: f32::MAX, y: f32::MAX },
f32::MAX,
f32::MAX,
0.0,
true,
true,
&mut out,
);
assert!(out.len() <= 4);
}
#[test]
fn angle_between_known_angles() {
assert!(approx(angle_between(1.0, 0.0, 1.0, 0.0), 0.0));
assert!(approx(
angle_between(1.0, 0.0, 0.0, 1.0),
core::f32::consts::FRAC_PI_2
));
assert!(approx(
angle_between(1.0, 0.0, 0.0, -1.0),
-core::f32::consts::FRAC_PI_2
));
assert!(approx(
angle_between(1.0, 0.0, -1.0, 0.0),
core::f32::consts::PI
));
assert!(approx(
angle_between(100.0, 0.0, 0.0, 0.001),
core::f32::consts::FRAC_PI_2
));
}
#[test]
fn angle_between_zero_length_vectors_return_zero() {
assert_eq!(angle_between(0.0, 0.0, 1.0, 0.0), 0.0);
assert_eq!(angle_between(1.0, 0.0, 0.0, 0.0), 0.0);
assert_eq!(angle_between(0.0, 0.0, 0.0, 0.0), 0.0);
assert_eq!(angle_between(1e-30, 1e-30, 1e-30, 1e-30), 0.0);
}
#[test]
fn angle_between_is_always_within_pi_for_finite_inputs() {
let vals = [-1e30_f32, -3.0, -1.0, -0.0, 0.0, 1.0, 3.0, 1e30];
for ux in vals {
for uy in vals {
for vx in vals {
for vy in vals {
let a = angle_between(ux, uy, vx, vy);
assert!(
a.is_nan() || a.abs() <= core::f32::consts::PI + 1e-5,
"angle_between({ux},{uy},{vx},{vy}) = {a} is out of range"
);
}
}
}
}
}
#[test]
fn angle_between_clamps_the_acos_domain() {
let a = angle_between(0.1, 0.2, 0.1, 0.2);
assert!(!a.is_nan(), "parallel vectors must not produce NaN, got {a}");
assert!(approx(a, 0.0));
let a = angle_between(0.1, 0.2, -0.1, -0.2);
assert!(!a.is_nan());
assert!(approx(a.abs(), core::f32::consts::PI));
}
#[test]
fn angle_between_nan_and_inf_do_not_panic() {
assert!(angle_between(f32::NAN, 0.0, 1.0, 0.0).is_nan());
assert!(angle_between(1.0, 0.0, f32::NAN, f32::NAN).is_nan());
assert!(angle_between(f32::INFINITY, 0.0, f32::INFINITY, 0.0).is_nan());
assert!(angle_between(f32::INFINITY, 0.0, 1.0, 0.0).is_nan());
}
#[test]
fn arc_segment_to_cubic_quarter_circle() {
let (c1, c2, ep) = arc_segment_to_cubic(
0.0,
0.0,
1.0,
1.0,
1.0,
0.0,
0.0,
core::f32::consts::FRAC_PI_2,
);
assert!(approx(ep.x, 0.0) && approx(ep.y, 1.0), "ep = {ep:?}");
assert!(approx(c1.x, 1.0) && approx(c1.y, KAPPA), "c1 = {c1:?}");
assert!(approx(c2.x, KAPPA) && approx(c2.y, 1.0), "c2 = {c2:?}");
}
#[test]
fn arc_segment_to_cubic_zero_sweep_collapses() {
let (c1, c2, ep) = arc_segment_to_cubic(5.0, 5.0, 2.0, 2.0, 1.0, 0.0, 0.7, 0.7);
assert!(approx(c1.x, c2.x) && approx(c1.y, c2.y));
assert!(approx(c2.x, ep.x) && approx(c2.y, ep.y));
assert!(approx((ep.x - 5.0).hypot(ep.y - 5.0), 2.0));
}
#[test]
fn arc_segment_to_cubic_zero_radius_is_the_center() {
let (c1, c2, ep) = arc_segment_to_cubic(3.0, 4.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0);
for p in [c1, c2, ep] {
assert_eq!(p, SvgPoint { x: 3.0, y: 4.0 });
}
}
#[test]
fn arc_segment_to_cubic_applies_rotation() {
let (_, _, ep) = arc_segment_to_cubic(0.0, 0.0, 2.0, 1.0, 0.0, 1.0, 0.0, 0.0);
assert!(approx(ep.x, 0.0) && approx(ep.y, 2.0), "ep = {ep:?}");
}
#[test]
fn arc_segment_to_cubic_nan_and_inf_do_not_panic() {
let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN];
for v in bad {
let (c1, c2, ep) = arc_segment_to_cubic(v, v, v, v, v, v, v, v);
for p in [c1, c2, ep] {
assert!(p.x.is_nan() || p.x.is_finite() || p.x.is_infinite());
assert!(p.y.is_nan() || p.y.is_finite() || p.y.is_infinite());
}
}
let (c1, _, _) = arc_segment_to_cubic(
0.0,
0.0,
1.0,
1.0,
1.0,
0.0,
0.0,
core::f32::consts::TAU,
);
assert!(!c1.x.is_nan() || c1.x.is_nan(), "must not panic");
}
#[test]
fn circle_has_four_cubics_and_closes_on_itself() {
let p = svg_circle_to_paths(10.0, 20.0, 5.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 4);
assert!(items.iter().all(|e| matches!(e, SvgPathElement::CubicCurve(_))));
assert_contiguous(items, "circle");
assert_eq!(
items.last().unwrap().get_end(),
items.first().unwrap().get_start(),
"the circle must close exactly"
);
assert_eq!(items[0].get_start(), SvgPoint { x: 10.0, y: 15.0 });
assert_eq!(items[0].get_end(), SvgPoint { x: 15.0, y: 20.0 });
assert_eq!(items[1].get_end(), SvgPoint { x: 10.0, y: 25.0 });
assert_eq!(items[2].get_end(), SvgPoint { x: 5.0, y: 20.0 });
}
#[test]
fn circle_zero_radius_collapses_to_the_center() {
let p = svg_circle_to_paths(3.0, 4.0, 0.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 4);
for pt in all_points(&p) {
assert_eq!(pt, SvgPoint { x: 3.0, y: 4.0 });
}
}
#[test]
fn circle_negative_radius_is_mirrored_not_rejected() {
let p = svg_circle_to_paths(0.0, 0.0, -5.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 4);
assert_contiguous(items, "negative-r circle");
assert_eq!(items[0].get_start(), SvgPoint { x: 0.0, y: 5.0 });
for pt in all_points(&p) {
assert!(pt.x.is_finite() && pt.y.is_finite());
}
}
#[test]
fn circle_nan_inf_and_max_do_not_panic() {
for (cx, cy, r) in [
(f32::NAN, 0.0, 1.0),
(0.0, 0.0, f32::NAN),
(0.0, 0.0, f32::INFINITY),
(f32::MAX, f32::MAX, f32::MAX),
(f32::MIN, f32::MIN, f32::MIN),
] {
let p = svg_circle_to_paths(cx, cy, r);
assert_eq!(p.items.as_ref().len(), 4, "cx={cx} cy={cy} r={r}");
}
let p = svg_circle_to_paths(f32::MAX, 0.0, f32::MAX);
assert!(all_points(&p).iter().any(|pt| pt.x.is_infinite()));
}
#[test]
fn rect_sharp_corners_are_four_lines() {
let p = svg_rect_to_path(1.0, 2.0, 10.0, 20.0, 0.0, 0.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 4);
assert!(items.iter().all(|e| matches!(e, SvgPathElement::Line(_))));
assert_contiguous(items, "sharp rect");
assert_eq!(items[0].get_start(), SvgPoint { x: 1.0, y: 2.0 });
assert_eq!(items[1].get_start(), SvgPoint { x: 11.0, y: 2.0 });
assert_eq!(items[2].get_start(), SvgPoint { x: 11.0, y: 22.0 });
assert_eq!(items[3].get_start(), SvgPoint { x: 1.0, y: 22.0 });
assert_eq!(
items.last().unwrap().get_end(),
items.first().unwrap().get_start(),
"the rect must close exactly"
);
}
#[test]
fn rect_rounded_is_eight_alternating_segments_and_closes() {
let p = svg_rect_to_path(0.0, 0.0, 100.0, 50.0, 10.0, 5.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 8);
for (i, e) in items.iter().enumerate() {
if i % 2 == 0 {
assert!(matches!(e, SvgPathElement::Line(_)), "item {i} should be an edge");
} else {
assert!(
matches!(e, SvgPathElement::CubicCurve(_)),
"item {i} should be a corner"
);
}
}
assert_contiguous(items, "rounded rect");
assert_eq!(
items.last().unwrap().get_end(),
items.first().unwrap().get_start(),
"the rounded rect must close exactly"
);
}
#[test]
fn rect_oversized_radii_are_clamped_to_half() {
let p = svg_rect_to_path(0.0, 0.0, 10.0, 10.0, 1000.0, 1000.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 8);
match items[0] {
SvgPathElement::Line(l) => {
assert_eq!(l.start, SvgPoint { x: 5.0, y: 0.0 });
assert_eq!(l.end, SvgPoint { x: 5.0, y: 0.0 });
}
other => panic!("expected Line, got {other:?}"),
}
assert_contiguous(items, "clamped rect");
for pt in all_points(&p) {
assert!(pt.x.is_finite() && pt.y.is_finite());
assert!((0.0..=10.0).contains(&pt.x), "x {} escaped the rect", pt.x);
assert!((0.0..=10.0).contains(&pt.y), "y {} escaped the rect", pt.y);
}
}
#[test]
fn rect_single_zero_radius_still_rounds() {
let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, 0.0, 10.0);
assert_eq!(p.items.as_ref().len(), 8);
let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, 10.0, 0.0);
assert_eq!(p.items.as_ref().len(), 8);
}
#[test]
fn rect_negative_extent_takes_the_sharp_branch() {
let p = svg_rect_to_path(0.0, 0.0, -10.0, -10.0, 4.0, 4.0);
let items = p.items.as_ref();
assert_eq!(items.len(), 4);
assert!(items.iter().all(|e| matches!(e, SvgPathElement::Line(_))));
assert_contiguous(items, "negative rect");
assert_eq!(items[1].get_start(), SvgPoint { x: -10.0, y: 0.0 });
}
#[test]
fn rect_nan_radius_falls_back_to_half_extent() {
let p = svg_rect_to_path(0.0, 0.0, 100.0, 100.0, f32::NAN, f32::NAN);
let items = p.items.as_ref();
assert_eq!(items.len(), 8, "NaN radii clamp to w/2, h/2 -> rounded path");
for pt in all_points(&p) {
assert!(pt.x.is_finite() && pt.y.is_finite(), "NaN leaked into {pt:?}");
}
match items[0] {
SvgPathElement::Line(l) => assert_eq!(l.start, l.end),
other => panic!("expected Line, got {other:?}"),
}
}
#[test]
fn rect_nan_extent_is_deterministic() {
let p = svg_rect_to_path(0.0, 0.0, f32::NAN, f32::NAN, 0.0, 0.0);
assert_eq!(p.items.as_ref().len(), 4);
let p = svg_rect_to_path(f32::NAN, f32::NAN, 10.0, 10.0, 0.0, 0.0);
assert_eq!(p.items.as_ref().len(), 4);
}
#[test]
fn rect_inf_and_max_extents_do_not_panic() {
for (x, y, w, h, rx, ry) in [
(0.0, 0.0, f32::INFINITY, f32::INFINITY, 0.0, 0.0),
(0.0, 0.0, f32::MAX, f32::MAX, 0.0, 0.0),
(f32::MIN, f32::MIN, f32::MAX, f32::MAX, f32::MAX, f32::MAX),
(0.0, 0.0, f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY),
] {
let p = svg_rect_to_path(x, y, w, h, rx, ry);
let n = p.items.as_ref().len();
assert!(n == 4 || n == 8, "w={w} h={h} rx={rx} ry={ry}: got {n} items");
}
}
}