From 059d6185c4000349678d682cac882f2110f73841 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 05:11:34 +0100 Subject: [PATCH] fix(io): DDS export writes the document's alpha channel A DDS texture's alpha came only from layer transparency, so a document's alpha channel (the usual home of a texture's alpha) was lost with every compression. As for a 32-bit Targa, exactly one alpha channel now becomes the texture's alpha (BC1 on/off, BC2 4-bit, BC3 interpolated, uncompressed exact), with the colours composited over white where the layers are transparent; with several, none is written, with a warning. Fixes #8563 Co-Authored-By: Claude Opus 5.5 --- crates/io/src/flat.rs | 34 +++++++++++++++++++++++++++++----- crates/io/tests/it/flat.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 7786c28351..118f12a911 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -626,6 +626,9 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti if format == Format::Tga { img = tga_image(doc, img, opts.tga_bits, &mut warnings)?; } + if format == Format::Dds { + img = dds_image(doc, img, &mut warnings)?; + } encode_image(&img, format, opts, warnings) } @@ -664,7 +667,7 @@ fn tga_image(doc: &Document, img: Image, bits: Option, warnings: &mut V warnings.push("transparency composited over white; a 24-bit Targa has no alpha".into()); matte_over_white(&img) } - (TgaBits::Bits32, [channel]) => tga_with_alpha_channel(doc, img, channel, warnings), + (TgaBits::Bits32, [channel]) => with_alpha_channel(doc, img, channel, "Targa", warnings), (TgaBits::Bits32, alphas) => { if !alphas.is_empty() { warnings.push("Targa holds one alpha channel; with several, none was written (as in Photoshop)".into()); @@ -677,11 +680,32 @@ fn tga_image(doc: &Document, img: Image, bits: Option, warnings: &mut V } } -/// A 32-bit Targa's image: the colours (composited over white where the layers are transparent) -/// and `channel`'s values as the alpha. -fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, warnings: &mut Vec) -> Result { +/// The image a DDS texture holds (#8563): as with a 32-bit Targa, the document's alpha channel +/// becomes the texture's alpha when it has exactly one (spot channels don't count), the colours +/// composited over white where the layers are transparent; BC1 keeps it as on/off, BC2 and BC3 +/// in full. With none, the layers' transparency is the alpha as before; with several, none of +/// them is written. Grayscale textures keep their transparency. +fn dds_image(doc: &Document, img: Image, warnings: &mut Vec) -> Result { + let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); + match alphas.as_slice() { + [] => Ok(img), + [channel] if img.layout().is_rgb() => with_alpha_channel(doc, img, channel, "texture", warnings), + [_] => { + warnings.push("a grayscale DDS keeps the layers' transparency; the alpha channel was not saved".into()); + Ok(img) + } + _ => { + warnings.push("a DDS texture holds one alpha channel; with several, none was written".into()); + Ok(img) + } + } +} + +/// A 32-bit Targa's or a texture's image: the colours (composited over white where the layers are +/// transparent) and `channel`'s values as the alpha. `what` names the file in the warning. +fn with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, what: &str, warnings: &mut Vec) -> Result { let img = if img.layout().has_alpha() { - warnings.push("transparency composited over white; the alpha channel is the Targa's alpha".into()); + warnings.push(format!("transparency composited over white; the alpha channel is the {what}'s alpha")); matte_over_white(&img)? } else { img diff --git a/crates/io/tests/it/flat.rs b/crates/io/tests/it/flat.rs index 353e2bbb7a..ef40f899ec 100644 --- a/crates/io/tests/it/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -1190,3 +1190,39 @@ fn a_stereo_png_keeps_its_stereo_mark_on_save() { let plain = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap(); assert_eq!(photocraft_codecs::png_stereo(&plain.bytes), None); } + +/// #8563: a DDS export writes the document's alpha channel as the texture's alpha, as a 32-bit +/// Targa does: exactly in an uncompressed texture, to 4 bits in BC2, interpolated in BC3, and on +/// or off in BC1. With several alpha channels none is written, with a warning. +#[test] +fn dds_writes_the_alpha_channel_as_the_texture_alpha() { + use photocraft_codecs::DdsCompression; + let d = with_alpha_channel(single(ColorMode::Rgb, SampleType::U8, false), "Alpha 1"); + for compression in DdsCompression::ALL { + let mut opts = ExportOptions::default(); + opts.encode.dds.compression = compression; + let r = export(&d, "a.dds", &opts).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).expect("decode dds"); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Rgba, "{compression:?}"); + let (w, _) = img.dimensions(); + let tolerance = match compression { + DdsCompression::None => 1.0, + DdsCompression::Bc2 => 9.0, + _ => 18.0, + }; + for (i, px) in img.data().as_chunks::<4>().0.iter().enumerate() { + let x = i as u32 % w; + let want = alpha_value(x) * 255.0; + if compression == DdsCompression::Bc1 && x == 4 { + continue; // half opacity sits on BC1's on/off threshold + } + assert!((f32::from(px[3]) - want).abs() <= tolerance, "{compression:?} pixel {i}: alpha {} want {want}", px[3]); + } + } + // Two alpha channels: Photoshop's rule for Targa, none of them is written. + let two = with_alpha_channel(d.clone(), "Alpha 2"); + let r = export(&two, "a.dds", &ExportOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("several")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).expect("decode dds"); + assert!(!img.layout().has_alpha() || img.data().as_chunks::<4>().0.iter().all(|p| p[3] == 255)); +}