1use oxideav_core::{Path, Point};
37
38#[derive(Clone, Debug, PartialEq)]
40pub enum SvgPathError {
41 UnsupportedCommand(char),
43 UnexpectedChar(char),
46 InvalidNumber,
48 Truncated,
50 NotStartedWithMove,
54}
55
56pub fn parse_path(data: &str) -> Result<Path, SvgPathError> {
61 let parser = Parser::new(data);
62 parser.parse()
63}
64
65pub fn parse_bbox(data: &str) -> Option<(f32, f32, f32, f32)> {
75 let path = parse_path(data).ok()?;
76 if path.commands.is_empty() {
77 return None;
78 }
79 let mut min_x = f32::INFINITY;
80 let mut min_y = f32::INFINITY;
81 let mut max_x = f32::NEG_INFINITY;
82 let mut max_y = f32::NEG_INFINITY;
83 let mut hit = false;
84 let mut push = |x: f32, y: f32, hit: &mut bool| {
85 *hit = true;
86 if x < min_x {
87 min_x = x;
88 }
89 if y < min_y {
90 min_y = y;
91 }
92 if x > max_x {
93 max_x = x;
94 }
95 if y > max_y {
96 max_y = y;
97 }
98 };
99 use oxideav_core::PathCommand as C;
100 for c in &path.commands {
101 match *c {
102 C::MoveTo(p) | C::LineTo(p) => push(p.x, p.y, &mut hit),
103 C::QuadCurveTo { control, end } => {
104 push(control.x, control.y, &mut hit);
105 push(end.x, end.y, &mut hit);
106 }
107 C::CubicCurveTo { c1, c2, end } => {
108 push(c1.x, c1.y, &mut hit);
109 push(c2.x, c2.y, &mut hit);
110 push(end.x, end.y, &mut hit);
111 }
112 C::Close => {}
113 _ => {}
114 }
115 }
116 if !hit {
117 return None;
118 }
119 Some((min_x, min_y, max_x, max_y))
120}
121
122struct Parser<'a> {
127 bytes: &'a [u8],
128 pos: usize,
129 out: Path,
130 cx: f32,
132 cy: f32,
133 start_x: f32,
135 start_y: f32,
136 prev_cubic_ctrl: Option<(f32, f32)>,
140 prev_quad_ctrl: Option<(f32, f32)>,
141 started: bool,
142}
143
144impl<'a> Parser<'a> {
145 fn new(data: &'a str) -> Self {
146 Self {
147 bytes: data.as_bytes(),
148 pos: 0,
149 out: Path::new(),
150 cx: 0.0,
151 cy: 0.0,
152 start_x: 0.0,
153 start_y: 0.0,
154 prev_cubic_ctrl: None,
155 prev_quad_ctrl: None,
156 started: false,
157 }
158 }
159
160 fn parse(mut self) -> Result<Path, SvgPathError> {
161 self.skip_ws_comma();
162 while self.pos < self.bytes.len() {
163 let cmd = self.bytes[self.pos] as char;
164 if !is_command(cmd) {
165 return Err(SvgPathError::UnexpectedChar(cmd));
166 }
167 self.pos += 1;
168 if !self.started && !matches!(cmd, 'M' | 'm') {
169 return Err(SvgPathError::NotStartedWithMove);
170 }
171 self.dispatch(cmd)?;
172 self.skip_ws_comma();
173 }
174 Ok(self.out)
175 }
176
177 fn dispatch(&mut self, cmd: char) -> Result<(), SvgPathError> {
178 let abs = cmd.is_ascii_uppercase();
179 match cmd.to_ascii_uppercase() {
180 'M' => self.cmd_move(abs)?,
181 'L' => self.cmd_line(abs)?,
182 'H' => self.cmd_hline(abs)?,
183 'V' => self.cmd_vline(abs)?,
184 'C' => self.cmd_cubic(abs)?,
185 'S' => self.cmd_smooth_cubic(abs)?,
186 'Q' => self.cmd_quad(abs)?,
187 'T' => self.cmd_smooth_quad(abs)?,
188 'Z' => self.cmd_close(),
189 'A' => return Err(SvgPathError::UnsupportedCommand(cmd)),
191 other => return Err(SvgPathError::UnsupportedCommand(other)),
192 }
193 match cmd.to_ascii_uppercase() {
195 'C' | 'S' => self.prev_quad_ctrl = None,
196 'Q' | 'T' => self.prev_cubic_ctrl = None,
197 _ => {
198 self.prev_cubic_ctrl = None;
199 self.prev_quad_ctrl = None;
200 }
201 }
202 Ok(())
203 }
204
205 fn cmd_move(&mut self, abs: bool) -> Result<(), SvgPathError> {
206 let (mut x, mut y) = self.read_pair()?;
209 if !abs {
210 x += self.cx;
211 y += self.cy;
212 }
213 self.cx = x;
214 self.cy = y;
215 self.start_x = x;
216 self.start_y = y;
217 self.out.move_to(Point::new(x, y));
218 self.started = true;
219 while self.peek_number().is_some() {
221 let (mut nx, mut ny) = self.read_pair()?;
222 if !abs {
223 nx += self.cx;
224 ny += self.cy;
225 }
226 self.cx = nx;
227 self.cy = ny;
228 self.out.line_to(Point::new(nx, ny));
229 }
230 Ok(())
231 }
232
233 fn cmd_line(&mut self, abs: bool) -> Result<(), SvgPathError> {
234 let mut got_any = false;
235 while self.peek_number().is_some() {
236 let (mut x, mut y) = self.read_pair()?;
237 if !abs {
238 x += self.cx;
239 y += self.cy;
240 }
241 self.cx = x;
242 self.cy = y;
243 self.out.line_to(Point::new(x, y));
244 got_any = true;
245 }
246 if !got_any {
247 return Err(SvgPathError::Truncated);
248 }
249 Ok(())
250 }
251
252 fn cmd_hline(&mut self, abs: bool) -> Result<(), SvgPathError> {
253 let mut got_any = false;
254 while self.peek_number().is_some() {
255 let mut x = self.read_number()?;
256 if !abs {
257 x += self.cx;
258 }
259 self.cx = x;
260 self.out.line_to(Point::new(x, self.cy));
261 got_any = true;
262 }
263 if !got_any {
264 return Err(SvgPathError::Truncated);
265 }
266 Ok(())
267 }
268
269 fn cmd_vline(&mut self, abs: bool) -> Result<(), SvgPathError> {
270 let mut got_any = false;
271 while self.peek_number().is_some() {
272 let mut y = self.read_number()?;
273 if !abs {
274 y += self.cy;
275 }
276 self.cy = y;
277 self.out.line_to(Point::new(self.cx, y));
278 got_any = true;
279 }
280 if !got_any {
281 return Err(SvgPathError::Truncated);
282 }
283 Ok(())
284 }
285
286 fn cmd_cubic(&mut self, abs: bool) -> Result<(), SvgPathError> {
287 let mut got_any = false;
288 while self.peek_number().is_some() {
289 let (mut x1, mut y1) = self.read_pair()?;
290 let (mut x2, mut y2) = self.read_pair()?;
291 let (mut x, mut y) = self.read_pair()?;
292 if !abs {
293 x1 += self.cx;
294 y1 += self.cy;
295 x2 += self.cx;
296 y2 += self.cy;
297 x += self.cx;
298 y += self.cy;
299 }
300 self.out
301 .cubic_to(Point::new(x1, y1), Point::new(x2, y2), Point::new(x, y));
302 self.prev_cubic_ctrl = Some((x2, y2));
303 self.cx = x;
304 self.cy = y;
305 got_any = true;
306 }
307 if !got_any {
308 return Err(SvgPathError::Truncated);
309 }
310 Ok(())
311 }
312
313 fn cmd_smooth_cubic(&mut self, abs: bool) -> Result<(), SvgPathError> {
314 let mut got_any = false;
315 while self.peek_number().is_some() {
316 let (rx, ry) = match self.prev_cubic_ctrl {
321 Some((px, py)) => (2.0 * self.cx - px, 2.0 * self.cy - py),
322 None => (self.cx, self.cy),
323 };
324 let (mut x2, mut y2) = self.read_pair()?;
325 let (mut x, mut y) = self.read_pair()?;
326 if !abs {
327 x2 += self.cx;
328 y2 += self.cy;
329 x += self.cx;
330 y += self.cy;
331 }
332 self.out
333 .cubic_to(Point::new(rx, ry), Point::new(x2, y2), Point::new(x, y));
334 self.prev_cubic_ctrl = Some((x2, y2));
335 self.cx = x;
336 self.cy = y;
337 got_any = true;
338 }
339 if !got_any {
340 return Err(SvgPathError::Truncated);
341 }
342 Ok(())
343 }
344
345 fn cmd_quad(&mut self, abs: bool) -> Result<(), SvgPathError> {
346 let mut got_any = false;
347 while self.peek_number().is_some() {
348 let (mut x1, mut y1) = self.read_pair()?;
349 let (mut x, mut y) = self.read_pair()?;
350 if !abs {
351 x1 += self.cx;
352 y1 += self.cy;
353 x += self.cx;
354 y += self.cy;
355 }
356 self.out.quad_to(Point::new(x1, y1), Point::new(x, y));
357 self.prev_quad_ctrl = Some((x1, y1));
358 self.cx = x;
359 self.cy = y;
360 got_any = true;
361 }
362 if !got_any {
363 return Err(SvgPathError::Truncated);
364 }
365 Ok(())
366 }
367
368 fn cmd_smooth_quad(&mut self, abs: bool) -> Result<(), SvgPathError> {
369 let mut got_any = false;
370 while self.peek_number().is_some() {
371 let (rx, ry) = match self.prev_quad_ctrl {
372 Some((px, py)) => (2.0 * self.cx - px, 2.0 * self.cy - py),
373 None => (self.cx, self.cy),
374 };
375 let (mut x, mut y) = self.read_pair()?;
376 if !abs {
377 x += self.cx;
378 y += self.cy;
379 }
380 self.out.quad_to(Point::new(rx, ry), Point::new(x, y));
381 self.prev_quad_ctrl = Some((rx, ry));
382 self.cx = x;
383 self.cy = y;
384 got_any = true;
385 }
386 if !got_any {
387 return Err(SvgPathError::Truncated);
388 }
389 Ok(())
390 }
391
392 fn cmd_close(&mut self) {
393 self.out.close();
394 self.cx = self.start_x;
395 self.cy = self.start_y;
396 }
397
398 fn skip_ws_comma(&mut self) {
401 let mut seen_comma = false;
404 while self.pos < self.bytes.len() {
405 let c = self.bytes[self.pos];
406 match c {
407 b' ' | b'\t' | b'\r' | b'\n' | 0x0C => self.pos += 1,
408 b',' if !seen_comma => {
409 seen_comma = true;
410 self.pos += 1;
411 }
412 _ => break,
413 }
414 }
415 }
416
417 fn peek_number(&mut self) -> Option<u8> {
418 self.skip_ws_comma();
419 if self.pos >= self.bytes.len() {
420 return None;
421 }
422 let c = self.bytes[self.pos];
423 if c.is_ascii_digit() || c == b'+' || c == b'-' || c == b'.' {
424 Some(c)
425 } else {
426 None
427 }
428 }
429
430 fn read_number(&mut self) -> Result<f32, SvgPathError> {
431 self.skip_ws_comma();
432 if self.pos >= self.bytes.len() {
433 return Err(SvgPathError::Truncated);
434 }
435 let start = self.pos;
436 if matches!(self.bytes[self.pos], b'+' | b'-') {
438 self.pos += 1;
439 }
440 while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
442 self.pos += 1;
443 }
444 if self.pos < self.bytes.len() && self.bytes[self.pos] == b'.' {
446 self.pos += 1;
447 while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
448 self.pos += 1;
449 }
450 }
451 if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'e' | b'E') {
453 self.pos += 1;
454 if self.pos < self.bytes.len() && matches!(self.bytes[self.pos], b'+' | b'-') {
455 self.pos += 1;
456 }
457 while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
458 self.pos += 1;
459 }
460 }
461 if self.pos == start {
462 return Err(SvgPathError::Truncated);
463 }
464 let raw = std::str::from_utf8(&self.bytes[start..self.pos])
465 .map_err(|_| SvgPathError::InvalidNumber)?;
466 raw.parse::<f32>().map_err(|_| SvgPathError::InvalidNumber)
467 }
468
469 fn read_pair(&mut self) -> Result<(f32, f32), SvgPathError> {
470 let x = self.read_number()?;
471 let y = self.read_number()?;
472 Ok((x, y))
473 }
474}
475
476fn is_command(c: char) -> bool {
477 matches!(
478 c,
479 'M' | 'm'
480 | 'L'
481 | 'l'
482 | 'H'
483 | 'h'
484 | 'V'
485 | 'v'
486 | 'C'
487 | 'c'
488 | 'S'
489 | 's'
490 | 'Q'
491 | 'q'
492 | 'T'
493 | 't'
494 | 'Z'
495 | 'z'
496 | 'A'
497 | 'a'
498 )
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn empty_input_is_empty_path() {
507 let p = parse_path("").unwrap();
508 assert_eq!(p.commands.len(), 0);
509 }
510
511 #[test]
512 fn whitespace_only_is_empty_path() {
513 let p = parse_path(" \t\n ").unwrap();
514 assert_eq!(p.commands.len(), 0);
515 }
516
517 #[test]
518 fn move_then_line_absolute() {
519 let p = parse_path("M 10 20 L 30 40").unwrap();
520 assert_eq!(p.commands.len(), 2);
522 }
523
524 #[test]
525 fn move_then_line_relative() {
526 let p = parse_path("m 10,20 l 5,5").unwrap();
528 assert_eq!(p.commands.len(), 2);
529 }
530
531 #[test]
532 fn must_start_with_move() {
533 let err = parse_path("L 10 10").unwrap_err();
534 assert_eq!(err, SvgPathError::NotStartedWithMove);
535 }
536
537 #[test]
538 fn comma_or_space_separator() {
539 let a = parse_path("M0,0L1,1L2,2").unwrap();
540 let b = parse_path("M 0 0 L 1 1 L 2 2").unwrap();
541 assert_eq!(a.commands.len(), b.commands.len());
542 }
543
544 #[test]
545 fn implicit_line_after_moveto() {
546 let p = parse_path("M 0 0 1 1 2 2").unwrap();
548 assert_eq!(p.commands.len(), 3);
550 }
551
552 #[test]
553 fn close_command() {
554 let p = parse_path("M 0 0 L 1 0 L 1 1 Z").unwrap();
555 assert_eq!(p.commands.len(), 4);
557 }
558
559 #[test]
560 fn horizontal_and_vertical() {
561 let p = parse_path("M 5 5 H 10 V 12 h -3 v 4").unwrap();
562 assert_eq!(p.commands.len(), 5);
564 }
565
566 #[test]
567 fn cubic_bezier() {
568 let p = parse_path("M 0 0 C 10 0 10 10 20 10").unwrap();
569 assert_eq!(p.commands.len(), 2);
570 }
571
572 #[test]
573 fn smooth_cubic_reflects_prev_control() {
574 let p = parse_path("M 0 0 C 10 0 10 10 20 10 S 30 0 40 10").unwrap();
575 assert_eq!(p.commands.len(), 3);
577 }
578
579 #[test]
580 fn quadratic_and_smooth_quadratic() {
581 let p = parse_path("M 0 0 Q 5 5 10 0 T 20 0").unwrap();
582 assert_eq!(p.commands.len(), 3);
583 }
584
585 #[test]
586 fn scientific_notation_number() {
587 let p = parse_path("M 1e2 2.5e-1 L 1.5E1 0.0").unwrap();
588 assert_eq!(p.commands.len(), 2);
589 }
590
591 #[test]
592 fn negative_after_implicit_pair_no_separator() {
593 let p = parse_path("M 1-2 L 3-4").unwrap();
596 assert_eq!(p.commands.len(), 2);
597 }
598
599 #[test]
600 fn leading_dot_decimal() {
601 let p = parse_path("M .5 .5 L 1 1").unwrap();
602 assert_eq!(p.commands.len(), 2);
603 }
604
605 #[test]
606 fn unsupported_arc_returns_error() {
607 let err = parse_path("M 0 0 A 5 5 0 0 0 10 10").unwrap_err();
608 assert!(matches!(err, SvgPathError::UnsupportedCommand('A')));
609 }
610
611 #[test]
612 fn unexpected_char_at_top_level() {
613 let err = parse_path("M 0 0 X 1 1").unwrap_err();
614 assert!(matches!(err, SvgPathError::UnexpectedChar('X')));
615 }
616
617 #[test]
618 fn truncated_after_command_letter() {
619 let err = parse_path("M").unwrap_err();
620 assert!(matches!(err, SvgPathError::Truncated));
621 }
622
623 #[test]
624 fn close_restores_current_point_to_subpath_start() {
625 let p = parse_path("M 10 20 L 30 40 Z M 1 1").unwrap();
631 assert_eq!(p.commands.len(), 4);
633 }
634}