Image Trace: Raster::new returns an error for a buffer of the wrong length

Raster::new checked its RGBA length with assert_eq!, so bytes of the
wrong length (from a file, a plug-in or a control message) would abort
instead of reporting an error. It now returns Result<Raster, TraceError>
and gives TraceError::Decode naming the byte count and the size (the
existing variant, whose text is already translated, instead of a new
message every language would need). The size product is checked for
overflow.

Raster::pixel indexed the bytes directly; it now gives transparent
black outside the raster, or past the end of a raster whose public
fields were set to a size its bytes don't have (which mosaic reads).

The public signature changes: every caller in the workspace was a test
(trace unit tests, Flat Logo tests and the quality harness), updated to
unwrap. No shipped code built a Raster with new; decode, decode_to_fit
and from_fn always make the right length, and trace_within already
refuses a raster whose fields disagree.

Test (code-drawn): a_raster_of_the_wrong_length_is_an_error_not_a_crash
(short, long, empty, zero-width and overflowing sizes; pixel, trace and
mosaic on a raster whose fields disagree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
This commit is contained in:
andeecollard
2026-10-17 19:38:06 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 0594ffe30f
commit a2e2eba2e2
8 changed files with 57 additions and 18 deletions
+18 -5
View File
@@ -123,10 +123,15 @@ pub struct Raster {
}
impl Raster {
/// A raster from RGBA bytes (`width * height * 4` of them).
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Self {
assert_eq!(rgba.len(), width as usize * height as usize * 4, "rgba length must be width*height*4");
Self { width, height, rgba }
/// A raster from RGBA bytes, which must be exactly `width * height * 4` of them: any other
/// length (bytes from a file, a plug-in or a message) is a [`TraceError::Decode`] error, not
/// a crash. (That error's text is already translated; its detail names the sizes.)
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<Self, TraceError> {
let want = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
if want != Some(rgba.len()) {
return Err(TraceError::Decode(format!("{} bytes of RGBA data for a {width} × {height} image", rgba.len())));
}
Ok(Self { width, height, rgba })
}
/// A raster whose pixel `(x, y)` is `f(x, y)`.
pub fn from_fn(width: u32, height: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Self {
@@ -192,9 +197,17 @@ impl Raster {
}
out
}
/// Pixel `(x, y)`; transparent black outside the raster (or past the end of a raster whose
/// fields were set to a size its bytes don't have).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
if x >= self.width || y >= self.height {
return [0; 4];
}
let i = (y as usize * self.width as usize + x as usize) * 4;
[self.rgba[i], self.rgba[i + 1], self.rgba[i + 2], self.rgba[i + 3]]
match self.rgba.get(i..i + 4) {
Some(&[r, g, b, a]) => [r, g, b, a],
_ => [0; 4],
}
}
}
+8 -8
View File
@@ -57,7 +57,7 @@ fn render(w: u32, h: u32, blur: f64, shade: impl Fn(f64, f64) -> Option<[u8; 3]>
let un = |v: f64| if a > 1e-9 { ((v / a) * 255.0).round().clamp(0.0, 255.0) as u8 } else { 0 };
rgba.extend_from_slice(&[un(p[0]), un(p[1]), un(p[2]), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
}
Raster::new(w, h, rgba)
Raster::new(w, h, rgba).unwrap()
}
const RED: [u8; 3] = [210, 40, 50];
@@ -235,12 +235,12 @@ fn assert_sane(res: &TraceResult, w: u32, h: u32) {
fn degenerate_images_are_fine() {
let p = logo_params();
for (w, h, img) in [
(0, 0, Raster::new(0, 0, vec![])),
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255])),
(3, 3, Raster::new(3, 3, vec![0; 36])),
(2, 2, Raster::new(2, 2, vec![255; 16])),
(5, 1, Raster::new(5, 1, vec![9; 20])),
(1, 7, Raster::new(1, 7, vec![130; 28])),
(0, 0, Raster::new(0, 0, vec![]).unwrap()),
(1, 1, Raster::new(1, 1, vec![200, 30, 30, 255]).unwrap()),
(3, 3, Raster::new(3, 3, vec![0; 36]).unwrap()),
(2, 2, Raster::new(2, 2, vec![255; 16]).unwrap()),
(5, 1, Raster::new(5, 1, vec![9; 20]).unwrap()),
(1, 7, Raster::new(1, 7, vec![130; 28]).unwrap()),
] {
match trace_within(&img, &p, 1000) {
Ok(res) => assert_sane(&res, w, h),
@@ -398,7 +398,7 @@ fn random_small_images_and_settings_never_panic() {
});
}
}
let img = Raster::new(w, h, rgba);
let img = Raster::new(w, h, rgba).unwrap();
let p = TraceParams {
paths: weird[(next() % 8) as usize],
corners: weird[(next() % 8) as usize],
+23 -1
View File
@@ -602,7 +602,7 @@ fn create_strokes_survives_noise_and_tiny_images() {
seed
};
let noise: Vec<u8> = (0..120 * 120).flat_map(|_| if next() % 3 == 0 { BLACK } else { WHITE }).collect();
let noise = Raster::new(120, 120, noise);
let noise = Raster::new(120, 120, noise).unwrap();
for img in [noise, Raster::from_fn(1, 1, |_, _| BLACK), Raster::from_fn(3, 2, |x, _| if x == 1 { BLACK } else { WHITE })] {
for (fills, width) in [(true, 1.0), (false, 4.0), (true, 100.0)] {
for noise in [0, 1, 4] {
@@ -1652,3 +1652,25 @@ fn a_higher_paths_never_gives_fewer_segments() {
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
#[test]
fn a_raster_of_the_wrong_length_is_an_error_not_a_crash() {
for (w, h, len) in [(4, 4, 63), (4, 4, 65), (4, 4, 0), (0, 4, 4), (1, 1, 3), (u32::MAX, u32::MAX, 16)] {
match Raster::new(w, h, vec![7; len]) {
Err(TraceError::Decode(why)) => assert!(why.starts_with(&format!("{len} bytes")) && why.contains(&format!("{w} × {h}")), "{why}"),
other => panic!("{w} × {h}, {len} bytes: {other:?}"),
}
}
let e = Raster::new(3, 2, vec![0; 20]).unwrap_err().to_string();
assert!(e.contains("20 bytes") && e.contains("3 × 2"), "{e}");
assert_eq!(Raster::new(3, 2, vec![9; 24]).unwrap().pixel(2, 1), [9; 4]);
assert!(Raster::new(0, 0, vec![]).is_ok());
// A raster whose public fields disagree is still no crash: no pixel past its bytes, no trace,
// no mosaic tile out of range.
let broken = Raster { width: 8, height: 8, rgba: vec![255; 40] };
assert_eq!(broken.pixel(7, 7), [0; 4]);
assert_eq!(broken.pixel(1, 1), [255; 4]);
assert_eq!(broken.pixel(9, 0), [0; 4]);
assert!(trace(&broken, &TraceParams::default()).paths.is_empty());
assert_eq!(mosaic(&broken, 2, 2).len(), 4);
}
+1 -1
View File
@@ -109,7 +109,7 @@ fn evaluate(sc: &Scene, input: &'static str, cov: &[f32]) -> Metrics {
[v, v, v, 255]
})
.collect();
let img = Raster::new(sc.w as u32, sc.h as u32, rgba);
let img = Raster::new(sc.w as u32, sc.h as u32, rgba).unwrap();
let params = TraceParams { strokes: true, fills: true, stroke_width: sc.wmax, ignore_white: true, noise: 4, ..TraceParams::default() };
let t0 = Instant::now();
let res = trace_within(&img, &params, super::MAX_ANCHORS).unwrap();
+1 -1
View File
@@ -140,7 +140,7 @@ fn from_rgb(w: usize, h: usize, buf: &[[f32; 3]]) -> Raster {
rgba.extend(p.map(|v| v.round().clamp(0.0, 255.0) as u8));
rgba.push(255);
}
Raster::new(w as u32, h as u32, rgba)
Raster::new(w as u32, h as u32, rgba).unwrap()
}
/// A deterministic hash of a pixel position and a salt, as a value in `-1.0..1.0`.
+2
View File
@@ -1,5 +1,6 @@
# Image Trace quality
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-17 · **Change:** minor (Image Trace: Raster::new returns an error for a buffer of the wrong length; earlier: Gradients: grown over noise and soft rims)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: maximal-disc pruning and junction merging; centre-lines rerun)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Create Strokes: exact crop distances and Guo–Hall thinning; centre-lines rerun)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (centre-lines baseline; no tracing behaviour change)
@@ -1211,6 +1212,7 @@ The remaining spur/extra-junction target is not met by this graph-only change.
| date | change |
|:--|:--|
| 2026-10-17 | `Raster::new` returns an error for RGBA bytes of the wrong length (it failed an assertion) and `Raster::pixel` reads nothing past the raster; traces of valid images unchanged |
| 2026-10-17 | M12: maximal-disc graph pruning, split-crossing merges and degree-two joins; correct junction degrees 30→44/45 aa and hard, none missed; remaining spurs unchanged. Centre-lines rerun, scores and earlier tables unchanged. |
| 2026-10-17 | M12: exact per-blob Euclidean distances and Guo–Hall thinning; centre-lines rerun on 46 inputs. Ends found: aa 638→682/688, hard 631→681/688. Whole-blob stroke/fill choice retained; preset/cost tables unchanged. |
| 2026-10-17 | M12: add the ignored centre-lines class and measure the existing Create Strokes output on 23 code-drawn scenes, aa and hard; centre/width/topology/end/coverage metrics, no new images, dependencies or behaviour |
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long