fix(geom): keep arc subdivision bounded whatever the radius

kurbo turns an arc into cubics by radius divided by tolerance, so the fixed
0.01 let an ellipse, polygon corner, rounded rectangle, per-side stroke
corner or SVG arc with a radius like 1e300 ask for more cubics than memory
holds before any size check saw the geometry. Arcs now use a tolerance
that scales with the radius, and SVG path data from documents, commands and
imports is refused before parsing when a number passes 1e9.
This commit is contained in:
Dylan M. Taylor
2026-11-03 15:45:48 +00:00
parent 02c38a6d95
commit 411b16931b
7 changed files with 122 additions and 13 deletions
+1 -1
View File
@@ -513,7 +513,7 @@ impl TextPath {
/// The path (empty when `d` doesn't parse).
pub fn path(&self) -> figcraft_geom::BezPath {
figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default()
figcraft_geom::svg_path(&self.d).unwrap_or_default()
}
/// The start offset clamped into 0..1 (NaN: 0).
+4 -2
View File
@@ -194,7 +194,7 @@ fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result<Value> {
/// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path.
pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
figcraft_geom::svg_path(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
@@ -213,7 +213,9 @@ pub(crate) struct PathShape {
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let mut path = figcraft_geom::svg_path(d).map_err(|e| {
if e.contains("too large") { bad(cmd, "`path` has coordinates too large to draw") } else { bad(cmd, "`path` is not valid SVG path data") }
})?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
+1 -1
View File
@@ -3951,7 +3951,7 @@ fn vector_path(v: &Value, warnings: &mut Vec<String>) -> Option<VectorNetwork> {
/// Parse supported vector commands and report unrepresentable geometry.
fn vector_bezpath(v: &Value, warnings: &mut Vec<String>) -> Option<BezPath> {
if let Some(d) = v.get("content").and_then(Value::as_str) {
let path = BezPath::from_svg(d).ok().filter(|p| !p.elements().is_empty())?;
let path = figcraft_doc::geom::svg_path(d).ok().filter(|p| !p.elements().is_empty())?;
let origin = Affine::translate((-geometry(v, "x", 0.0), -geometry(v, "y", 0.0)));
return Some(origin * path);
}
+1 -1
View File
@@ -843,7 +843,7 @@ impl<'a> Ctx<'a> {
self.warn("very long vector paths were skipped");
continue;
}
match BezPath::from_svg(d) {
match figcraft_doc::geom::svg_path(d) {
Ok(bp) if sane(&bp) && out.elements().len() + bp.elements().len() <= MAX_PATH_ELEMENTS => {
winding.get_or_insert(if text(p, "windingRule") == Some("EVENODD") { WindingRule::EvenOdd } else { WindingRule::NonZero });
out.extend(bp);
+67 -7
View File
@@ -18,6 +18,62 @@ pub use transform::*;
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
pub type Radii = [f64; 4];
/// The tolerance an arc of radius `r` is turned into cubics with: a hundredth of a unit, or a
/// millionth of the radius once that is more. kurbo's cubic count grows with radius ÷ tolerance,
/// so a fixed tolerance let a radius of 1e300 ask for more cubics than memory holds.
pub fn arc_tolerance(r: f64) -> f64 {
// An infinite radius (finite inputs can overflow on the way, 1e300 squared) gets an infinite
// tolerance: kurbo's count is then NaN, which it floors to its minimum of a few cubics.
if r.is_finite() { (r.abs() * 1e-6).max(0.01) } else { f64::INFINITY }
}
/// Largest coordinate, radius or other number SVG path data may carry ([`svg_path`]).
pub const MAX_SVG_COORD: f64 = 1e9;
/// `d` (SVG path data) as a path. A number past [`MAX_SVG_COORD`] is refused before parsing: the
/// parser turns an arc into cubics by its radius at a fixed tolerance, so a radius of 1e300 would
/// be unbounded work and memory on the way to geometry that is refused anyway.
pub fn svg_path(d: &str) -> Result<BezPath, String> {
if number_past(d, MAX_SVG_COORD) {
return Err(format!("path data holds a number too large (past {MAX_SVG_COORD:e})"));
}
BezPath::from_svg(d).map_err(|e| e.to_string())
}
/// Whether a number in SVG path data `d` lies past ±`max` (or isn't finite). Numbers are split as
/// the SVG grammar does (`1-2` and `.5.5` are two each; `1e-3` is one).
fn number_past(d: &str, max: f64) -> bool {
fn big(cur: &mut String, max: f64) -> bool {
let big = cur.parse::<f64>().is_ok_and(|v| v.is_nan() || v.abs() > max);
cur.clear();
big
}
let (mut cur, mut dot, mut exp) = (String::new(), false, false);
for c in d.chars() {
let continues = match c {
'0'..='9' => true,
'.' => !dot && !exp,
'e' | 'E' => !exp && cur.chars().any(|c| c.is_ascii_digit()),
'+' | '-' => cur.ends_with(['e', 'E']),
_ => false,
};
if continues {
dot |= c == '.';
exp |= matches!(c, 'e' | 'E');
cur.push(c);
continue;
}
if big(&mut cur, max) {
return true;
}
(dot, exp) = (c == '.', false);
if matches!(c, '+' | '-' | '.') {
cur.push(c);
}
}
big(&mut cur, max)
}
fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
@@ -126,7 +182,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof:
}
if sweep.abs() > 1e-9 {
let arc = kurbo::Arc::new(center, (r, r), a0, sweep, 0.0);
arc.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
arc.to_cubic_beziers(arc_tolerance(r), |p1, p2, p| path.curve_to(p1, p2, p));
}
let end = at(0.0, p);
if a + b + c > 1e-9 {
@@ -297,7 +353,7 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat
sweep -= TAU;
}
if sweep.abs() > 1e-9 {
kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(arc_tolerance(r), |p1, p2, q| path.curve_to(p1, p2, q));
}
let end = at(u2, p, 0.0);
if a + b + c > 1e-9 {
@@ -334,25 +390,29 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let start = finite(start);
let inner = finite(inner).clamp(0.0, 1.0);
let full = full_sweep(sweep);
let tol = arc_tolerance(rx.max(ry));
let mut path = BezPath::new();
// A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an
// affine map, which overflows to infinity for radii near f64's limit (and infinite cubics).
let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0);
if full && inner <= 0.0 {
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
return whole(rx, ry).to_path(tol);
}
if full {
// A ring: outer clockwise, inner counter-clockwise.
path.extend(kurbo::Ellipse::new(c, (rx, ry), 0.0).path_elements(0.01));
let inner_path = kurbo::Ellipse::new(c, (rx * inner, ry * inner), 0.0).to_path(0.01);
path.extend(whole(rx, ry).path_elements(tol));
let inner_path = whole(rx * inner, ry * inner).to_path(tol);
path.extend(inner_path.reverse_subpaths());
return path;
}
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
path.move_to(c + Vec2::new(rx * start.cos(), ry * start.sin()));
outer.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
outer.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p));
if inner > 0.0 {
let end = start + sweep;
path.line_to(c + Vec2::new(rx * inner * end.cos(), ry * inner * end.sin()));
let back = kurbo::Arc::new(c, (rx * inner, ry * inner), end, -sweep, 0.0);
back.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
back.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p));
} else {
path.line_to(c);
}
+46
View File
@@ -177,3 +177,49 @@ fn reverse_subpaths_preserves_open_and_closed() {
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&closed);
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
#[test]
fn a_huge_ellipse_arc_is_a_few_cubics() {
let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5);
assert!(big.elements().len() < 200, "{}", big.elements().len());
}
#[test]
fn a_huge_ring_is_a_few_cubics() {
let ring = ellipse(1e300, 1e300, 0.0, TAU, 0.5);
assert!(ring.elements().len() < 200, "{}", ring.elements().len());
}
#[test]
fn a_huge_smoothed_star_is_a_few_cubics() {
let star = smoothed_polygon(&star_points(1e300, 1e300, 5, 0.5), 1e299, 0.5);
assert!(star.elements().len() < 400, "{}", star.elements().len());
}
#[test]
fn a_huge_rounded_rect_is_a_few_cubics() {
let rect = rounded_rect(1e300, 1e300, [1e299; 4], 0.0);
assert!(rect.elements().len() < 100, "{}", rect.elements().len());
}
#[test]
fn an_infinite_radius_is_a_few_cubics() {
let mut n = 0;
kurbo::Arc::new((0.0, 0.0), (f64::INFINITY, 1.0), 0.0, 1.0, 0.0).to_cubic_beziers(arc_tolerance(f64::INFINITY), |_, _, _| n += 1);
assert!(n < 16, "{n}");
}
#[test]
fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
let start = std::time::Instant::now();
for d in ["M0 0 A1e300 1e300 0 0 1 1 1", "M0 0A1e300-1e300 0 0 1 1 1", "M 0 0 L 1e10 5", "M0 0L-2e9.5 0", "M0 0 L 1e999 0"] {
assert!(svg_path(d).is_err(), "{d}");
}
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
for d in ["M0 0 L 1e9 5 A 5 5 0 0 1 10 10 Z", "M0 0 L-2.5 0.5 L1e-3 -4e2 Z", "M10,20 l1,-2.5e+1"] {
assert!(svg_path(d).is_ok(), "{d}");
}
assert!(!number_past("M0 0L-2.5.5 1e-3-4e2z", 1e3));
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
assert!(number_past("M.5.5e10", 1e3));
}
+2 -1
View File
@@ -617,7 +617,8 @@ fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) ->
if rx > 0.0 && ry > 0.0 {
let c = Point::new(corner.x + sx * rx, corner.y + sy * ry);
p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin()));
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e));
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0)
.to_cubic_beziers(figcraft_geom::arc_tolerance(rx.max(ry)), |a, b, e| p.curve_to(a, b, e));
} else {
p.line_to(corner);
}