diff --git a/Cargo.lock b/Cargo.lock index 12592232b..0de52f01d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1060,6 +1060,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-hdr" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-heif" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c5a15be48..a1b7ffee6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } craft-gainmap = { path = "crates/gainmap" } craft-gcode = { path = "crates/gcode" } +craft-hdr = { path = "crates/hdr" } craft-mesh = { path = "crates/mesh" } craft-heif = { path = "crates/heif" } craft-fig = { path = "crates/fig" } diff --git a/README.md b/README.md index 575974164..0b71e7875 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = std::result::Result; + +/// Caps on what a file may ask the reader to allocate, checked before anything is allocated. +/// `#[non_exhaustive]`: start from [`Limits::default`] and set the fields you change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Most pixels (width × height) a picture may have; each takes 12 bytes decoded. Default 2^26. + pub max_pixels: u64, + /// Longest header read, in bytes (real ones are a few hundred). Default 64 KiB. + pub max_header: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_pixels: 1 << 26, max_header: 64 << 10 } + } +} + +/// A decoded picture. +#[derive(Debug, Clone, PartialEq)] +pub struct Picture { + pub width: u32, + pub height: u32, + /// Scene-linear RGB, three floats a pixel, top row first. + pub rgb: Vec, +} + +/// Whether `bytes` starts like a Radiance picture. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"#?RADIANCE") || bytes.starts_with(b"#?RGBE") +} + +/// One resolution-line axis: `(is_x, positive)`. +fn axis(tok: &str) -> Option<(bool, bool)> { + match tok { + "+X" => Some((true, true)), + "-X" => Some((true, false)), + "+Y" => Some((false, true)), + "-Y" => Some((false, false)), + _ => None, + } +} + +/// RGBE to linear floats: `mantissa × 2^(e − 136)`, zero for exponent 0. (Radiance's own reader +/// adds half a mantissa step; other common readers do not, which also makes 1.0 = +/// (128, 128, 128, 129) read back as exactly 1.0.) +pub fn rgbe_to_linear(p: [u8; 4]) -> [f32; 3] { + if p[3] == 0 { + return [0.0; 3]; + } + let scale = 2f32.powi(i32::from(p[3]) - 136); + [f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale] +} + +/// Reads the pixels of one scan line into `line`. +fn scanline(b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> { + let n = line.len(); + let take = |pos: &mut usize, k: usize| -> Result<&[u8]> { + let s = pos.checked_add(k).and_then(|end| b.get(*pos..end)).ok_or(Error::Malformed("truncated pixel data"))?; + *pos += k; + Ok(s) + }; + let head = pos.checked_add(4).and_then(|end| b.get(*pos..end)); + if (8..=32767).contains(&n) + && let Some(&[2, 2, hi, lo]) = head + && hi & 0x80 == 0 + { + if usize::from(hi) << 8 | usize::from(lo) != n { + return Err(Error::Malformed("scan line length does not match the picture")); + } + *pos += 4; + for c in 0..4 { + let mut x = 0; + while x < n { + let code = take(pos, 1)?.first().copied().unwrap_or(0); + if code > 128 { + let run = usize::from(code - 128); + let v = take(pos, 1)?.first().copied().unwrap_or(0); + let dst = line.get_mut(x..x + run).ok_or(Error::Malformed("run past the end of the scan line"))?; + for p in dst { + p[c] = v; + } + x += run; + } else { + let run = usize::from(code); + if run == 0 { + return Err(Error::Malformed("empty literal run")); + } + let src = take(pos, run)?; + let dst = line.get_mut(x..x + run).ok_or(Error::Malformed("literal past the end of the scan line"))?; + for (p, &v) in dst.iter_mut().zip(src) { + p[c] = v; + } + x += run; + } + } + } + return Ok(()); + } + // Flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times. + let (mut x, mut shift) = (0usize, 0u32); + while x < n { + let p: [u8; 4] = take(pos, 4)?.try_into().map_err(|_| Error::Malformed("truncated pixel data"))?; + if p[..3] == [1, 1, 1] { + let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX); + let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or(Error::Malformed("run with no previous pixel"))?; + let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or(Error::Malformed("run past the end of the scan line"))?; + dst.fill(prev); + x += dst.len(); + shift = shift.saturating_add(8).min(24); + } else { + if let Some(d) = line.get_mut(x) { + *d = p; + } + x += 1; + shift = 0; + } + } + Ok(()) +} + +/// Decodes a Radiance picture to linear float RGB, top row first. +pub fn decode(b: &[u8], limits: &Limits) -> Result { + if !sniff(b) { + return Err(Error::NotHdr); + } + // Header: lines up to the first empty one, then the resolution line. + let mut pos = 0; + let line = |pos: &mut usize| -> Result { + let rest = b.get(*pos..).ok_or(Error::Malformed("truncated header"))?; + let end = rest.iter().take(limits.max_header).position(|&c| c == b'\n').ok_or(Error::Malformed("truncated header"))?; + let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string(); + *pos += end + 1; + Ok(s) + }; + loop { + let l = line(&mut pos)?; + if l.is_empty() { + break; + } + if let Some(f) = l.strip_prefix("FORMAT=") { + match f.trim() { + "32-bit_rle_rgbe" => {} + "32-bit_rle_xyze" => return Err(Error::Unsupported("the XYZE colour format".into())), + other => return Err(Error::Unsupported(format!("format {other}"))), + } + } + if pos > limits.max_header { + return Err(Error::LimitExceeded("header length")); + } + } + let res = line(&mut pos)?; + let bad = Error::Malformed("bad resolution line"); + let t: Vec<&str> = res.split_whitespace().collect(); + let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or(bad.clone())?; + let ((x1, p1), (x2, p2)) = (axis(a1).ok_or(bad.clone())?, axis(a2).ok_or(bad.clone())?); + if x1 == x2 { + return Err(Error::Malformed("resolution line names one axis twice")); + } + let n1: usize = n1.parse().map_err(|_| bad.clone())?; + let n2: usize = n2.parse().map_err(|_| bad.clone())?; + let (w, h) = if x1 { (n1, n2) } else { (n2, n1) }; + if w == 0 || h == 0 { + return Err(Error::Malformed("empty picture")); + } + let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return Err(Error::LimitExceeded("pixel count")) }; + let pixels = u64::from(width) * u64::from(height); + if pixels > limits.max_pixels { + return Err(Error::LimitExceeded("pixel count")); + } + // A scan line takes at least a few bits of file, so a header cannot ask for more than the data has. + if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < pixels { + return Err(Error::Malformed("truncated pixel data")); + } + // Validate every encoded scan line before allocating the float picture: a short file could + // otherwise ask for the whole limit of zeroed pixels and only then fail on its first line. + let pixel_data = pos; + let mut scan = vec![[0u8; 4]; n2]; + for _ in 0..n1 { + scanline(b, &mut pos, &mut scan)?; + } + + let total = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or(Error::LimitExceeded("pixel count"))?; + let mut rgb = vec![0f32; total]; + pos = pixel_data; + for i in 0..n1 { + scanline(b, &mut pos, &mut scan)?; + for (j, &p) in scan.iter().enumerate() { + // The first axis counts scan lines, the second runs along one. + let (x, y) = if x1 { + (if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j }) + } else { + (if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i }) + }; + let at = (y * w + x) * 3; + if let Some(o) = rgb.get_mut(at..at + 3) { + o.copy_from_slice(&rgbe_to_linear(p)); + } + } + } + Ok(Picture { width, height, rgb }) +} diff --git a/crates/hdr/src/tests.rs b/crates/hdr/src/tests.rs new file mode 100644 index 000000000..7e40454b1 --- /dev/null +++ b/crates/hdr/src/tests.rs @@ -0,0 +1,144 @@ +//! Ported from FilmCraft's Radiance HDR reader tests. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +fn file(res: &str, body: &[u8]) -> Vec { + let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes(); + v.extend_from_slice(body); + v +} + +fn dec(b: &[u8]) -> Result { + decode(b, &Limits::default()) +} + +/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes. +fn rle_line(line: &[[u8; 4]]) -> Vec { + let n = line.len(); + let mut out = vec![2, 2, (n >> 8) as u8, n as u8]; + for c in 0..4 { + let ch: Vec = line.iter().map(|p| p[c]).collect(); + let mut i = 0; + while i < n { + let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count(); + if run >= 3 { + out.extend([128 + run as u8, ch[i]]); + i += run; + } else { + let mut j = i + 1; + while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) { + j += 1; + } + out.push((j - i) as u8); + out.extend_from_slice(&ch[i..j]); + i = j; + } + } + } + out +} + +#[test] +fn rgbe_values() { + assert_eq!(rgbe_to_linear([0, 0, 0, 0]), [0.0; 3]); + assert_eq!(rgbe_to_linear([128, 128, 128, 129]), [1.0; 3]); + assert_eq!(rgbe_to_linear([128, 64, 0, 136]), [128.0, 64.0, 0.0]); + assert_eq!(rgbe_to_linear([128, 128, 128, 128])[0], 0.5); + assert!(rgbe_to_linear([255, 255, 255, 255])[0].is_finite()); +} + +#[test] +fn flat_top_to_bottom() { + let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat(); + let f = dec(&file("-Y 2 +X 2", &body)).unwrap(); + assert_eq!((f.width, f.height), (2, 2)); + // Exponent 137 scales by 2, 135 by 1/2. + assert_eq!(f.rgb, [10.0, 20.0, 30.0, 1.0, 2.0, 3.0, 8.0, 10.0, 12.0, 3.5, 4.0, 4.5]); + // The `#?RGBE` signature and CRLF header lines read the same. + let mut crlf = b"#?RGBE\r\nFORMAT=32-bit_rle_rgbe\r\n\r\n-Y 2 +X 2\r\n".to_vec(); + crlf.extend_from_slice(&body); + assert_eq!(dec(&crlf).unwrap(), f); +} + +#[test] +fn orientations() { + // 3 wide, 2 high, pixel value = its index in the file. + let body: Vec = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect(); + let red = |res: &str| -> Vec { dec(&file(res, &body)).unwrap().rgb.chunks(3).map(|p| p[0]).collect() }; + assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]); + assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]); + assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]); + // Scan lines are columns: file index = x * 2 + (row, top first for -Y). + assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]); + assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]); + let f = dec(&file("+X 3 -Y 2", &body)).unwrap(); + assert_eq!((f.width, f.height), (3, 2)); +} + +#[test] +fn run_length_lines() { + let w = 40usize; + let rows: Vec> = + (0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect(); + let body: Vec = rows.iter().flat_map(|r| rle_line(r)).collect(); + let f = dec(&file(&format!("-Y 3 +X {w}"), &body)).unwrap(); + for (y, r) in rows.iter().enumerate() { + for (x, q) in r.iter().enumerate() { + assert_eq!(&f.rgb[(y * w + x) * 3..(y * w + x) * 3 + 3], &rgbe_to_linear(*q), "pixel {x},{y}"); + } + } +} + +#[test] +fn old_style_runs() { + // One pixel, then (1,1,1,3) repeats it 3 times, then a new pixel. + let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat(); + let f = dec(&file("-Y 1 +X 5", &body)).unwrap(); + assert_eq!(f.rgb.chunks(3).map(|p| p[0]).collect::>(), [5.0, 5.0, 5.0, 5.0, 9.0]); +} + +#[test] +fn hostile_input_is_an_error_not_a_panic() { + let ok = file("-Y 2 +X 2", &[0; 16]); + assert!(dec(&ok).is_ok()); + for cut in 0..ok.len() - 1 { + // A cut anywhere is an error (the header or the pixel data is incomplete), never a panic. + assert!(dec(&ok[..cut]).is_err(), "{cut}"); + } + assert_eq!(dec(b"P6 1 1 255 abc"), Err(Error::NotHdr)); + assert_eq!(dec(&file("-Y 2 +X 2", &[0; 8])), Err(Error::Malformed("truncated pixel data"))); + assert_eq!(dec(&file("-Y 0 +X 4", &[])), Err(Error::Malformed("empty picture"))); + assert_eq!(dec(&file("-Y 2 -Y 2", &[0; 16])), Err(Error::Malformed("resolution line names one axis twice"))); + assert_eq!(dec(&file("junk", &[0; 16])), Err(Error::Malformed("bad resolution line"))); + assert!(matches!(dec(b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0"), Err(Error::Unsupported(_)))); + assert!(matches!(dec(b"#?RADIANCE\nFORMAT=other\n\n-Y 1 +X 1\n\0\0\0\0"), Err(Error::Unsupported(_)))); + // An RLE line announcing the wrong width, a run past the end, a run at the start of a flat line. + assert_eq!(dec(&file("-Y 1 +X 10", &[2, 2, 0, 11, 0])), Err(Error::Malformed("scan line length does not match the picture"))); + assert_eq!(dec(&file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])), Err(Error::Malformed("run past the end of the scan line"))); + assert_eq!(dec(&file("-Y 1 +X 10", &[2, 2, 0, 10, 0])), Err(Error::Malformed("empty literal run"))); + assert_eq!(dec(&file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])), Err(Error::Malformed("run with no previous pixel"))); + assert!(dec(b"#?RADIANCE").is_err()); +} + +#[test] +fn limits_are_checked_before_allocating() { + assert_eq!(dec(&file("-Y 99999999 +X 99999999", &[0; 16])), Err(Error::LimitExceeded("pixel count"))); + assert_eq!(dec(&file("-Y 99999999999 +X 1", &[0; 16])), Err(Error::LimitExceeded("pixel count"))); + // Within the pixel limit and past the coarse size check, but the scan lines are not there: + // refused by validation, before the float picture is allocated. + assert_eq!(dec(&file("-Y 67108864 +X 1", &[0; 16_384])), Err(Error::Malformed("truncated pixel data"))); + let ok = file("-Y 2 +X 2", &[0; 16]); + assert!(decode(&ok, &Limits { max_pixels: 4, ..Limits::default() }).is_ok()); + assert_eq!(decode(&ok, &Limits { max_pixels: 3, ..Limits::default() }), Err(Error::LimitExceeded("pixel count"))); + // A header without its blank line inside the header limit. + let mut long = b"#?RADIANCE\n".to_vec(); + for _ in 0..8 { + long.extend_from_slice(b"# 0123456789\n"); + } + long.extend_from_slice(b"\n-Y 1 +X 1\n\0\0\0\0"); + assert!(dec(&long).is_ok()); + assert_eq!(decode(&long, &Limits { max_header: 64, ..Limits::default() }), Err(Error::LimitExceeded("header length"))); + assert_eq!(decode(&long, &Limits { max_header: 8, ..Limits::default() }), Err(Error::Malformed("truncated header"))); + assert_eq!((Limits::default().max_pixels, Limits::default().max_header), (1 << 26, 64 << 10)); +}