mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
fix(codecs): respect uncompressed DDS row pitch
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user