Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,22 @@ fn dither_image(
tone: Option<f64>,
gamut: Option<f64>,
) -> PyResult<Vec<u8>> {
let _ = height;
// Validate the buffer against the caller's dimensions instead of silently
// deriving height and truncating trailing pixels on a width/length mismatch.
// Layout is flat RGB: len = width × height × 3.
let expected = width
.checked_mul(height)
.and_then(|n| n.checked_mul(3))
.ok_or_else(|| PyValueError::new_err("width × height × 3 overflows usize"))?;
if pixels.len() != expected {
return Err(PyValueError::new_err(format!(
"pixel buffer length ({}) does not match width × height × 3 ({} × {} × 3 = {})",
pixels.len(),
width,
height,
expected,
)));
}
let img = ImageBuffer::new(pixels, width);
let config = DitherConfig {
mode: parse_mode(mode_id)?,
Expand Down
21 changes: 21 additions & 0 deletions packages/python/tests/test_dithering.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ def test_output_contains_only_valid_indices(self, scheme):
assert max_idx < scheme.color_count, f"{scheme.name}: pixel index {max_idx} >= color count {scheme.color_count}"


class TestBufferValidation:
"""Test the low-level `_rs.dither_image` validates buffer dimensions."""

def test_mismatched_dimensions_raise(self):
"""A pixel buffer whose length != width * height * 3 must raise, not truncate."""
import epaper_dithering._rs as _rs

# 10x10 RGB image = 300 bytes; claim a height that does not match.
pixels = bytes(10 * 10 * 3)
with pytest.raises(ValueError):
_rs.dither_image(pixels, 10, 11, scheme_id=ColorScheme.MONO.value)

def test_matching_dimensions_succeed(self):
"""The correct width/height is accepted."""
import epaper_dithering._rs as _rs

pixels = bytes(10 * 10 * 3)
indices = _rs.dither_image(pixels, 10, 10, scheme_id=ColorScheme.MONO.value)
assert len(indices) == 10 * 10


class TestColorScience:
"""Test color science improvements."""

Expand Down
4 changes: 2 additions & 2 deletions packages/rust/core/src/algorithms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ mod tests {
/// in the mid-tone region and assert the max/min ratio stays modest. Empirically the
/// linear-space implementation produces ratio ≈ 13.5; sRGB-space ≈ 2.2.
///
/// The mono decision midpoint sits at OKLab L=0.5 ≈ sRGB 188, so the top two bands
/// The mono decision midpoint sits at OKLab L=0.5 ≈ sRGB 100, so the top two bands
/// (sRGB ≳ 192) are already in the highlight roll-off where dither activity legitimately
/// tapers; we exclude them along with the pure-black first band.
#[test]
Expand Down Expand Up @@ -530,7 +530,7 @@ mod tests {
}

// Mid-tone bands: skip the pure-black first band and the top two near-white bands
// (past the mono midpoint ≈ sRGB 188) where the threshold clamps and dither
// (past the mono midpoint ≈ sRGB 100) where the threshold clamps and dither
// activity legitimately falls off.
let mid = &transitions[1..BANDS - 2];
let max = *mid.iter().max().unwrap();
Expand Down