mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
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:
1 parent
02c38a6d95
commit
411b16931b
7 files changed
+122
-13
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user