use oxideav_core::{Path, Point};
#[derive(Clone, Debug, PartialEq)]
pub enum SvgPathError {
UnsupportedCommand(char),
UnexpectedChar(char),
InvalidNumber,
Truncated,
NotStartedWithMove,
}
pub fn parse_path(data: &str) -> Result<Path, SvgPathError> {
let parser = Parser::new(data);
parser.parse()
}
pub fn parse_bbox(data: &str) -> Option<(f32, f32, f32, f32)> {
let path = parse_path(data).ok()?;
if path.commands.is_empty() {
return None;
}
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
let mut hit = false;
let mut push = |x: f32, y: f32, hit: &mut bool| {
*hit = true;
if x < min_x {
min_x = x;
}
if y < min_y {
min_y = y;
}
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
};
use oxideav_core::PathCommand as C;
for c in &path.commands {
match *c {
C::MoveTo(p) | C::LineTo(p) => push(p.x, p.y, &mut hit),
C::QuadCurveTo { control, end } => {
push(control.x, control.y, &mut hit);
push(end.x, end.y, &mut hit);
}
C::CubicCurveTo { c1, c2, end } => {
push(c1.x, c1.y, &mut hit);
push(c2.x, c2.y, &mut hit);
push(end.x, end.y, &mut hit);
}
C::Close => {}
_ => {}
}
}
if !hit {
return None;
}
Some((min_x, min_y, max_x, max_y))
}
struct Parser<'a> {
bytes: &'a [u8],
pos: usize,
out: Path,
cx: f32,
cy: f32,
start_x: f32,
start_y: f32,
prev_cubic_ctrl: Option<(f32, f32)>,
prev_quad_ctrl: Option<(f32, f32)>,
started: bool,
}
impl<'a> Parser<'a> {
fn new(data: &'a str) -> Self {
Self {
bytes: data.as_bytes(),
pos: 0,
out: Path::new(),
cx: 0.0,
cy: 0.0,
start_x: 0.0,
start_y: 0.0,
prev_cubic_ctrl: None,
prev_quad_ctrl: None,
started: false,
}
}
fn parse(mut self) -> Result<Path, SvgPathError> {
self.skip_ws_comma();
while self.pos < self.bytes.len() {
let cmd = self.bytes[self.pos] as char;
if !is_command(cmd) {
return Err(SvgPathError::UnexpectedChar(cmd));
}
self.pos += 1;
if !self.started && !matches!(cmd, 'M' | 'm') {
return Err(SvgPathError::NotStartedWithMove);
}
self.dispatch(cmd)?;
self.skip_ws_comma();
}
Ok(self.out)
}
fn dispatch(&mut self, cmd: char) -> Result<(), SvgPathError> {
let abs = cmd.is_ascii_uppercase();
match cmd.to_ascii_uppercase() {
'M' => self.cmd_move(abs)?,
'L' => self.cmd_line(abs)?,
'H' => self.cmd_hline(abs)?,
'V' => self.cmd_vline(abs)?,
'C' => self.cmd_cubic(abs)?,
'S' => self.cmd_smooth_cubic(abs)?,
'Q' => self.cmd_quad(abs)?,
'T' => self.cmd_smooth_quad(abs)?,
'Z' => self.cmd_close(),
'A' => return Err(SvgPathError::UnsupportedCommand(cmd)),
other => return Err(SvgPathError::UnsupportedCommand(other)),
}
match cmd.to_ascii_uppercase() {
'C' | 'S' => self.prev_quad_ctrl = None,
'Q' | 'T' => self.prev_cubic_ctrl = None,
_ => {
self.prev_cubic_ctrl = None;
self.prev_quad_ctrl = None;
}
}
Ok(())
}
fn cmd_move(&mut self, abs: bool) -> Result<(), SvgPathError> {
let (mut x, mut y) = self.read_pair()?;
if !abs {
x += self.cx;
y += self.cy;
}
self.cx = x;
self.cy = y;
self.start_x = x;
self.start_y = y;
self.out.move_to(Point::new(x, y));
self.started = true;
while self.peek_number().is_some() {
let (mut nx, mut ny) = self.read_pair()?;
if !abs {
nx += self.cx;
ny += self.cy;
}
self.cx = nx;
self.cy = ny;
self.out.line_to(Point::new(nx, ny));
}
Ok(())
}
fn cmd_line(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let (mut x, mut y) = self.read_pair()?;
if !abs {
x += self.cx;
y += self.cy;
}
self.cx = x;
self.cy = y;
self.out.line_to(Point::new(x, y));
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_hline(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let mut x = self.read_number()?;
if !abs {
x += self.cx;
}
self.cx = x;
self.out.line_to(Point::new(x, self.cy));
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_vline(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let mut y = self.read_number()?;
if !abs {
y += self.cy;
}
self.cy = y;
self.out.line_to(Point::new(self.cx, y));
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_cubic(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let (mut x1, mut y1) = self.read_pair()?;
let (mut x2, mut y2) = self.read_pair()?;
let (mut x, mut y) = self.read_pair()?;
if !abs {
x1 += self.cx;
y1 += self.cy;
x2 += self.cx;
y2 += self.cy;
x += self.cx;
y += self.cy;
}
self.out
.cubic_to(Point::new(x1, y1), Point::new(x2, y2), Point::new(x, y));
self.prev_cubic_ctrl = Some((x2, y2));
self.cx = x;
self.cy = y;
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_smooth_cubic(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let (rx, ry) = match self.prev_cubic_ctrl {
Some((px, py)) => (2.0 * self.cx - px, 2.0 * self.cy - py),
None => (self.cx, self.cy),
};
let (mut x2, mut y2) = self.read_pair()?;
let (mut x, mut y) = self.read_pair()?;
if !abs {
x2 += self.cx;
y2 += self.cy;
x += self.cx;
y += self.cy;
}
self.out
.cubic_to(Point::new(rx, ry), Point::new(x2, y2), Point::new(x, y));
self.prev_cubic_ctrl = Some((x2, y2));
self.cx = x;
self.cy = y;
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_quad(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let (mut x1, mut y1) = self.read_pair()?;
let (mut x, mut y) = self.read_pair()?;
if !abs {
x1 += self.cx;
y1 += self.cy;
x += self.cx;
y += self.cy;
}
self.out.quad_to(Point::new(x1, y1), Point::new(x, y));
self.prev_quad_ctrl = Some((x1, y1));
self.cx = x;
self.cy = y;
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_smooth_quad(&mut self, abs: bool) -> Result<(), SvgPathError> {
let mut got_any = false;
while self.peek_number().is_some() {
let (rx, ry) = match self.prev_quad_ctrl {
Some((px, py)) => (2.0 * self.cx - px, 2.0 * self.cy - py),
None => (self.cx, self.cy),
};
let (mut x, mut y) = self.read_pair()?;
if !abs {
x += self.cx;
y += self.cy;
}
self.out.quad_to(Point::new(rx, ry), Point::new(x, y));
self.prev_quad_ctrl = Some((rx, ry));
self.cx = x;
self.cy = y;
got_any = true;
}
if !got_any {
return Err(SvgPathError::Truncated);
}
Ok(())
}
fn cmd_close(&mut self) {
self.out.close();
self.cx = self.start_x;
self.cy = self.start_y;
}
fn skip_ws_comma(&mut self) {
let mut seen_comma = false;
while self.pos < self.bytes.len() {
let c = self.bytes[self.pos];
match c {
b' ' | b'\t' | b'\r' | b'\n' | 0x0C => self.pos += 1,
b',' if !seen_comma => {
seen_comma = true;
self.pos += 1;
}
_ => break,
}
}
}
fn peek_number(&mut self) -> Option<u8> {
self.skip_ws_comma();
if self.pos >= self.bytes.len() {
return None;
}
let c = self.bytes[self.pos];
if c.is_ascii_digit() || c == b'+' || c == b'-' || c == b'.' {
Some(c)
} else {
None
}
}
fn read_number(&mut self) -> Result<f32, SvgPathError> {
self.skip_ws_comma();
if self.pos >= self.bytes.len() {
return Err(SvgPathError::Truncated);
}
let start = self.pos;
if matches!(self.bytes[self.pos], b'+' | b'-') {
self.pos += 1;
}
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
self.pos += 1;
}
if self.pos < self.bytes.len() && self.bytes[self.pos] == b'.' {
self.pos += 1;
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
self.pos += 1;
}
}
if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'e' | b'E') {
self.pos += 1;
if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'+' | b'-') {
self.pos += 1;
}
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
self.pos += 1;
}
}
if self.pos == start {
return Err(SvgPathError::Truncated);
}
let raw = std::str::from_utf8(&self.bytes[start..self.pos])
.map_err(|_| SvgPathError::InvalidNumber)?;
raw.parse::<f32>().map_err(|_| SvgPathError::InvalidNumber)
}
fn read_pair(&mut self) -> Result<(f32, f32), SvgPathError> {
let x = self.read_number()?;
let y = self.read_number()?;
Ok((x, y))
}
}
fn is_command(c: char) -> bool {
matches!(
c,
'M' | 'm'
| 'L'
| 'l'
| 'H'
| 'h'
| 'V'
| 'v'
| 'C'
| 'c'
| 'S'
| 's'
| 'Q'
| 'q'
| 'T'
| 't'
| 'Z'
| 'z'
| 'A'
| 'a'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_empty_path() {
let p = parse_path("").unwrap();
assert_eq!(p.commands.len(), 0);
}
#[test]
fn whitespace_only_is_empty_path() {
let p = parse_path(" \t\n ").unwrap();
assert_eq!(p.commands.len(), 0);
}
#[test]
fn move_then_line_absolute() {
let p = parse_path("M 10 20 L 30 40").unwrap();
assert_eq!(p.commands.len(), 2);
}
#[test]
fn move_then_line_relative() {
let p = parse_path("m 10,20 l 5,5").unwrap();
assert_eq!(p.commands.len(), 2);
}
#[test]
fn must_start_with_move() {
let err = parse_path("L 10 10").unwrap_err();
assert_eq!(err, SvgPathError::NotStartedWithMove);
}
#[test]
fn comma_or_space_separator() {
let a = parse_path("M0,0L1,1L2,2").unwrap();
let b = parse_path("M 0 0 L 1 1 L 2 2").unwrap();
assert_eq!(a.commands.len(), b.commands.len());
}
#[test]
fn implicit_line_after_moveto() {
let p = parse_path("M 0 0 1 1 2 2").unwrap();
assert_eq!(p.commands.len(), 3);
}
#[test]
fn close_command() {
let p = parse_path("M 0 0 L 1 0 L 1 1 Z").unwrap();
assert_eq!(p.commands.len(), 4);
}
#[test]
fn horizontal_and_vertical() {
let p = parse_path("M 5 5 H 10 V 12 h -3 v 4").unwrap();
assert_eq!(p.commands.len(), 5);
}
#[test]
fn cubic_bezier() {
let p = parse_path("M 0 0 C 10 0 10 10 20 10").unwrap();
assert_eq!(p.commands.len(), 2);
}
#[test]
fn smooth_cubic_reflects_prev_control() {
let p = parse_path("M 0 0 C 10 0 10 10 20 10 S 30 0 40 10").unwrap();
assert_eq!(p.commands.len(), 3);
}
#[test]
fn quadratic_and_smooth_quadratic() {
let p = parse_path("M 0 0 Q 5 5 10 0 T 20 0").unwrap();
assert_eq!(p.commands.len(), 3);
}
#[test]
fn scientific_notation_number() {
let p = parse_path("M 1e2 2.5e-1 L 1.5E1 0.0").unwrap();
assert_eq!(p.commands.len(), 2);
}
#[test]
fn negative_after_implicit_pair_no_separator() {
let p = parse_path("M 1-2 L 3-4").unwrap();
assert_eq!(p.commands.len(), 2);
}
#[test]
fn leading_dot_decimal() {
let p = parse_path("M .5 .5 L 1 1").unwrap();
assert_eq!(p.commands.len(), 2);
}
#[test]
fn unsupported_arc_returns_error() {
let err = parse_path("M 0 0 A 5 5 0 0 0 10 10").unwrap_err();
assert!(matches!(err, SvgPathError::UnsupportedCommand('A')));
}
#[test]
fn unexpected_char_at_top_level() {
let err = parse_path("M 0 0 X 1 1").unwrap_err();
assert!(matches!(err, SvgPathError::UnexpectedChar('X')));
}
#[test]
fn truncated_after_command_letter() {
let err = parse_path("M").unwrap_err();
assert!(matches!(err, SvgPathError::Truncated));
}
#[test]
fn close_restores_current_point_to_subpath_start() {
let p = parse_path("M 10 20 L 30 40 Z M 1 1").unwrap();
assert_eq!(p.commands.len(), 4);
}
}