path.rs
raw
//! Vector-path literals shared by both surface languages: the s-expression
//! form `(path (move x y) (line x y) (cubic …) (close))` and the
//! `(svg-path "M0 0 L10 10 …")` convenience, both producing the same
//! [`PathCmd`] sequence. One parser so the two spellings can never drift.
use crate::diagnostics::Diagnostic;
use crate::ir::PathCmd;
use crate::sexpr::{Sexpr, Span};
/// Parse a `(path …)` or `(svg-path "…")` list form. `items` is the whole
/// list including the head symbol; returns `None` (not an error) when the
/// head is neither, so callers can fall through to other value kinds.
pub fn parse_path_form(items: &[Sexpr], span: Span) -> Option<Result<Vec<PathCmd>, Diagnostic>> {
match items.first().and_then(Sexpr::as_symbol) {
Some("path") => Some(parse_sexpr_path(&items[1..])),
Some("svg-path") => Some(parse_svg_form(&items[1..], span)),
_ => None,
}
}
/// `(path (move x y) (line x y) (quad cx cy x y) (cubic c1x c1y c2x c2y x y)
/// (close))`.
fn parse_sexpr_path(cmds: &[Sexpr]) -> Result<Vec<PathCmd>, Diagnostic> {
let mut out = Vec::with_capacity(cmds.len());
for cmd in cmds {
let Some(list) = cmd.as_list() else {
return Err(Diagnostic::new(
"a path command is a list like `(move x y)` or `(close)`",
cmd.span(),
));
};
let Some(op) = list.first().and_then(Sexpr::as_symbol) else {
return Err(Diagnostic::new(
"a path command must start with `move`, `line`, `quad`, \
`cubic`, or `close`",
cmd.span(),
));
};
let nums = |n: usize| -> Result<Vec<f64>, Diagnostic> {
if list.len() != n + 1 {
return Err(Diagnostic::new(
format!("`{op}` takes {n} coordinate(s), found {}", list.len() - 1),
cmd.span(),
));
}
list[1..].iter().map(number).collect()
};
out.push(match op {
"move" => {
let c = nums(2)?;
PathCmd::Move(c[0], c[1])
}
"line" => {
let c = nums(2)?;
PathCmd::Line(c[0], c[1])
}
"quad" => {
let c = nums(4)?;
PathCmd::Quad(c[0], c[1], c[2], c[3])
}
"cubic" => {
let c = nums(6)?;
PathCmd::Cubic(c[0], c[1], c[2], c[3], c[4], c[5])
}
"close" => {
nums(0)?;
PathCmd::Close
}
other => {
return Err(Diagnostic::new(
format!(
"unknown path command `{other}`; expected move, line, quad, cubic, or close"
),
cmd.span(),
));
}
});
}
Ok(out)
}
fn number(value: &Sexpr) -> Result<f64, Diagnostic> {
match value {
Sexpr::Int(v, _) => Ok(*v as f64),
Sexpr::Float(v, _) => Ok(*v),
other => Err(Diagnostic::new(
format!(
"expected a path coordinate number, found {}",
other.kind_name()
),
other.span(),
)),
}
}
/// `(svg-path "…")` — exactly one string argument, an SVG path-data string.
fn parse_svg_form(args: &[Sexpr], span: Span) -> Result<Vec<PathCmd>, Diagnostic> {
let [Sexpr::Str(data, str_span)] = args else {
return Err(Diagnostic::new(
"`svg-path` takes exactly one string of SVG path data",
span,
));
};
parse_svg_data(data, *str_span)
}
/// A pragmatic SVG path-data parser covering the absolute and relative
/// command set guiduck's tokens use: M/L/H/V/C/Q/Z (and lowercase relative
/// variants). Enough for hand-written and design-tool-exported marks; not a
/// full SVG arc/smooth-curve implementation (A/S/T are rejected with a
/// pointed message rather than silently mis-drawn).
fn parse_svg_data(data: &str, span: Span) -> Result<Vec<PathCmd>, Diagnostic> {
let mut lex = SvgLexer::new(data, span);
let mut out = Vec::new();
let (mut cx, mut cy) = (0.0_f64, 0.0_f64);
let (mut sx, mut sy) = (0.0_f64, 0.0_f64);
let mut cmd = 0_u8;
loop {
match lex.peek()? {
Peek::End => break,
Peek::Command(c) => {
lex.take_command();
if c.eq_ignore_ascii_case(&b'Z') {
out.push(PathCmd::Close);
cx = sx;
cy = sy;
cmd = 0;
continue;
}
// Set the command and fall through to read its first operand
// set (an M's first pair really is a Move).
cmd = c;
}
// A number where a command is expected repeats the last command
// (an implicit M repeats as L, per the SVG grammar).
Peek::Number => {
if cmd == 0 {
return Err(Diagnostic::new(
"SVG path data must start with a move command (M or m)",
span,
));
} else if cmd == b'M' {
cmd = b'L';
} else if cmd == b'm' {
cmd = b'l';
}
}
}
let rel = cmd.is_ascii_lowercase();
match cmd.to_ascii_uppercase() {
b'M' => {
let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
out.push(PathCmd::Move(x, y));
sx = x;
sy = y;
}
b'L' => {
let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
out.push(PathCmd::Line(x, y));
}
b'H' => {
let v = lex.number()?;
cx = if rel { cx + v } else { v };
out.push(PathCmd::Line(cx, cy));
}
b'V' => {
let v = lex.number()?;
cy = if rel { cy + v } else { v };
out.push(PathCmd::Line(cx, cy));
}
b'Q' => {
let (qx, qy) = lex.control(cx, cy, rel)?;
let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
out.push(PathCmd::Quad(qx, qy, x, y));
}
b'C' => {
let (a, b) = lex.control(cx, cy, rel)?;
let (c, d) = lex.control(cx, cy, rel)?;
let (x, y) = lex.point(&mut cx, &mut cy, rel)?;
out.push(PathCmd::Cubic(a, b, c, d, x, y));
}
other => {
return Err(Diagnostic::new(
format!(
"unsupported SVG path command `{}`; use the s-expr `(path …)` \
form or M/L/H/V/C/Q/Z",
other as char
),
span,
));
}
}
}
Ok(out)
}
enum Peek {
Command(u8),
Number,
End,
}
/// A tiny scanner over SVG path data: whitespace/comma separated numbers
/// interleaved with single-letter commands.
struct SvgLexer<'a> {
rest: &'a str,
span: Span,
}
impl<'a> SvgLexer<'a> {
fn new(data: &'a str, span: Span) -> Self {
Self { rest: data, span }
}
fn skip_sep(&mut self) {
self.rest = self
.rest
.trim_start_matches(|c: char| c.is_whitespace() || c == ',');
}
fn peek(&mut self) -> Result<Peek, Diagnostic> {
self.skip_sep();
match self.rest.chars().next() {
None => Ok(Peek::End),
Some(c) if c.is_ascii_alphabetic() => Ok(Peek::Command(c as u8)),
Some(_) => Ok(Peek::Number),
}
}
fn take_command(&mut self) {
self.rest = &self.rest[1..];
}
fn number(&mut self) -> Result<f64, Diagnostic> {
self.skip_sep();
let end = self
.rest
.find(|c: char| {
c.is_whitespace() || c == ',' || (c.is_ascii_alphabetic() && c != 'e' && c != 'E')
})
.unwrap_or(self.rest.len());
let (tok, rest) = self.rest.split_at(end);
self.rest = rest;
tok.parse::<f64>().map_err(|_| {
Diagnostic::new(format!("`{tok}` is not a valid SVG path number"), self.span)
})
}
/// Read an (x, y) pair and advance the current point.
fn point(&mut self, cx: &mut f64, cy: &mut f64, rel: bool) -> Result<(f64, f64), Diagnostic> {
let x = self.number()?;
let y = self.number()?;
let (ax, ay) = if rel { (*cx + x, *cy + y) } else { (x, y) };
*cx = ax;
*cy = ay;
Ok((ax, ay))
}
/// Read an (x, y) control point resolved against the current point
/// without moving it.
fn control(&mut self, cx: f64, cy: f64, rel: bool) -> Result<(f64, f64), Diagnostic> {
let x = self.number()?;
let y = self.number()?;
Ok(if rel { (cx + x, cy + y) } else { (x, y) })
}
}
#[cfg(test)]
mod tests;