fix(codecs): respect uncompressed DDS row pitch

This commit is contained in:
Jason Xiang
2026-10-14 10:52:14 -07:00
parent b8ae1cd92f
commit 5834d96003
3 changed files with 140 additions and 17 deletions
+3 -2
View File
@@ -70,8 +70,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
with the legacy header or the DX10 one. Uncompressed row padding follows the header's
`DDSD_PITCH` stride; without that flag, rows are tightly packed. Everything opens as U8;
the other mip levels are ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
+36 -15
View File
@@ -14,6 +14,7 @@ const HEADER: usize = 128;
/// DDS_HEADER_DXT10, present when the FourCC is `DX10`.
const DX10_HEADER: usize = 20;
const DDSD_PITCH: u32 = 0x8;
const DDSD_DEPTH: u32 = 0x80_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
@@ -106,15 +107,29 @@ impl Encoding {
}
}
/// Bytes of the top mip level.
fn level_size(&self, width: u32, height: u32) -> Option<usize> {
let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?);
let block = match self {
Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h),
Encoding::Bc1 => 8,
Encoding::Bc2 | Encoding::Bc3 => 16,
/// Stored row stride and byte size of the top mip level (block rows for BC).
fn level_layout(&self, width: u32, height: u32, pitch: Option<u32>) -> Result<(usize, usize), CodecError> {
let overflow = || bad("pixel data size overflow");
let (w, h) = (usize::try_from(width).map_err(|_| overflow())?, usize::try_from(height).map_err(|_| overflow())?);
let (row_bytes, rows) = match self {
Encoding::Masks { bytes, .. } => {
let packed = w.checked_mul(*bytes).ok_or_else(overflow)?;
let row_bytes = match pitch {
Some(pitch) => usize::try_from(pitch).map_err(|_| overflow())?,
None => packed,
};
if row_bytes < packed {
return Err(bad(format!("row pitch {row_bytes} is smaller than the {packed}-byte pixel row")));
}
(row_bytes, h)
}
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
let block = if matches!(self, Encoding::Bc1) { 8 } else { 16 };
(w.div_ceil(4).checked_mul(block).ok_or_else(overflow)?, h.div_ceil(4))
}
};
w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block)
let size = row_bytes.checked_mul(rows).ok_or_else(overflow)?;
Ok((row_bytes, size))
}
}
@@ -184,19 +199,25 @@ pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) };
let layout = encoding.layout();
limits.check(width, height, layout, SampleType::U8)?;
let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?;
// The same header field is a total level size for BC, but a row stride when DDSD_PITCH is set.
let pitch = if flags & DDSD_PITCH != 0 { Some(le32(b, 20)?) } else { None };
let (row_pitch, size) = encoding.level_layout(width, height, pitch)?;
let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?;
let (w, h) = (width as usize, height as usize);
let channels = layout.channels();
let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?;
let row_size = w.checked_mul(channels).ok_or_else(|| bad("pixel size overflow"))?;
let total = row_size.checked_mul(h).ok_or_else(|| bad("pixel size overflow"))?;
let mut pixels = vec![0u8; total];
match encoding {
Encoding::Masks { bytes, gray, fields } => {
for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) {
let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte));
let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel));
let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] };
dst.copy_from_slice(&values[..channels]);
for (src_row, dst_row) in data.chunks_exact(row_pitch).zip(pixels.chunks_exact_mut(row_size)) {
// Stop at the output width, leaving any stored row padding unread.
for (src, dst) in src_row.chunks_exact(bytes).zip(dst_row.chunks_exact_mut(channels)) {
let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte));
let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel));
let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] };
dst.copy_from_slice(&values[..channels]);
}
}
}
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
+101
View File
@@ -6,6 +6,7 @@ const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const DDSD_PITCH: u32 = 0x8;
/// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature.
fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec<u8> {
@@ -31,6 +32,11 @@ fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec<u8> {
header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4])
}
fn set_pitch(b: &mut [u8], pitch: u32) {
b[8..12].copy_from_slice(&(0x1007 | DDSD_PITCH).to_le_bytes());
b[20..24].copy_from_slice(&pitch.to_le_bytes());
}
/// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size.
fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec<u8> {
let mut b = compressed(width, height, b"DX10");
@@ -169,6 +175,101 @@ fn decodes_packed_masks_and_luminance() {
assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..]));
}
#[test]
fn decodes_padded_rgb_rows() {
// DWORD-aligned 24-bit rows: the fourth byte is padding, not the next pixel.
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
set_pitch(&mut b, 4);
b.extend([255, 0, 0, 99, 0, 255, 0, 88]);
let im = decode(&b).unwrap();
assert_eq!((im.dimensions(), im.layout()), ((1, 2), ChannelLayout::Rgb));
assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]);
// Padding can contain whole pixels; only the declared width is decoded.
let mut b = header(2, 2, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]);
set_pitch(&mut b, 8);
for v in [RED, GREEN, WHITE, WHITE, BLUE, BLACK, WHITE, WHITE] {
b.extend(v.to_le_bytes());
}
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0]);
}
#[test]
fn decodes_padded_luminance_rows() {
let mut b = header(2, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]);
set_pitch(&mut b, 4);
b.extend([10, 20, 99, 98, 30, 40, 97, 96]);
let im = decode(&b).unwrap();
assert_eq!((im.layout(), im.data()), (ChannelLayout::Gray, &[10u8, 20, 30, 40][..]));
let mut b = header(1, 2, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]);
set_pitch(&mut b, 4);
b.extend([10, 20, 99, 98, 30, 40, 97, 96]);
let im = decode(&b).unwrap();
assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..]));
}
#[test]
fn decodes_padded_dx10_rows() {
let mut b = dx10(1, 2, 28, 3, 0, 1); // R8G8B8A8_UNORM
set_pitch(&mut b, 8);
b.extend([1, 2, 3, 4, 99, 98, 97, 96, 5, 6, 7, 8, 95, 94, 93, 92]);
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn ignores_pitch_without_the_pitch_flag() {
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
b[20..24].copy_from_slice(&u32::MAX.to_le_bytes());
b.extend([255, 0, 0, 0, 255, 0]);
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]);
set_pitch(&mut b, 3); // An explicitly tightly packed row is also valid.
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]);
}
#[test]
fn compressed_linear_size_is_not_a_row_pitch() {
let mut b = compressed(4, 8, b"DXT1");
b[8..12].copy_from_slice(&(0x1007u32 | 0x8_0000).to_le_bytes()); // DDSD_LINEARSIZE
b[20..24].copy_from_slice(&16u32.to_le_bytes()); // Two 8-byte block rows.
b.extend(bc1_block(RED, RED, [0; 4]));
b.extend(bc1_block(BLUE, BLUE, [0; 4]));
let im = decode(&b).unwrap();
assert!(im.data()[..64].as_chunks::<4>().0.iter().all(|p| p == &[255, 0, 0, 255]));
assert!(im.data()[64..].as_chunks::<4>().0.iter().all(|p| p == &[0, 0, 255, 255]));
}
#[test]
fn rejects_pitch_smaller_than_a_pixel_row() {
for pitch in [0, 1, 2] {
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
set_pitch(&mut b, pitch);
b.extend([255, 0, 0, 0, 255, 0]);
assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })), "pitch {pitch}");
}
}
#[test]
fn rejects_truncated_padded_rows() {
let mut b = header(1, 2, DDPF_RGB, &[0; 4], 24, [0xff, 0xff00, 0xff_0000, 0]);
set_pitch(&mut b, 4);
b.extend([255, 0, 0, 99, 0, 255, 0, 88]);
assert!(decode(&b).is_ok());
for n in 128..b.len() {
assert!(matches!(decode(&b[..n]), Err(CodecError::Malformed { .. })), "decoded {n} of {} bytes", b.len());
}
}
#[test]
fn rejects_huge_pitch_without_allocating() {
let mut b = header(1, 2, DDPF_LUMINANCE, &[0; 4], 8, [0xff, 0, 0, 0]);
set_pitch(&mut b, u32::MAX);
b.extend([10, 20]);
// On 32-bit targets pitch * height overflows; on 64-bit the payload is too short.
assert!(matches!(decode(&b), Err(CodecError::Malformed { .. })));
}
#[test]
fn decodes_dx10_headers() {
let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM