Never crash: geom and color return or fall back instead of panicking

Replace the unwrap/expect calls in path closing, bounds, Pathfinder output
and gradient sampling with let-else / if-let; the ASE reader uses checked
offsets (a 32-bit length could overflow on wasm32). Both crates now deny
unwrap/expect/panic/unimplemented/todo/unreachable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 11:39:01 -04:00
parent 192b72054d
commit 89891fdfbe
8 changed files with 34 additions and 19 deletions

4
clippy.toml Normal file
View File

@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true

View File

@ -26,8 +26,9 @@ struct R<'a> {
impl R<'_> {
fn take(&mut self, n: usize) -> Result<&[u8], AseError> {
let s = self.b.get(self.i..self.i + n).ok_or(AseError::Truncated)?;
self.i += n;
let end = self.i.checked_add(n).ok_or(AseError::Truncated)?;
let s = self.b.get(self.i..end).ok_or(AseError::Truncated)?;
self.i = end;
Ok(s)
}
fn u16(&mut self) -> Result<u16, AseError> {

View File

@ -83,8 +83,10 @@ impl Gradient {
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
}
}
let l = stops.last().unwrap();
(l.color, l.opacity)
match stops.last() {
Some(l) => (l.color, l.opacity),
None => (Color::BLACK, 1.0),
}
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {

View File

@ -2,6 +2,7 @@
//!
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so
//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod ase;

View File

@ -120,8 +120,10 @@ pub fn smooth(sp: &SubPath, a: At, b: At, tol: f64) -> SubPath {
let mut out = tidy(head);
if sp.closed {
// The walk repeated the first anchor at the end.
if out.len() > 2 && (out[0].p - out[out.len() - 1].p).hypot() < 1e-6 {
let last = out.pop().expect("len");
if out.len() > 2
&& (out[0].p - out[out.len() - 1].p).hypot() < 1e-6
&& let Some(last) = out.pop()
{
out[0].h_in = last.h_in;
}
}

View File

@ -5,6 +5,7 @@
//! handle positions. Rendering and algorithms convert to [`kurbo::BezPath`].
//!
//! Coordinates are document points (1/72 in), y pointing down.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod corners;

View File

@ -307,13 +307,12 @@ impl PathData {
PathEl::ClosePath => {
if let Some(mut sp) = cur.take() {
// Merge a closing anchor that duplicates the start.
if sp.anchors.len() > 1 {
let first = sp.anchors[0].p;
let lastp = sp.anchors.last().unwrap().p;
if first.distance(lastp) < 1e-7 {
let l = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = l.h_in;
}
if sp.anchors.len() > 1
&& let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last())
&& first.p.distance(last.p) < 1e-7
&& let Some(l) = sp.anchors.pop()
{
sp.anchors[0].h_in = l.h_in;
}
sp.closed = true;
for a in &mut sp.anchors {
@ -341,9 +340,10 @@ impl PathData {
return None;
}
let bp = self.to_bezpath();
if self.anchor_count() == 1 {
let p = self.subpaths.iter().find_map(|s| s.anchors.first()).unwrap().p;
return Some(Rect::from_points(p, p));
if self.anchor_count() == 1
&& let Some(a) = self.subpaths.iter().find_map(|s| s.anchors.first())
{
return Some(Rect::from_points(a.p, a.p));
}
Some(bp.bounding_box())
}

View File

@ -67,14 +67,18 @@ fn from_flo(paths: &[SimpleBezierPath]) -> PathData {
}
let mut anchors = vec![Anchor::corner(p(start))];
for (c1, c2, end) in curves {
anchors.last_mut().expect("non-empty").h_out = p(c1);
if let Some(prev) = anchors.last_mut() {
prev.h_out = p(c1);
}
let mut a = Anchor::corner(p(end));
a.h_in = p(c2);
anchors.push(a);
}
// The last curve comes back to the start: fold its end into the first anchor.
if anchors.len() > 2 && anchors.last().expect("non-empty").p.distance(anchors[0].p) < 1e-6 {
let last = anchors.pop().expect("non-empty");
if anchors.len() > 2
&& anchors.last().is_some_and(|l| l.p.distance(anchors[0].p) < 1e-6)
&& let Some(last) = anchors.pop()
{
anchors[0].h_in = last.h_in;
}
for a in &mut anchors {