feat(codecs): open DDS textures, uncompressed and BC1-BC3 (#1327) (#4255)

A read-only DirectDraw Surface codec on the model of PCX: the top mip
level of a 2D texture, uncompressed (channel masks, RGB or luminance,
8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10
header. Cubemaps, arrays, volumes and other formats are refused with
an Unsupported error naming what was found; every header field is
treated as hostile (checked sizes, limits before allocating, no
indexing on file offsets).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 21:47:16 -04:00
1 parent b507e04f1f
commit 10ba166a82
9 files changed
+585 -7

No files matched your search

+2 -2
View File
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr",
"grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
"abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
+2 -2
View File
@@ -21,8 +21,8 @@ mod recovery_tests;
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
"pgm", "ppm", "pam", "pfm", "pcx", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
+7
View File
@@ -40,6 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
@@ -67,6 +68,12 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
## Documented asymmetries and limitations
* **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
(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`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit
+284
View File
@@ -0,0 +1,284 @@
//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
//! texture arrays and volume textures are refused rather than flattened to one face or slice.
use crate::SampleType;
use crate::error::CodecError;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
/// DDS_HEADER_DXT10, present when the FourCC is `DX10`.
const DX10_HEADER: usize = 20;
const DDSD_DEPTH: u32 = 0x80_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const DDSCAPS2_CUBEMAP: u32 = 0x200;
const DDSCAPS2_VOLUME: u32 = 0x20_0000;
const DIMENSION_TEXTURE2D: u32 = 3;
const MISC_TEXTURECUBE: u32 = 0x4;
fn bad(reason: impl std::fmt::Display) -> CodecError {
CodecError::malformed(Format::Dds, reason)
}
fn unsupported(reason: impl Into<String>) -> CodecError {
CodecError::unsupported(Format::Dds, reason)
}
fn le32(b: &[u8], at: usize) -> Result<u32, CodecError> {
let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?;
Ok(u32::from_le_bytes(bytes))
}
/// A FourCC as text when it is printable, else as the number legacy writers store there.
fn fourcc_name(fourcc: u32) -> String {
let bytes = fourcc.to_le_bytes();
if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() }
}
/// One channel of an uncompressed pixel: where it sits and its largest value.
#[derive(Clone, Copy)]
struct Field {
shift: u32,
max: u32,
}
impl Field {
/// `None` for an empty mask; an error for one with gaps or wider than the pixel.
fn new(mask: u32, bits: u32) -> Result<Option<Field>, CodecError> {
if mask == 0 {
return Ok(None);
}
let shift = mask.trailing_zeros();
let max = mask >> shift;
if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) {
return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel")));
}
Ok(Some(Field { shift, max }))
}
fn get(self, pixel: u32) -> u8 {
let v = u64::from((pixel >> self.shift) & self.max);
let max = u64::from(self.max);
((v * 255 + max / 2) / max) as u8
}
}
enum Encoding {
/// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha.
Masks {
bytes: usize,
gray: bool,
fields: [Option<Field>; 4],
},
Bc1,
Bc2,
Bc3,
}
impl Encoding {
fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result<Encoding, CodecError> {
if !matches!(bits, 8 | 16 | 24 | 32) {
return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32")));
}
let mut fields = [None; 4];
for (field, mask) in fields.iter_mut().zip(masks) {
*field = Field::new(mask, bits)?;
}
let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) };
if !colour {
return Err(bad("the pixel format has no colour channel masks"));
}
Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields })
}
fn layout(&self) -> ChannelLayout {
match self {
Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA,
Encoding::Masks { gray: true, .. } => ChannelLayout::Gray,
Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb,
_ => ChannelLayout::Rgba,
}
}
/// 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,
};
w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block)
}
}
/// The legacy pixel format: a FourCC, or channel masks.
fn legacy_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?);
let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 };
if flags & DDPF_FOURCC != 0 {
return match &fourcc.to_le_bytes() {
b"DXT1" => Ok(Encoding::Bc1),
b"DXT3" => Ok(Encoding::Bc2),
b"DXT5" => Ok(Encoding::Bc3),
b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")),
_ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))),
};
}
if flags & DDPF_RGB != 0 {
return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false);
}
if flags & DDPF_LUMINANCE != 0 {
return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true);
}
Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression")))
}
/// The DX10 header's DXGI format, refusing anything but a single 2D texture.
fn dx10_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?);
if misc & MISC_TEXTURECUBE != 0 {
return Err(unsupported("cubemaps are not supported"));
}
if dimension != DIMENSION_TEXTURE2D {
return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})")));
}
if array > 1 {
return Err(unsupported(format!("texture arrays are not supported ({array} textures)")));
}
const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000];
const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000];
const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0];
match dxgi {
27..=29 => Encoding::masks(32, RGBA, false),
87 | 90 | 91 => Encoding::masks(32, BGRA, false),
88 | 92 | 93 => Encoding::masks(32, BGRX, false),
70..=72 => Ok(Encoding::Bc1),
73..=75 => Ok(Encoding::Bc2),
76..=78 => Ok(Encoding::Bc3),
_ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))),
}
}
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !b.starts_with(b"DDS ") {
return Err(bad("missing the `DDS ` signature"));
}
if le32(b, 4)? != 124 {
return Err(bad("header size is not 124"));
}
let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?);
if caps2 & DDSCAPS2_CUBEMAP != 0 {
return Err(unsupported("cubemaps are not supported"));
}
if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) {
return Err(unsupported("volume textures are not supported"));
}
let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10";
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"))?;
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 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]);
}
}
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 };
let blocks_x = w.div_ceil(4);
for (i, block) in data.chunks_exact(block_size).enumerate() {
let texels = match encoding {
Encoding::Bc1 => bc1(block, true),
Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)),
_ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)),
};
let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4);
for (t, texel) in texels.iter().enumerate() {
let (x, y) = (bx + t % 4, by + t / 4);
if x < w && y < h {
let at = (y * w + x) * 4;
if let Some(dst) = pixels.get_mut(at..at + 4) {
dst.copy_from_slice(texel);
}
}
}
}
}
}
Image::from_u8(width, height, layout, pixels)
}
fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
/// `c0 <= c1` order selects three colours and transparent black.
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
let palette = if c0 > c1 || !one_bit_alpha {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
};
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
/// BC2: four explicit bits of alpha per texel.
fn bc2_alpha(block: &[u8]) -> [u8; 16] {
let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0)));
std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17)
}
/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus
/// 0 and 255 when `a0 <= a1`.
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
for (texel, a) in texels.iter_mut().zip(alpha) {
texel[3] = a;
}
texels
}
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod dds;
pub(crate) mod deep_exr;
pub(crate) mod dpx;
pub(crate) mod exr;
+15 -1
View File
@@ -19,6 +19,8 @@ pub enum Format {
Pcx,
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
Xpm,
/// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
Dds,
Qoi,
OpenExr,
/// Radiance RGBE.
@@ -67,6 +69,10 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
Format::Pcx,
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
),
(
Format::Dds,
"DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.",
),
(
Format::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
@@ -89,7 +95,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 18] = [
pub const ALL: [Format; 19] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -101,6 +107,7 @@ impl Format {
Format::Pnm,
Format::Pcx,
Format::Xpm,
Format::Dds,
Format::Qoi,
Format::OpenExr,
Format::Hdr,
@@ -133,6 +140,7 @@ impl Format {
Format::Pnm => "Netpbm",
Format::Pcx => "PCX",
Format::Xpm => "XPM",
Format::Dds => "DDS",
Format::Qoi => "QOI",
Format::OpenExr => "OpenEXR",
Format::Hdr => "Radiance HDR",
@@ -158,6 +166,7 @@ impl Format {
Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"],
Format::Pcx => &["pcx"],
Format::Xpm => &["xpm"],
Format::Dds => &["dds"],
Format::Qoi => &["qoi"],
Format::OpenExr => &["exr"],
Format::Hdr => &["hdr"],
@@ -181,6 +190,7 @@ impl Format {
Format::Pnm => "image/x-portable-anymap",
Format::Pcx => "image/x-pcx",
Format::Xpm => "image/x-xpixmap",
Format::Dds => "image/vnd-ms.dds",
Format::Qoi => "image/qoi",
Format::OpenExr => "image/x-exr",
Format::Hdr => "image/vnd.radiance",
@@ -232,6 +242,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base },
Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base },
Format::Qoi => base,
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
@@ -267,6 +278,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(b"qoif") {
return Some(Format::Qoi);
}
if b.starts_with(b"DDS ") {
return Some(Format::Dds);
}
if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) {
return Some(Format::OpenExr);
}
+3 -1
View File
@@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
pub use half::f16;
use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm};
use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -76,6 +76,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Pnm => pnm::decode(bytes, l),
Format::Pcx => pcx::decode(bytes, l),
Format::Xpm => xpm::decode(bytes, l),
Format::Dds => dds::decode(bytes, l),
Format::Fits => fits::decode(bytes, l),
Format::Dpx => dpx::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
@@ -227,6 +228,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Pnm => pnm::encode(image, plan, opts),
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")),
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::OpenExr => exr::encode(image, plan, opts),
+268
View File
@@ -0,0 +1,268 @@
use photocraft_codecs::{
ChannelLayout, CodecError, DecodeOptions, Format, Limits, SampleType, caps, decode, decode_as, decode_as_with, detect, encode, from_extension,
};
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
/// 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> {
let mut b = vec![0u8; 128];
b[0..4].copy_from_slice(b"DDS ");
let mut put = |at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
put(4, 124);
put(8, 0x1007); // CAPS | HEIGHT | WIDTH | PIXELFORMAT
put(12, height);
put(16, width);
put(76, 32);
put(80, pf_flags);
put(88, bits);
for (i, m) in masks.into_iter().enumerate() {
put(92 + 4 * i, m);
}
put(108, 0x1000); // DDSCAPS_TEXTURE
b[84..88].copy_from_slice(fourcc);
b
}
fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec<u8> {
header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4])
}
/// 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");
for v in [dxgi, dimension, misc, array, 0] {
b.extend(v.to_le_bytes());
}
b
}
/// BC1 colour endpoints as RGB565, then one 2-bit index per pixel, row by row.
fn bc1_block(c0: u16, c1: u16, rows: [u8; 4]) -> Vec<u8> {
let mut b = Vec::new();
b.extend(c0.to_le_bytes());
b.extend(c1.to_le_bytes());
b.extend(rows);
b
}
const RED: u16 = 0xf800;
const GREEN: u16 = 0x07e0;
const BLUE: u16 = 0x001f;
const BLACK: u16 = 0x0000;
const WHITE: u16 = 0xffff;
/// Indices 0, 1 on the top row and 2, 3 below them, for the top-left 2x2 pixels.
const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0];
#[test]
fn dds_is_readable_but_not_writable() {
assert_eq!(from_extension("texture.DDS"), Some(Format::Dds));
assert!(caps(Format::Dds).read && !caps(Format::Dds).write);
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap();
assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. })));
}
#[test]
fn decodes_bc1_four_colour_blocks() {
// c0 > c1: four opaque colours, the two in between at thirds.
let mut b = compressed(2, 2, b"DXT1");
b.extend(bc1_block(RED, BLUE, INDICES_0123));
assert_eq!(detect(&b), Some(Format::Dds));
let im = decode(&b).unwrap();
assert_eq!((im.dimensions(), im.layout(), im.sample_type()), ((2, 2), ChannelLayout::Rgba, SampleType::U8));
assert_eq!(im.data(), &[255, 0, 0, 255, 0, 0, 255, 255, 170, 0, 85, 255, 85, 0, 170, 255]);
}
#[test]
fn decodes_bc1_one_bit_alpha_blocks() {
// c0 <= c1: three colours (the third halfway) and index 3 is transparent black.
let mut b = compressed(2, 2, b"DXT1");
b.extend(bc1_block(BLUE, RED, INDICES_0123));
assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]);
}
#[test]
fn decodes_bc2_explicit_alpha_with_four_colour_blocks() {
let mut b = compressed(2, 2, b"DXT3");
// Four bits of alpha per pixel: 15, 0 on the top row, 8, 1 below.
b.extend([0x0f, 0x00, 0x18, 0x00, 0, 0, 0, 0]);
// c0 < c1 would mean transparency in BC1; BC2 colour blocks always have four colours.
b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0]));
let im = decode(&b).unwrap();
assert_eq!(im.layout(), ChannelLayout::Rgba);
assert_eq!(im.data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 136, 85, 85, 85, 17]);
}
#[test]
fn decodes_bc3_interpolated_alpha() {
// a0 > a1: eight alpha values, six interpolated at sevenths. Indices 0, 1 / 2, 7.
let mut b = compressed(2, 2, b"DXT5");
b.extend([255, 0, 0x08, 0xa0, 0x03, 0, 0, 0]);
b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0]));
assert_eq!(decode(&b).unwrap().data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 219, 85, 85, 85, 36]);
// a0 <= a1: six values, four interpolated at fifths, then 0 and 255. Indices 2, 5 / 6, 7.
let mut b = compressed(2, 2, b"DXT5");
let bits: u64 = 2 | 5 << 3 | 6 << 12 | 7 << 15;
b.extend([0, 255]);
b.extend(&bits.to_le_bytes()[..6]);
b.extend(bc1_block(GREEN, GREEN, [0; 4]));
assert_eq!(decode(&b).unwrap().data(), &[0, 255, 0, 51, 0, 255, 0, 204, 0, 255, 0, 0, 0, 255, 0, 255]);
}
#[test]
fn bc_textures_need_not_be_a_multiple_of_four() {
// 5x3 is two blocks wide and one high; the right block's first column is x = 4.
let mut b = compressed(5, 3, b"DXT1");
b.extend(bc1_block(RED, RED, [0; 4]));
b.extend(bc1_block(BLUE, BLUE, [0; 4]));
let im = decode(&b).unwrap();
assert_eq!(im.dimensions(), (5, 3));
let px = |x: usize, y: usize| &im.data()[(y * 5 + x) * 4..(y * 5 + x + 1) * 4];
assert_eq!((px(3, 2), px(4, 0), px(4, 2)), (&[255, 0, 0, 255][..], &[0, 0, 255, 255][..], &[0, 0, 255, 255][..]));
}
#[test]
fn decodes_uncompressed_rgba_and_bgra_masks() {
let rgba = [0xff, 0xff00, 0xff_0000, 0xff00_0000];
let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, rgba);
b.extend([1, 2, 3, 4, 250, 251, 252, 253]);
let im = decode(&b).unwrap();
assert_eq!(im.layout(), ChannelLayout::Rgba);
assert_eq!(im.data(), &[1, 2, 3, 4, 250, 251, 252, 253]);
let bgra = [0xff_0000, 0xff00, 0xff, 0xff00_0000];
let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, bgra);
b.extend([3, 2, 1, 4, 252, 251, 250, 253]);
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 250, 251, 252, 253]);
// An alpha mask without DDPF_ALPHAPIXELS is not alpha: X8R8G8B8 opens as RGB.
let mut b = header(1, 1, DDPF_RGB, &[0; 4], 32, bgra);
b.extend([3, 2, 1, 0]);
let im = decode(&b).unwrap();
assert_eq!((im.layout(), im.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..]));
}
#[test]
fn decodes_packed_masks_and_luminance() {
// R5G6B5: each field scaled to 0..255; 3 of 31 is 25.
let mut b = header(4, 1, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]);
for v in [RED, GREEN, BLUE, 3 << 11] {
b.extend(v.to_le_bytes());
}
let im = decode(&b).unwrap();
assert_eq!(im.layout(), ChannelLayout::Rgb);
assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 25, 0, 0]);
// 24-bit rows are packed three bytes a pixel, without padding.
let mut b = header(2, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]);
b.extend([3, 2, 1, 6, 5, 4, 9, 8, 7, 12, 11, 10]);
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
let mut b = header(2, 1, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]);
b.extend([10, 20, 30, 40]);
let im = decode(&b).unwrap();
assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..]));
}
#[test]
fn decodes_dx10_headers() {
let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM
b.extend([1, 2, 3, 4]);
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]);
let mut b = dx10(1, 1, 91, 3, 0, 1); // B8G8R8A8_UNORM_SRGB
b.extend([3, 2, 1, 4]);
assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]);
let mut b = dx10(2, 2, 71, 3, 0, 1); // BC1_UNORM
b.extend(bc1_block(BLUE, RED, INDICES_0123));
assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]);
}
#[test]
fn only_the_top_mip_level_is_read() {
let mut b = compressed(4, 4, b"DXT1");
b[28..32].copy_from_slice(&3u32.to_le_bytes());
b.extend(bc1_block(RED, RED, [0; 4]));
b.extend(bc1_block(BLUE, BLUE, [0; 4]));
b.extend(bc1_block(GREEN, GREEN, [0; 4]));
let im = decode(&b).unwrap();
assert_eq!(im.dimensions(), (4, 4));
assert!(im.data().chunks(4).all(|p| p == [255, 0, 0, 255]));
}
#[test]
fn refuses_cubemaps_arrays_and_volumes() {
let unsupported = |b: &[u8], what: &str| match decode_as(Format::Dds, b) {
Err(CodecError::Unsupported { reason, .. }) => assert!(reason.contains(what), "{reason}"),
other => panic!("{what}: {other:?}"),
};
let mut cube = compressed(4, 4, b"DXT1");
cube[112..116].copy_from_slice(&0xfe00u32.to_le_bytes()); // DDSCAPS2_CUBEMAP and all faces
cube.extend([0; 8 * 6]);
unsupported(&cube, "cubemap");
let mut volume = compressed(4, 4, b"DXT1");
volume[8..12].copy_from_slice(&(0x1007u32 | 0x80_0000).to_le_bytes()); // DDSD_DEPTH
volume[24..28].copy_from_slice(&4u32.to_le_bytes());
volume[112..116].copy_from_slice(&0x20_0000u32.to_le_bytes()); // DDSCAPS2_VOLUME
volume.extend([0; 8 * 4]);
unsupported(&volume, "volume");
unsupported(&dx10(4, 4, 71, 3, 0x4, 1), "cubemap");
unsupported(&dx10(4, 4, 71, 3, 0, 6), "array");
unsupported(&dx10(4, 4, 71, 4, 0, 1), "2D");
unsupported(&dx10(4, 4, 98, 3, 0, 1), "98"); // BC7_UNORM
unsupported(&compressed(4, 4, b"ATI2"), "ATI2");
unsupported(&compressed(4, 4, b"DXT2"), "premultiplied");
}
#[test]
fn rejects_truncated_files_at_every_length_without_panicking() {
let mut b = dx10(5, 3, 77, 3, 0, 1); // BC3_UNORM: two 16-byte blocks
b.extend([0x5a; 32]);
assert!(decode(&b).is_ok());
for n in 0..b.len() {
assert!(decode_as(Format::Dds, &b[..n]).is_err(), "decoded {n} of {} bytes", b.len());
}
let mut b = header(3, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]);
b.extend([7; 18]);
assert!(decode(&b).is_ok());
assert!(decode_as(Format::Dds, &b[..b.len() - 1]).is_err(), "one byte short");
}
#[test]
fn rejects_bogus_sizes_and_pixel_formats() {
// Huge dimensions are refused before anything is allocated.
let mut huge = compressed(u32::MAX, u32::MAX, b"DXT1");
huge.extend([0; 8]);
assert!(matches!(decode_as(Format::Dds, &huge), Err(CodecError::LimitExceeded(_))));
let opts = DecodeOptions { limits: Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() };
let mut tight = header(64, 64, DDPF_RGB, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]);
tight.extend(vec![0; 64 * 64 * 4]);
assert!(matches!(decode_as_with(Format::Dds, &tight, &opts), Err(CodecError::LimitExceeded(_))));
assert!(decode_as(Format::Dds, &compressed(0, 4, b"DXT1")).is_err(), "zero width");
let mut bad_size = compressed(4, 4, b"DXT1");
bad_size[4..8].copy_from_slice(&100u32.to_le_bytes());
bad_size.extend([0; 8]);
assert!(matches!(decode_as(Format::Dds, &bad_size), Err(CodecError::Malformed { .. })), "header size must be 124");
for (bits, masks) in [
(12, [0xf00, 0xf0, 0xf, 0]), // not a whole number of bytes
(16, [0xff_0000, 0xff00, 0xff, 0]), // a mask wider than the pixel
(32, [0xf0f, 0xf0f0, 0xf_0000, 0]), // a mask with a gap
(32, [0, 0, 0, 0]), // no colour at all
(0, [0xff, 0xff00, 0xff_0000, 0]), // zero bits per pixel
] {
let mut b = header(1, 1, DDPF_RGB, &[0; 4], bits, masks);
b.extend([0; 4]);
assert!(decode_as(Format::Dds, &b).is_err(), "{bits} bits, masks {masks:x?}");
}
let mut no_format = header(1, 1, 0, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]);
no_format.extend([0; 4]);
assert!(matches!(decode_as(Format::Dds, &no_format), Err(CodecError::Unsupported { .. })));
}
+3 -1
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
@@ -90,6 +90,7 @@ D5100 colour profile. Counts and the G14 estimate are unchanged.
| Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) |
| OpenRaster `.ora` | yes | yes | [ora.md](ora.md) |
| ICO, QOI | yes | yes | |
| DirectDraw Surface `.dds` | yes | no | Top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in |
| Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either |
### Affinity import notes (moved from `roadmap.md`)
@@ -141,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | DDS textures open, read-only (#1327) |
| 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits |
| 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing |
| 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) |