From 4121ff4dc1acbba7f30523f366965705d132e347 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 07:37:09 +0100 Subject: [PATCH 1/4] fix(tiff): keep a TIFF's alpha channel, and add a tiffCompression save option Photoshop writes a document's alpha channel as an unspecified extra sample (ExtraSamples 0). The decoder dropped it, so such a TIFF opened without its channel and a save never wrote one. With no extra sample marked as transparency, the first unspecified one is now decoded as the alpha and flagged as an alpha channel (Metadata::alpha_is_channel): flat TIFFs open it as a named alpha channel over an opaque Background, layered ones through their Photoshop channel names. Saving writes the first alpha channel as an unspecified extra sample when the document has no transparency (flat and layered), naming the channels it leaves out; extra samples beyond the alpha are dropped with a warning. Saves also take tiffCompression (none, lzw, zip) and the CLI --tiff-compression. Fixes #5301. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 28 ++++++-- crates/codecs/src/codecs/tiff.rs | 29 ++++++-- crates/codecs/src/image.rs | 11 +++ crates/codecs/tests/tiff_pages.rs | 30 +++++--- crates/codecs/tests/tiff_photoshop.rs | 22 ++++++ crates/engine/src/file_cmds.rs | 39 +++++++++-- crates/engine/src/file_cmds/tests.rs | 36 ++++++++++ crates/io/src/flat.rs | 94 ++++++++++++++++++++++---- crates/io/src/psd_import.rs | 2 +- crates/io/src/tiff_layers.rs | 10 ++- crates/io/tests/tiff_alpha_channels.rs | 80 ++++++++++++++++++++++ crates/io/tests/tiff_layers.rs | 38 ++++++----- 12 files changed, 357 insertions(+), 62 deletions(-) create mode 100644 crates/io/tests/tiff_alpha_channels.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index a81cce61ce..9ea9f4f066 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,19 +15,20 @@ pub const USAGE: &str = "\ photocraft-cli: headless Photocraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). - TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). + TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data); + --tiff-compression compresses it (none, the default; lzw; zip). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression ] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -69,17 +70,22 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { + name: "convert", + values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--tiff-compression"], + bare: &["--tiff-layers"], + run: convert, + }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--tiff-compression"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--tiff-compression"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -219,6 +225,14 @@ fn export_opts(a: &Args) -> Result { o.encode.dpx_bits = Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); } + if let Some(c) = a.get("--tiff-compression") { + o.encode.tiff_compression = match c { + "none" => photocraft_codecs::TiffCompression::None, + "lzw" => photocraft_codecs::TiffCompression::Lzw, + "zip" => photocraft_codecs::TiffCompression::Deflate, + _ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")), + }; + } Ok(o) } diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 5e09a68ece..9cae4e1c17 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; let mut img = decode_ifd(&file, &dir, limits)?; + let alpha_is_channel = img.meta.alpha_is_channel; img.meta = metadata(&file, &dir, &mut img.icc); + img.meta.alpha_is_channel = alpha_is_channel; let pages = info.page_count(); if pages > 1 { img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); @@ -541,8 +543,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 4, _ => 1, }; - let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); - let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0); + // With no extra sample marked as transparency, the first unspecified one (ExtraSamples 0) is + // decoded as the alpha and flagged as an alpha channel: Photoshop writes a document's alpha + // channel that way, and dropping it lost the channel (#5301). + let marked = extra.iter().position(|&e| e == 1 || e == 2); + let paletted = palette.is_some(); + let channel_alpha = marked.is_none() && !paletted && extra.first() == Some(&0); + let alpha_at = marked.or(channel_alpha.then_some(0)).filter(|&a| (color + a as u64) < spp); + // The `tiff` crate returns an RGB or CMYK alpha only when the first extra sample is marked as + // one, so an alpha channel (unspecified) is read through every sample too. + let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0 || channel_alpha); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -677,9 +687,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); } + // Extra samples past the colour and the one decoded as the alpha are not imported. + let kept = out_layout.channels() as u64; + let skipped = if paletted { 0 } else { spp.saturating_sub(kept) }; + if skipped > 0 { + img.warnings.push(DecodeWarning::ExtraSamplesSkipped { count: u32::try_from(skipped).unwrap_or(u32::MAX) }); + } Ok(img) } @@ -1030,6 +1047,9 @@ struct TagSet<'a> { dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, alpha: bool, + /// The alpha is an alpha channel ([`Metadata::alpha_is_channel`]): written as an unspecified + /// extra sample (0) rather than unassociated alpha (2). + alpha_is_channel: bool, photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, } @@ -1060,8 +1080,8 @@ where } let d = im.encoder(); if tags.alpha { - // 2 = unassociated alpha - d.write_tag(Tag::ExtraSamples, &[2u16][..])?; + // 2 = unassociated alpha (transparency); 0 = unspecified, Photoshop's alpha channel. + d.write_tag(Tag::ExtraSamples, &[if tags.alpha_is_channel { 0u16 } else { 2 }][..])?; } if let Some(icc) = tags.icc { d.write_tag(Tag::IccProfile, Undefined(icc))?; @@ -1169,6 +1189,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && d.1 > 0.0) } else { None }, text, alpha: img.layout().has_alpha(), + alpha_is_channel: img.layout().has_alpha() && img.meta.alpha_is_channel, // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index db59d4fa6b..526681bf8b 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -127,6 +127,11 @@ pub struct Metadata { /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. pub photoshop_layers: Option>, + /// The image's alpha is an alpha channel (a saved selection or texture mask), not + /// transparency: TIFF's "unspecified" extra sample (ExtraSamples 0), which Photoshop writes for + /// a document's alpha channel (#5301). Decoders set it; the TIFF encoder writes such an alpha as + /// an unspecified extra sample instead of as transparency. + pub alpha_is_channel: bool, } impl Metadata { @@ -137,6 +142,7 @@ impl Metadata { && self.text.is_empty() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() + && !self.alpha_is_channel } } @@ -163,6 +169,9 @@ pub enum DecodeWarning { /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, /// 255 when undefined). The code values are imported as stored, without a colour transform. DpxEncoding { transfer: u8, colorimetric: u8 }, + /// A TIFF's extra samples beyond the one decoded as the alpha (more alpha channels, or data + /// the file doesn't describe) were not imported. + ExtraSamplesSkipped { count: u32 }, } /// The SMPTE 268M name of a transfer characteristic or colorimetric code. @@ -213,6 +222,8 @@ impl fmt::Display for DecodeWarning { dpx_code_name(*transfer), dpx_code_name(*colorimetric) ), + DecodeWarning::ExtraSamplesSkipped { count: 1 } => write!(f, "one more extra channel in the TIFF was not imported"), + DecodeWarning::ExtraSamplesSkipped { count } => write!(f, "{count} more extra channels in the TIFF were not imported"), } } } diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/tiff_pages.rs index 60885e527d..b1f87a94cd 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/tiff_pages.rs @@ -128,13 +128,16 @@ fn planar_strips_at_every_depth() { } } +/// #5301: with no extra sample marked as transparency, the first unspecified one is decoded as +/// the alpha and flagged as an alpha channel (Photoshop's alpha channel). Extra samples beyond +/// the colour and the alpha are dropped, with a warning. #[test] -fn extra_samples_that_are_not_alpha_are_dropped() { +fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_dropped() { let (w, h) = (5usize, 3usize); let t = truth(w * h * 5, 4); let rgb_of = |spp: usize, keep: usize| -> Vec { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() }; for planar in [false, true] { - // RGB + one unspecified extra sample: RGB. + // RGB + one unspecified extra sample: RGB and an alpha channel. let px = &t.u8s[..w * h * 4]; let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]); @@ -142,9 +145,10 @@ fn extra_samples_that_are_not_alpha_are_dropped() { d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::Rgb); - assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}"); - // RGB + alpha + an unspecified extra sample: RGBA. + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, true), "planar {planar}"); + assert_eq!(img.data(), &rgb_of(4, 4)[..], "planar {planar}"); + assert_eq!(img.warnings, [], "planar {planar}"); + // RGB + alpha + an unspecified extra sample: RGBA (transparency), the extra dropped. let px = &t.u8s[..w * h * 5]; let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]); @@ -152,20 +156,23 @@ fn extra_samples_that_are_not_alpha_are_dropped() { d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(false, true, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, false), "planar {planar}"); assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}"); + assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "planar {planar}"); } - // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. + // Gray + alpha + extra: gray and alpha, the extra dropped; gray + an unspecified extra: gray + // and an alpha channel. let px = &t.u8s[..w * h * 3]; let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::GrayA); + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, false)); assert_eq!(img.data(), &rgb_of(3, 2)[..]); + assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }]); let px = &t.u8s[..w * h * 2]; let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!(img.layout(), ChannelLayout::Gray); - assert_eq!(img.data(), &rgb_of(2, 1)[..]); + assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, true)); + assert_eq!(img.data(), &rgb_of(2, 2)[..]); } #[test] @@ -193,7 +200,8 @@ fn alpha_after_an_unspecified_extra_sample_is_kept() { let what = format!("photometric {photometric} little {little} big {big} bits {bits} planar {planar}"); assert_eq!((img.layout(), img.sample_type()), (layout, sample), "{what}"); assert_eq!(img.data(), &want[..], "{what}"); - assert_eq!(img.warnings, [], "{what}"); + // The unspecified sample before the alpha is the one left out. + assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "{what}"); } } } diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/tiff_photoshop.rs index 400b72eb0c..1daebcc9f4 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/tiff_photoshop.rs @@ -172,3 +172,25 @@ fn malformed_tags_read_as_absent_without_panicking() { f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes()); assert_eq!(tiff_photoshop_tags(&f).1, None); } + +/// #5301: an alpha flagged as an alpha channel is written as an unspecified extra sample +/// (ExtraSamples 0, as Photoshop writes a document's alpha channel) and decodes back flagged, in +/// every layout with an alpha and both orders of decoding (chunky and the all-samples path); +/// transparency stays unassociated alpha (2). +#[test] +fn an_alpha_channel_round_trips_as_an_unspecified_extra_sample() { + for layout in [ChannelLayout::Rgba, ChannelLayout::GrayA, ChannelLayout::CmykA] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate] { + for channel in [true, false] { + let mut img = synth(13, 11, layout, SampleType::U8, 7, 0.4); + img.meta.alpha_is_channel = channel; + let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {compression:?} channel {channel}"); + assert_eq!(back.meta.alpha_is_channel, channel, "{layout:?} {compression:?}"); + assert_eq!(back.data(), img.data(), "{layout:?} {compression:?} channel {channel}"); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + } + } + } +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 7689f39679..0e286f0fa3 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -262,6 +262,8 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. pub dpx_bits: Option, + /// TIFF: the image compression (`"tiffCompression": "none" | "lzw" | "zip"`); unset, none. + pub tiff_compression: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -271,7 +273,15 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { + quality: None, + tiff_layers: false, + tga_bits: None, + dpx_bits: None, + tiff_compression: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -287,6 +297,7 @@ impl SaveOpts { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, dpx_bits: dpx_bits_param(p, cmd)?, + tiff_compression: tiff_compression_param(p, cmd)?, image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -318,6 +329,21 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + use photocraft_codecs::TiffCompression; + match p.get("tiffCompression") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_str() { + Some("none") => Ok(Some(TiffCompression::None)), + Some("lzw") => Ok(Some(TiffCompression::Lzw)), + Some("zip") => Ok(Some(TiffCompression::Deflate)), + _ => Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("`tiffCompression` must be \"none\", \"lzw\" or \"zip\", got {v}") }), + }, + } +} + /// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's /// depth). Anything else is a bad parameter of command `cmd`. pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result> { @@ -346,6 +372,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); opts.encode.dpx_bits = save.dpx_bits; + if let Some(c) = save.tiff_compression { + opts.encode.tiff_compression = c; + } if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; @@ -1486,7 +1515,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tiffCompression":"none|lzw|zip"? (TIFF: image compression; unset, none),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), @@ -1549,7 +1578,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1558,7 +1587,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1586,7 +1615,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 99ddce15de..2e14eddd54 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1147,3 +1147,39 @@ fn batch_naming_rejects_outputs_that_replace_inputs_before_processing() { assert_eq!(std::fs::read(&first).unwrap(), before[0]); assert_eq!(std::fs::read(&second).unwrap(), before[1]); } + +/// The Compression tag (259) of a TIFF's first directory. +fn tiff_compression_tag(b: &[u8]) -> Option { + let le = b.get(..2)? == b"II"; + let u16_at = |i: usize| b.get(i..i + 2).map(|s| if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }); + let u32_at = + |i: usize| b.get(i..i + 4).map(|s| if le { u32::from_le_bytes([s[0], s[1], s[2], s[3]]) } else { u32::from_be_bytes([s[0], s[1], s[2], s[3]]) }); + let ifd = u32_at(4)? as usize; + (0..usize::from(u16_at(ifd)?)).map(|k| ifd + 2 + 12 * k).find(|e| u16_at(*e) == Some(259)).and_then(|e| u16_at(e + 8)) +} + +/// #5301: `tiffCompression` picks a TIFF's compression (TIFF Options: None, LZW, ZIP); unset, +/// none. Anything else is refused before anything is written. +#[test] +fn save_a_copy_writes_the_tiff_compression_asked_for() { + let dir = tmp("tiff-compression"); + for (params, tag) in + [(json!({}), 1), (json!({"tiffCompression": "none"}), 1), (json!({"tiffCompression": "lzw"}), 5), (json!({"tiffCompression": "zip"}), 8)] + { + let mut s = session(12, 6, 8); + let out = join(&dir, "a.tif"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!(tiff_compression_tag(&bytes), Some(tag), "{params}"); + assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] { + let out = join(&dir, "bad.tif"); + let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + } +} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 5512a55c01..fe70cc7ffc 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -36,7 +36,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { } r.document.file_encoding = file_encoding(bytes); if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { - tga_alpha_to_channel(&mut r.document)?; + alpha_to_channel(&mut r.document, "Alpha 1")?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear @@ -103,11 +103,13 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { } } -/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the -/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; -/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel -/// on the next save, where only an alpha channel becomes the Targa's alpha. -fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { +/// Moves a just-opened single layer's alpha into an alpha channel named `name`, leaving an +/// opaque Background. Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" +/// channel holding the fourth byte, even when the header declares no alpha bits, and keeps the +/// stored colours (#2225; measured on Photoshop 27.11). Layer transparency would hide those +/// colours and lose the channel on the next save, where only an alpha channel becomes the +/// Targa's alpha. A TIFF's unspecified extra sample is Photoshop's alpha channel too (#5301). +fn alpha_to_channel(doc: &mut Document, name: &str) -> Result<(), IoError> { let canvas = doc.bounds(); let depth = doc.depth; let Some(layer) = doc.layers.first_mut() else { return Ok(()) }; @@ -133,11 +135,11 @@ fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { layer.name = "Background".into(); layer.locks.transparency = true; layer.locks.position = true; - doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", alpha)); + doc.channels.push(photocraft_doc::AlphaChannel::new(name, alpha)); Ok(()) } -/// Undoes [`tga_alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel +/// Undoes [`alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel /// becomes the layer's transparency again. For image sequences, which treat a frame's alpha as /// transparency, as video footage does (Photoshop's Interpret Footage). Anything else is left /// alone. @@ -187,7 +189,27 @@ pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option) -> Result } return Ok(r); } - image_to_document(name, &img.oriented(orientation)?) + // An unspecified extra sample is an alpha channel, not transparency (#5301): named as the + // TIFF's Photoshop resources name it (Unicode, else Pascal names), else "Alpha 1". + let channel_name = img.meta.alpha_is_channel.then(|| tiff_alpha_name(img.meta.photoshop_resources.as_deref())); + let mut r = image_to_document(name, &img.oriented(orientation)?)?; + if let Some(channel_name) = channel_name { + alpha_to_channel(&mut r.document, &channel_name)?; + } + Ok(r) +} + +/// The first alpha channel's name in a TIFF's Photoshop resources (1045 Unicode names, else +/// 1006 Pascal names), else "Alpha 1". +fn tiff_alpha_name(resources: Option<&[u8]>) -> String { + let resources = resources.and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()).map(|(r, _)| r).unwrap_or_default(); + let unicode = resources.iter().find(|r| r.id == 1045).and_then(|r| crate::psd_import::unicode_names(&r.data).into_iter().next()); + let pascal = || { + let data = &resources.iter().find(|r| r.id == 1006)?.data; + let n = usize::from(*data.first()?); + Some(String::from_utf8_lossy(data.get(1..1 + n)?).into_owned()) + }; + unicode.or_else(pascal).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Alpha 1".into()) } /// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a @@ -560,9 +582,39 @@ 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::Tiff { + img = tiff_image(doc, img, &mut warnings)?; + } encode_image(&img, format, opts, warnings) } +/// A flat TIFF's image with the document's alpha channel (#5301). Photoshop writes an alpha +/// channel as an unspecified extra sample; this writer holds one extra sample, so the first +/// alpha channel (spot channels don't count) is written when the document has no transparency, +/// named in the Photoshop resources so it opens with its name. With transparency, that is the +/// extra sample and the alpha channels are left out, as before; either way the ones left out are +/// named in a warning. +fn tiff_image(doc: &Document, img: Image, warnings: &mut Vec) -> Result { + let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); + let names = |cs: &[&AlphaChannel]| cs.iter().map(|c| format!("\"{}\"", c.name)).collect::>().join(", "); + let Some((first, rest)) = alphas.split_first() else { return Ok(img) }; + if img.layout().has_alpha() { + warnings.push(format!("the TIFF's one extra channel holds the transparency; alpha channels not saved: {}", names(&alphas))); + return Ok(img); + } + if !rest.is_empty() { + warnings.push(format!("the TIFF holds one alpha channel; not saved: {}", names(rest))); + } + let mut out = append_alpha_channel(doc, &img, first)?; + out.meta.alpha_is_channel = true; + let resources = [ + photocraft_psd::ImageResource::new(1006, crate::psd_export::pascal_names_resource(&[first.name.as_str()])), + photocraft_psd::ImageResource::new(1045, crate::psd_export::unicode_names_resource(&[first.name.as_str()])), + ]; + out.meta.photoshop_resources = photocraft_psd::tiff::resources_to_bytes(&resources).ok(); + Ok(out) +} + /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an /// alpha channel (spot channels don't count), else 24. pub fn tga_default_bits(doc: &Document) -> TgaBits { @@ -620,6 +672,18 @@ fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, wa } else { img }; + append_alpha_channel(doc, &img, channel) +} + +/// `img` (without alpha: RGB, gray or CMYK) with `channel`'s values added as its alpha. +fn append_alpha_channel(doc: &Document, img: &Image, channel: &AlphaChannel) -> Result { + let out_layout = match img.layout() { + ChannelLayout::Rgb => ChannelLayout::Rgba, + ChannelLayout::Gray => ChannelLayout::GrayA, + ChannelLayout::Cmyk => ChannelLayout::CmykA, + other => return Err(IoError::Unsupported(format!("an alpha channel can't be added to a {other:?} image"))), + }; + let colors = img.layout().channels(); let (w, h) = img.dimensions(); let canvas = doc.bounds(); if canvas.width() != w || canvas.height() != h { @@ -629,22 +693,22 @@ fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, wa let row = img.data().len() / (h.max(1) as usize); let band = (BAND_BYTES / row.max(1)).max(1); let stride = channel.surface.channels().max(1); - let mut data = try_buffer(img.pixel_count(), ChannelLayout::Rgba.channels() * sample.bytes())?; + let mut data = try_buffer(img.pixel_count(), out_layout.channels() * sample.bytes())?; let mut y0 = canvas.y0; for rows in img.data().chunks(row.max(1) * band) { let n = (rows.len() / row.max(1)) as u32; let y1 = y0.saturating_add(i32::try_from(n).unwrap_or(i32::MAX)); let alpha = channel.surface.read_region(Rect::new(canvas.x0, y0, canvas.x1, y1)); - let rgb = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); - let mut vals = Vec::with_capacity(rgb.len() / 3 * 4); - for (px, a) in rgb.as_chunks::<3>().0.iter().zip(alpha.chunks_exact(stride)) { + let color = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); + let mut vals = Vec::with_capacity(color.len() / colors.max(1) * (colors + 1)); + for (px, a) in color.chunks_exact(colors.max(1)).zip(alpha.chunks_exact(stride)) { vals.extend_from_slice(px); vals.push(a.first().copied().unwrap_or(1.0)); } - data.extend_from_slice(Image::from_normalized(w, n, ChannelLayout::Rgba, sample, &vals)?.data()); + data.extend_from_slice(Image::from_normalized(w, n, out_layout, sample, &vals)?.data()); y0 = y1; } - Ok(Image::from_raw(w, h, ChannelLayout::Rgba, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) + Ok(Image::from_raw(w, h, out_layout, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) } /// Encodes a flat codec image as `format`: the fidelity warnings, then the codec. The end of diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 444c2d6e5b..09073a015b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -589,7 +589,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -fn unicode_names(data: &[u8]) -> Vec { +pub(crate) fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 8a5810e922..e12bff5126 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -221,10 +221,13 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); - let channels = cc + usize::from(has_alpha); + // Without transparency, the first alpha channel is the TIFF's extra sample, written as an + // unspecified one (Photoshop's alpha channel) and named by the resources above (#5301). + let channel_alpha = !has_alpha && usize::from(file.header.channels) > cc; + let channels = cc + usize::from(has_alpha || channel_alpha); let extra = usize::from(file.header.channels).saturating_sub(channels); if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); + warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one extra channel only)")); } let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); @@ -232,7 +235,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha); + let layout = layout_for(fmt.mode, has_alpha || channel_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -245,6 +248,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), + alpha_is_channel: channel_alpha, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) diff --git a/crates/io/tests/tiff_alpha_channels.rs b/crates/io/tests/tiff_alpha_channels.rs new file mode 100644 index 0000000000..d44efc17c6 --- /dev/null +++ b/crates/io/tests/tiff_alpha_channels.rs @@ -0,0 +1,80 @@ +//! #5301: a TIFF's alpha channel. Photoshop writes a document's alpha channel as an +//! "unspecified" extra sample (ExtraSamples 0); it opens as an alpha channel over an opaque +//! image, not as transparency, and a document's alpha channel is written back that way. + +mod common; +use common::*; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; + +/// An opaque one-layer document of `mode` with the alpha channels `names`. +fn doc_with_channels(mode: ColorMode, names: &[&str]) -> Document { + let (w, h) = (40, 30); + let mut d = Document::new("t", Size::new(w, h), mode, SampleType::U8); + let canvas = Rect::new(0, 0, w as i32, h as i32); + d.layers.push(raster("Background", PixelFormat::new(mode, SampleType::U8, true), canvas, 3, false)); + for (i, name) in names.iter().enumerate() { + d.channels.push(AlphaChannel::new(*name, mask_surface(SampleType::U8, canvas, 0.0, 10 + i as u32))); + } + d +} + +fn channel_values(d: &Document, i: usize) -> Vec { + d.channels[i].surface.read_region(d.bounds()) +} + +fn tiff(doc: &Document, layers: bool) -> ExportResult { + export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") +} + +#[test] +fn a_flat_tiff_keeps_one_alpha_channel_with_its_name() { + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { + let d = doc_with_channels(mode, &["Letters"]); + let r = tiff(&d, false); + assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{mode:?}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.meta.alpha_is_channel, "{mode:?}: written as an unspecified extra sample"); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{mode:?}"); + assert_eq!(back.channels[0].name, "Letters"); + assert_eq!(channel_values(&back, 0), channel_values(&d, 0), "{mode:?}: the channel's values"); + assert_eq!(back.layers.len(), 1); + assert_eq!(back.layers[0].name, "Background", "{mode:?}: opaque, not transparency"); + } +} + +#[test] +fn a_flat_tiff_names_the_alpha_channels_it_leaves_out() { + let d = doc_with_channels(ColorMode::Rgb, &["Kept", "Second", "Third"]); + let r = tiff(&d, false); + assert!(r.warnings.iter().any(|w| w.contains("one alpha channel") && w.contains("\"Second\", \"Third\"")), "{:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Kept"]); +} + +#[test] +fn transparency_stays_the_extra_sample_and_the_channels_are_named_as_left_out() { + let mut d = doc_with_channels(ColorMode::Rgb, &["Letters"]); + d.layers[0] = raster("Layer 0", PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), Rect::new(5, 5, 20, 20), 3, true); + let r = tiff(&d, false); + assert!(r.warnings.iter().any(|w| w.contains("holds the transparency") && w.contains("\"Letters\"")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha() && !img.meta.alpha_is_channel); + let back = import("x.tif", &r.bytes).unwrap().document; + assert!(back.channels.is_empty()); +} + +#[test] +fn a_layered_tiff_keeps_one_alpha_channel_with_its_name() { + let d = doc_with_channels(ColorMode::Rgb, &["Letters"]); + let r = tiff(&d, true); + assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{:?}", r.warnings); + let back = import("x.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Letters"]); + assert_eq!(channel_values(&back, 0), channel_values(&d, 0)); + // Another reader sees an opaque image with an alpha channel, as Photoshop wrote it. + assert!(photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel); +} diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 56e51bf9f9..d1dd39b8c4 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -20,7 +20,9 @@ fn tags(bytes: &[u8]) -> (Option>, Option>) { (r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec)) } -fn roundtrip(doc: &Document) -> Document { +/// `doc` through a layered TIFF, and whether the TIFF kept its first alpha channel (it does +/// when the document has no transparency for the one extra sample, #5301). +fn roundtrip(doc: &Document) -> (Document, bool) { let r = tiff(doc, true); let (resources, layers) = tags(&r.bytes); let layers = layers.expect("layer data"); @@ -34,17 +36,19 @@ fn roundtrip(doc: &Document) -> Document { // The TIFF is a plain image to any other reader: the composite at the document's size. let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not - // kept, and the export says so. + // A TIFF holds one extra sample: the transparency, or else the first alpha channel. The + // other alpha channels (and a Quick Mask) are not kept, and the export says so. + let kept = flat.meta.alpha_is_channel; let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); - import("x.tif", &r.bytes).expect("import").document + assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > usize::from(kept), "{:?}", r.warnings); + (import("x.tif", &r.bytes).expect("import").document, kept) } -/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. -fn expected(doc: &Document) -> Document { +/// `doc` as a layered TIFF round trip returns it: with its first alpha channel when the TIFF +/// kept it (`kept`), without the others or a Quick Mask. +fn expected(doc: &Document, kept: bool) -> Document { let mut d = doc.clone(); - d.channels.clear(); + d.channels.truncate(usize::from(kept)); d.quick_mask = None; d } @@ -54,8 +58,8 @@ macro_rules! rt_case { #[test] fn $name() { let d = gen_doc($mode, $depth, $f); - let back = roundtrip(&d); - assert_docs_eq(&expected(&d), &back); + let (back, kept) = roundtrip(&d); + assert_docs_eq(&expected(&d, kept), &back); } }; } @@ -78,8 +82,8 @@ fn tiff_and_psd_open_as_the_same_document() { let d = gen_doc(mode, depth, Features::ALL); let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap(); let from_psd = import("x.psd", &psd.bytes).unwrap().document; - let from_tif = roundtrip(&d); - assert_docs_eq(&expected(&from_psd), &from_tif); + let (from_tif, kept) = roundtrip(&d); + assert_docs_eq(&expected(&from_psd, kept), &from_tif); // The composite the TIFF carries is what the document looks like. let tif = tiff(&d, true); let flat = import( @@ -261,12 +265,14 @@ fn psdtags_fixtures_open_with_their_layers() { // tifffile writes a Software tag; nothing else is worth a warning. assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps - // everything else; the file is then in the encoder's byte order. + // PSD rules it is an alpha channel, which a re-save keeps when the document has no + // transparency for the one extra sample (#5301). Re-saving keeps everything else; the + // file is then in the encoder's byte order. assert_eq!(d.channels.len(), 1, "{name}"); let out = tiff(d, true); + let kept = photocraft_codecs::decode(&out.bytes).unwrap().meta.alpha_is_channel; let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d), &back); + assert_docs_eq(&expected(d, kept), &back); } } @@ -313,7 +319,7 @@ fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); - assert_docs_eq(&expected(&d), &back); + assert_docs_eq(&expected(&d, photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel), &back); } } } From 4c4138d6eb2b0b878c385812ea5906bc62a08bc8 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Wed, 14 Oct 2026 07:38:36 +0100 Subject: [PATCH 2/4] docs: TIFF alpha channels and compression (#5301) Co-Authored-By: Claude Opus 5.5 --- book/src/formats/raster-formats.md | 2 +- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 2e65d51550..7afb2b9bbf 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. A TIFF holds one extra sample: the transparency, or, in a document without transparency, the first alpha channel. Photoshop writes an alpha channel as an *unspecified* extra sample (ExtraSamples 0); PhotoCraft opens one as a named alpha channel over an opaque image, flat or layered, and writes the first alpha channel back that way. Further alpha channels are not written, and the save names them. Lab documents are saved flat. TIFFs are written uncompressed unless a script asks: `"tiffCompression": "lzw"` or `"zip"` in the save, batch and export commands (`--tiff-compression` on the CLI). Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## WebP diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cb2ca13acd..87e6c99585 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (TIFF alpha channels open and save, LZW/ZIP for scripted saves, #5301; earlier: palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 145/258, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders; one alpha channel opens and saves (an unspecified extra sample, #5301), LZW/ZIP by `tiffCompression` (FILE-215-7: alpha channels beyond the first, or with transparency, not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | A TIFF's alpha channel (Photoshop's unspecified extra sample) opens as a named alpha channel and the first alpha channel saves back; scripted saves take `tiffCompression` (#5301) | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | | 2026-10-12 | minor | DDS textures open, read-only (#1327) | diff --git a/docs/gaps.md b/docs/gaps.md index 25a286aab8..db722d5ed4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (G10: a TIFF's first alpha channel opens and saves, #5301; earlier: G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -221,7 +221,7 @@ what an agent can't finish alone. - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, + Raw; TIFF alpha channels beyond the first (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Estimate:** 120–220 h. @@ -263,6 +263,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G10: TIFF alpha channels narrowed to those beyond the first (#5301) | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/scorecard.md b/docs/scorecard.md index 92f9315ebd..4112aa8648 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -104,7 +104,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 40b242ea53..1a28e7b210 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -52,7 +52,7 @@ id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" status = "partial" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample)" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them" [[item]] id = "FILE-215-8" From 3c24fec507bf4b862c136a229adec30baef29f34 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 21:57:49 +0100 Subject: [PATCH 3/4] fix(tiff): leave alpha channels to #7415, keep the tiffCompression option #7415 writes and reads every TIFF alpha and spot channel with Photoshop's channel resources, so this branch's single-alpha-channel handling would only make the two rework the same decode and encode paths. The codec, io, test and doc changes for it go back to main's versions; the tiffCompression save parameter, the CLI --tiff-compression flag and their test stay, and the format-parity TIFF row names the option. Co-Authored-By: Claude Opus 5.5 --- book/src/formats/raster-formats.md | 2 +- crates/codecs/src/codecs/tiff.rs | 29 ++------ crates/codecs/src/image.rs | 11 --- crates/codecs/tests/tiff_pages.rs | 30 +++----- crates/codecs/tests/tiff_photoshop.rs | 22 ------ crates/io/src/flat.rs | 94 ++++---------------------- crates/io/src/psd_import.rs | 2 +- crates/io/src/tiff_layers.rs | 10 +-- crates/io/tests/tiff_alpha_channels.rs | 80 ---------------------- crates/io/tests/tiff_layers.rs | 38 +++++------ docs/file-format-parity.md | 6 +- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 13 files changed, 56 insertions(+), 272 deletions(-) delete mode 100644 crates/io/tests/tiff_alpha_channels.rs diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 7afb2b9bbf..2e65d51550 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. A TIFF holds one extra sample: the transparency, or, in a document without transparency, the first alpha channel. Photoshop writes an alpha channel as an *unspecified* extra sample (ExtraSamples 0); PhotoCraft opens one as a named alpha channel over an opaque image, flat or layered, and writes the first alpha channel back that way. Further alpha channels are not written, and the save names them. Lab documents are saved flat. TIFFs are written uncompressed unless a script asks: `"tiffCompression": "lzw"` or `"zip"` in the save, batch and export commands (`--tiff-compression` on the CLI). Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## WebP diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 7fceb00656..9528fec9c8 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -249,9 +249,7 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; let mut img = decode_ifd(&file, &dir, limits)?; - let alpha_is_channel = img.meta.alpha_is_channel; img.meta = metadata(&file, &dir, &mut img.icc); - img.meta.alpha_is_channel = alpha_is_channel; let pages = info.page_count(); if pages > 1 { img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); @@ -550,16 +548,8 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 4, _ => 1, }; - // With no extra sample marked as transparency, the first unspecified one (ExtraSamples 0) is - // decoded as the alpha and flagged as an alpha channel: Photoshop writes a document's alpha - // channel that way, and dropping it lost the channel (#5301). - let marked = extra.iter().position(|&e| e == 1 || e == 2); - let paletted = palette.is_some(); - let channel_alpha = marked.is_none() && !paletted && extra.first() == Some(&0); - let alpha_at = marked.or(channel_alpha.then_some(0)).filter(|&a| (color + a as u64) < spp); - // The `tiff` crate returns an RGB, CMYK or Lab alpha only when the first extra sample is marked - // as one, so an alpha channel (unspecified) is read through every sample too. - let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0 || channel_alpha); + let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); + let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -703,16 +693,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); } - // Extra samples past the colour and the one decoded as the alpha are not imported. - let kept = out_layout.channels() as u64; - let skipped = if paletted { 0 } else { spp.saturating_sub(kept) }; - if skipped > 0 { - img.warnings.push(DecodeWarning::ExtraSamplesSkipped { count: u32::try_from(skipped).unwrap_or(u32::MAX) }); - } Ok(img) } @@ -1097,9 +1080,6 @@ struct TagSet<'a> { dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, alpha: bool, - /// The alpha is an alpha channel ([`Metadata::alpha_is_channel`]): written as an unspecified - /// extra sample (0) rather than unassociated alpha (2). - alpha_is_channel: bool, photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, } @@ -1130,8 +1110,8 @@ where } let d = im.encoder(); if tags.alpha { - // 2 = unassociated alpha (transparency); 0 = unspecified, Photoshop's alpha channel. - d.write_tag(Tag::ExtraSamples, &[if tags.alpha_is_channel { 0u16 } else { 2 }][..])?; + // 2 = unassociated alpha + d.write_tag(Tag::ExtraSamples, &[2u16][..])?; } if let Some(icc) = tags.icc { d.write_tag(Tag::IccProfile, Undefined(icc))?; @@ -1253,7 +1233,6 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result 0.0 && d.1 > 0.0) } else { None }, text, alpha: img.layout().has_alpha(), - alpha_is_channel: img.layout().has_alpha() && img.meta.alpha_is_channel, // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 47994d2fa4..5a03e7d283 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -145,11 +145,6 @@ pub struct Metadata { /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. pub photoshop_layers: Option>, - /// The image's alpha is an alpha channel (a saved selection or texture mask), not - /// transparency: TIFF's "unspecified" extra sample (ExtraSamples 0), which Photoshop writes for - /// a document's alpha channel (#5301). Decoders set it; the TIFF encoder writes such an alpha as - /// an unspecified extra sample instead of as transparency. - pub alpha_is_channel: bool, } impl Metadata { @@ -160,7 +155,6 @@ impl Metadata { && self.text.is_empty() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() - && !self.alpha_is_channel } } @@ -187,9 +181,6 @@ pub enum DecodeWarning { /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, /// 255 when undefined). The code values are imported as stored, without a colour transform. DpxEncoding { transfer: u8, colorimetric: u8 }, - /// A TIFF's extra samples beyond the one decoded as the alpha (more alpha channels, or data - /// the file doesn't describe) were not imported. - ExtraSamplesSkipped { count: u32 }, } /// The SMPTE 268M name of a transfer characteristic or colorimetric code. @@ -240,8 +231,6 @@ impl fmt::Display for DecodeWarning { dpx_code_name(*transfer), dpx_code_name(*colorimetric) ), - DecodeWarning::ExtraSamplesSkipped { count: 1 } => write!(f, "one more extra channel in the TIFF was not imported"), - DecodeWarning::ExtraSamplesSkipped { count } => write!(f, "{count} more extra channels in the TIFF were not imported"), } } } diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/tiff_pages.rs index b1f87a94cd..60885e527d 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/tiff_pages.rs @@ -128,16 +128,13 @@ fn planar_strips_at_every_depth() { } } -/// #5301: with no extra sample marked as transparency, the first unspecified one is decoded as -/// the alpha and flagged as an alpha channel (Photoshop's alpha channel). Extra samples beyond -/// the colour and the alpha are dropped, with a warning. #[test] -fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_dropped() { +fn extra_samples_that_are_not_alpha_are_dropped() { let (w, h) = (5usize, 3usize); let t = truth(w * h * 5, 4); let rgb_of = |spp: usize, keep: usize| -> Vec { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() }; for planar in [false, true] { - // RGB + one unspecified extra sample: RGB and an alpha channel. + // RGB + one unspecified extra sample: RGB. let px = &t.u8s[..w * h * 4]; let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]); @@ -145,10 +142,9 @@ fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_droppe d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, true), "planar {planar}"); - assert_eq!(img.data(), &rgb_of(4, 4)[..], "planar {planar}"); - assert_eq!(img.warnings, [], "planar {planar}"); - // RGB + alpha + an unspecified extra sample: RGBA (transparency), the extra dropped. + assert_eq!(img.layout(), ChannelLayout::Rgb); + assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}"); + // RGB + alpha + an unspecified extra sample: RGBA. let px = &t.u8s[..w * h * 5]; let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]); @@ -156,23 +152,20 @@ fn an_unspecified_extra_sample_is_an_alpha_channel_and_further_extras_are_droppe d = d.tag(284, SHORT, &[2]); } let img = decode_ok(&build(false, true, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::Rgba, false), "planar {planar}"); + assert_eq!(img.layout(), ChannelLayout::Rgba); assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}"); - assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "planar {planar}"); } - // Gray + alpha + extra: gray and alpha, the extra dropped; gray + an unspecified extra: gray - // and an alpha channel. + // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. let px = &t.u8s[..w * h * 3]; let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, false)); + assert_eq!(img.layout(), ChannelLayout::GrayA); assert_eq!(img.data(), &rgb_of(3, 2)[..]); - assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }]); let px = &t.u8s[..w * h * 2]; let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); - assert_eq!((img.layout(), img.meta.alpha_is_channel), (ChannelLayout::GrayA, true)); - assert_eq!(img.data(), &rgb_of(2, 2)[..]); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.data(), &rgb_of(2, 1)[..]); } #[test] @@ -200,8 +193,7 @@ fn alpha_after_an_unspecified_extra_sample_is_kept() { let what = format!("photometric {photometric} little {little} big {big} bits {bits} planar {planar}"); assert_eq!((img.layout(), img.sample_type()), (layout, sample), "{what}"); assert_eq!(img.data(), &want[..], "{what}"); - // The unspecified sample before the alpha is the one left out. - assert_eq!(img.warnings, [DecodeWarning::ExtraSamplesSkipped { count: 1 }], "{what}"); + assert_eq!(img.warnings, [], "{what}"); } } } diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/tiff_photoshop.rs index 1daebcc9f4..400b72eb0c 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/tiff_photoshop.rs @@ -172,25 +172,3 @@ fn malformed_tags_read_as_absent_without_panicking() { f[at + 4..at + 8].copy_from_slice(&u32::MAX.to_le_bytes()); assert_eq!(tiff_photoshop_tags(&f).1, None); } - -/// #5301: an alpha flagged as an alpha channel is written as an unspecified extra sample -/// (ExtraSamples 0, as Photoshop writes a document's alpha channel) and decodes back flagged, in -/// every layout with an alpha and both orders of decoding (chunky and the all-samples path); -/// transparency stays unassociated alpha (2). -#[test] -fn an_alpha_channel_round_trips_as_an_unspecified_extra_sample() { - for layout in [ChannelLayout::Rgba, ChannelLayout::GrayA, ChannelLayout::CmykA] { - for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate] { - for channel in [true, false] { - let mut img = synth(13, 11, layout, SampleType::U8, 7, 0.4); - img.meta.alpha_is_channel = channel; - let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap(); - let back = decode(&bytes).unwrap(); - assert_eq!(back.layout(), layout, "{layout:?} {compression:?} channel {channel}"); - assert_eq!(back.meta.alpha_is_channel, channel, "{layout:?} {compression:?}"); - assert_eq!(back.data(), img.data(), "{layout:?} {compression:?} channel {channel}"); - assert!(back.warnings.is_empty(), "{:?}", back.warnings); - } - } - } -} diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 163852b541..773e692729 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -38,7 +38,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { } r.document.file_encoding = file_encoding_as(format, bytes); if format == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { - alpha_to_channel(&mut r.document, "Alpha 1")?; + tga_alpha_to_channel(&mut r.document)?; } // OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless // stated otherwise, and an OpenEXR `Y` is linear luminance): tag them linear sRGB or linear @@ -110,13 +110,11 @@ pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { } } -/// Moves a just-opened single layer's alpha into an alpha channel named `name`, leaving an -/// opaque Background. Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" -/// channel holding the fourth byte, even when the header declares no alpha bits, and keeps the -/// stored colours (#2225; measured on Photoshop 27.11). Layer transparency would hide those -/// colours and lose the channel on the next save, where only an alpha channel becomes the -/// Targa's alpha. A TIFF's unspecified extra sample is Photoshop's alpha channel too (#5301). -fn alpha_to_channel(doc: &mut Document, name: &str) -> Result<(), IoError> { +/// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the +/// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; +/// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel +/// on the next save, where only an alpha channel becomes the Targa's alpha. +fn tga_alpha_to_channel(doc: &mut Document) -> Result<(), IoError> { let canvas = doc.bounds(); let depth = doc.depth; let Some(layer) = doc.layers.first_mut() else { return Ok(()) }; @@ -142,11 +140,11 @@ fn alpha_to_channel(doc: &mut Document, name: &str) -> Result<(), IoError> { layer.name = "Background".into(); layer.locks.transparency = true; layer.locks.position = true; - doc.channels.push(photocraft_doc::AlphaChannel::new(name, alpha)); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", alpha)); Ok(()) } -/// Undoes [`alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel +/// Undoes [`tga_alpha_to_channel`] for a document a Targa just opened as: the "Alpha 1" channel /// becomes the layer's transparency again. For image sequences, which treat a frame's alpha as /// transparency, as video footage does (Photoshop's Interpret Footage). Anything else is left /// alone. @@ -196,27 +194,7 @@ pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option) -> Result } return Ok(r); } - // An unspecified extra sample is an alpha channel, not transparency (#5301): named as the - // TIFF's Photoshop resources name it (Unicode, else Pascal names), else "Alpha 1". - let channel_name = img.meta.alpha_is_channel.then(|| tiff_alpha_name(img.meta.photoshop_resources.as_deref())); - let mut r = image_to_document(name, &img.oriented(orientation)?)?; - if let Some(channel_name) = channel_name { - alpha_to_channel(&mut r.document, &channel_name)?; - } - Ok(r) -} - -/// The first alpha channel's name in a TIFF's Photoshop resources (1045 Unicode names, else -/// 1006 Pascal names), else "Alpha 1". -fn tiff_alpha_name(resources: Option<&[u8]>) -> String { - let resources = resources.and_then(|r| photocraft_psd::tiff::resources_from_bytes(r).ok()).map(|(r, _)| r).unwrap_or_default(); - let unicode = resources.iter().find(|r| r.id == 1045).and_then(|r| crate::psd_import::unicode_names(&r.data).into_iter().next()); - let pascal = || { - let data = &resources.iter().find(|r| r.id == 1006)?.data; - let n = usize::from(*data.first()?); - Some(String::from_utf8_lossy(data.get(1..1 + n)?).into_owned()) - }; - unicode.or_else(pascal).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Alpha 1".into()) + image_to_document(name, &img.oriented(orientation)?) } /// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a @@ -601,39 +579,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::Tiff { - img = tiff_image(doc, img, &mut warnings)?; - } encode_image(&img, format, opts, warnings) } -/// A flat TIFF's image with the document's alpha channel (#5301). Photoshop writes an alpha -/// channel as an unspecified extra sample; this writer holds one extra sample, so the first -/// alpha channel (spot channels don't count) is written when the document has no transparency, -/// named in the Photoshop resources so it opens with its name. With transparency, that is the -/// extra sample and the alpha channels are left out, as before; either way the ones left out are -/// named in a warning. -fn tiff_image(doc: &Document, img: Image, warnings: &mut Vec) -> Result { - let alphas: Vec<&AlphaChannel> = doc.channels.iter().filter(|c| c.spot.is_none()).collect(); - let names = |cs: &[&AlphaChannel]| cs.iter().map(|c| format!("\"{}\"", c.name)).collect::>().join(", "); - let Some((first, rest)) = alphas.split_first() else { return Ok(img) }; - if img.layout().has_alpha() { - warnings.push(format!("the TIFF's one extra channel holds the transparency; alpha channels not saved: {}", names(&alphas))); - return Ok(img); - } - if !rest.is_empty() { - warnings.push(format!("the TIFF holds one alpha channel; not saved: {}", names(rest))); - } - let mut out = append_alpha_channel(doc, &img, first)?; - out.meta.alpha_is_channel = true; - let resources = [ - photocraft_psd::ImageResource::new(1006, crate::psd_export::pascal_names_resource(&[first.name.as_str()])), - photocraft_psd::ImageResource::new(1045, crate::psd_export::unicode_names_resource(&[first.name.as_str()])), - ]; - out.meta.photoshop_resources = photocraft_psd::tiff::resources_to_bytes(&resources).ok(); - Ok(out) -} - /// What Targa Options offers first, as Photoshop does: 32 bits per pixel when the document has an /// alpha channel (spot channels don't count), else 24. pub fn tga_default_bits(doc: &Document) -> TgaBits { @@ -691,18 +639,6 @@ fn tga_with_alpha_channel(doc: &Document, img: Image, channel: &AlphaChannel, wa } else { img }; - append_alpha_channel(doc, &img, channel) -} - -/// `img` (without alpha: RGB, gray or CMYK) with `channel`'s values added as its alpha. -fn append_alpha_channel(doc: &Document, img: &Image, channel: &AlphaChannel) -> Result { - let out_layout = match img.layout() { - ChannelLayout::Rgb => ChannelLayout::Rgba, - ChannelLayout::Gray => ChannelLayout::GrayA, - ChannelLayout::Cmyk => ChannelLayout::CmykA, - other => return Err(IoError::Unsupported(format!("an alpha channel can't be added to a {other:?} image"))), - }; - let colors = img.layout().channels(); let (w, h) = img.dimensions(); let canvas = doc.bounds(); if canvas.width() != w || canvas.height() != h { @@ -712,22 +648,22 @@ fn append_alpha_channel(doc: &Document, img: &Image, channel: &AlphaChannel) -> let row = img.data().len() / (h.max(1) as usize); let band = (BAND_BYTES / row.max(1)).max(1); let stride = channel.surface.channels().max(1); - let mut data = try_buffer(img.pixel_count(), out_layout.channels() * sample.bytes())?; + let mut data = try_buffer(img.pixel_count(), ChannelLayout::Rgba.channels() * sample.bytes())?; let mut y0 = canvas.y0; for rows in img.data().chunks(row.max(1) * band) { let n = (rows.len() / row.max(1)) as u32; let y1 = y0.saturating_add(i32::try_from(n).unwrap_or(i32::MAX)); let alpha = channel.surface.read_region(Rect::new(canvas.x0, y0, canvas.x1, y1)); - let color = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); - let mut vals = Vec::with_capacity(color.len() / colors.max(1) * (colors + 1)); - for (px, a) in color.chunks_exact(colors.max(1)).zip(alpha.chunks_exact(stride)) { + let rgb = Image::from_raw(w, n, img.layout(), sample, rows.to_vec())?.to_normalized(); + let mut vals = Vec::with_capacity(rgb.len() / 3 * 4); + for (px, a) in rgb.as_chunks::<3>().0.iter().zip(alpha.chunks_exact(stride)) { vals.extend_from_slice(px); vals.push(a.first().copied().unwrap_or(1.0)); } - data.extend_from_slice(Image::from_normalized(w, n, out_layout, sample, &vals)?.data()); + data.extend_from_slice(Image::from_normalized(w, n, ChannelLayout::Rgba, sample, &vals)?.data()); y0 = y1; } - Ok(Image::from_raw(w, h, out_layout, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) + Ok(Image::from_raw(w, h, ChannelLayout::Rgba, sample, data)?.with_icc(img.icc.clone()).with_meta(img.meta.clone())) } /// Encodes a flat codec image as `format`: the fidelity warnings, then the codec. The end of diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 8a6449d6fa..c224659c64 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -603,7 +603,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -pub(crate) fn unicode_names(data: &[u8]) -> Vec { +fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 1cf67c5f45..628517b6f7 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -227,13 +227,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); - // Without transparency, the first alpha channel is the TIFF's extra sample, written as an - // unspecified one (Photoshop's alpha channel) and named by the resources above (#5301). - let channel_alpha = !has_alpha && usize::from(file.header.channels) > cc; - let channels = cc + usize::from(has_alpha || channel_alpha); + let channels = cc + usize::from(has_alpha); let extra = usize::from(file.header.channels).saturating_sub(channels); if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one extra channel only)")); + warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); } let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); @@ -241,7 +238,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha || channel_alpha); + let layout = layout_for(fmt.mode, has_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -254,7 +251,6 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), - alpha_is_channel: channel_alpha, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) diff --git a/crates/io/tests/tiff_alpha_channels.rs b/crates/io/tests/tiff_alpha_channels.rs deleted file mode 100644 index d44efc17c6..0000000000 --- a/crates/io/tests/tiff_alpha_channels.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! #5301: a TIFF's alpha channel. Photoshop writes a document's alpha channel as an -//! "unspecified" extra sample (ExtraSamples 0); it opens as an alpha channel over an opaque -//! image, not as transparency, and a document's alpha channel is written back that way. - -mod common; -use common::*; -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{AlphaChannel, Document}; -use photocraft_geom::{Rect, Size}; -use photocraft_io::*; - -/// An opaque one-layer document of `mode` with the alpha channels `names`. -fn doc_with_channels(mode: ColorMode, names: &[&str]) -> Document { - let (w, h) = (40, 30); - let mut d = Document::new("t", Size::new(w, h), mode, SampleType::U8); - let canvas = Rect::new(0, 0, w as i32, h as i32); - d.layers.push(raster("Background", PixelFormat::new(mode, SampleType::U8, true), canvas, 3, false)); - for (i, name) in names.iter().enumerate() { - d.channels.push(AlphaChannel::new(*name, mask_surface(SampleType::U8, canvas, 0.0, 10 + i as u32))); - } - d -} - -fn channel_values(d: &Document, i: usize) -> Vec { - d.channels[i].surface.read_region(d.bounds()) -} - -fn tiff(doc: &Document, layers: bool) -> ExportResult { - export(doc, "x.tif", &ExportOptions { tiff_layers: layers, ..Default::default() }).expect("export") -} - -#[test] -fn a_flat_tiff_keeps_one_alpha_channel_with_its_name() { - for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] { - let d = doc_with_channels(mode, &["Letters"]); - let r = tiff(&d, false); - assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{mode:?}: {:?}", r.warnings); - let img = photocraft_codecs::decode(&r.bytes).unwrap(); - assert!(img.meta.alpha_is_channel, "{mode:?}: written as an unspecified extra sample"); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels.len(), 1, "{mode:?}"); - assert_eq!(back.channels[0].name, "Letters"); - assert_eq!(channel_values(&back, 0), channel_values(&d, 0), "{mode:?}: the channel's values"); - assert_eq!(back.layers.len(), 1); - assert_eq!(back.layers[0].name, "Background", "{mode:?}: opaque, not transparency"); - } -} - -#[test] -fn a_flat_tiff_names_the_alpha_channels_it_leaves_out() { - let d = doc_with_channels(ColorMode::Rgb, &["Kept", "Second", "Third"]); - let r = tiff(&d, false); - assert!(r.warnings.iter().any(|w| w.contains("one alpha channel") && w.contains("\"Second\", \"Third\"")), "{:?}", r.warnings); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Kept"]); -} - -#[test] -fn transparency_stays_the_extra_sample_and_the_channels_are_named_as_left_out() { - let mut d = doc_with_channels(ColorMode::Rgb, &["Letters"]); - d.layers[0] = raster("Layer 0", PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), Rect::new(5, 5, 20, 20), 3, true); - let r = tiff(&d, false); - assert!(r.warnings.iter().any(|w| w.contains("holds the transparency") && w.contains("\"Letters\"")), "{:?}", r.warnings); - let img = photocraft_codecs::decode(&r.bytes).unwrap(); - assert!(img.layout().has_alpha() && !img.meta.alpha_is_channel); - let back = import("x.tif", &r.bytes).unwrap().document; - assert!(back.channels.is_empty()); -} - -#[test] -fn a_layered_tiff_keeps_one_alpha_channel_with_its_name() { - let d = doc_with_channels(ColorMode::Rgb, &["Letters"]); - let r = tiff(&d, true); - assert!(r.warnings.iter().all(|w| !w.contains("alpha channel")), "{:?}", r.warnings); - let back = import("x.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels.iter().map(|c| c.name.as_str()).collect::>(), ["Letters"]); - assert_eq!(channel_values(&back, 0), channel_values(&d, 0)); - // Another reader sees an opaque image with an alpha channel, as Photoshop wrote it. - assert!(photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel); -} diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index d1dd39b8c4..56e51bf9f9 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -20,9 +20,7 @@ fn tags(bytes: &[u8]) -> (Option>, Option>) { (r.map(<[u8]>::to_vec), l.map(<[u8]>::to_vec)) } -/// `doc` through a layered TIFF, and whether the TIFF kept its first alpha channel (it does -/// when the document has no transparency for the one extra sample, #5301). -fn roundtrip(doc: &Document) -> (Document, bool) { +fn roundtrip(doc: &Document) -> Document { let r = tiff(doc, true); let (resources, layers) = tags(&r.bytes); let layers = layers.expect("layer data"); @@ -36,19 +34,17 @@ fn roundtrip(doc: &Document) -> (Document, bool) { // The TIFF is a plain image to any other reader: the composite at the document's size. let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one extra sample: the transparency, or else the first alpha channel. The - // other alpha channels (and a Quick Mask) are not kept, and the export says so. - let kept = flat.meta.alpha_is_channel; + // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not + // kept, and the export says so. let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > usize::from(kept), "{:?}", r.warnings); - (import("x.tif", &r.bytes).expect("import").document, kept) + assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); + import("x.tif", &r.bytes).expect("import").document } -/// `doc` as a layered TIFF round trip returns it: with its first alpha channel when the TIFF -/// kept it (`kept`), without the others or a Quick Mask. -fn expected(doc: &Document, kept: bool) -> Document { +/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. +fn expected(doc: &Document) -> Document { let mut d = doc.clone(); - d.channels.truncate(usize::from(kept)); + d.channels.clear(); d.quick_mask = None; d } @@ -58,8 +54,8 @@ macro_rules! rt_case { #[test] fn $name() { let d = gen_doc($mode, $depth, $f); - let (back, kept) = roundtrip(&d); - assert_docs_eq(&expected(&d, kept), &back); + let back = roundtrip(&d); + assert_docs_eq(&expected(&d), &back); } }; } @@ -82,8 +78,8 @@ fn tiff_and_psd_open_as_the_same_document() { let d = gen_doc(mode, depth, Features::ALL); let psd = export(&d, "x.psd", &ExportOptions::default()).unwrap(); let from_psd = import("x.psd", &psd.bytes).unwrap().document; - let (from_tif, kept) = roundtrip(&d); - assert_docs_eq(&expected(&from_psd, kept), &from_tif); + let from_tif = roundtrip(&d); + assert_docs_eq(&expected(&from_psd), &from_tif); // The composite the TIFF carries is what the document looks like. let tif = tiff(&d, true); let flat = import( @@ -265,14 +261,12 @@ fn psdtags_fixtures_open_with_their_layers() { // tifffile writes a Software tag; nothing else is worth a warning. assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel, which a re-save keeps when the document has no - // transparency for the one extra sample (#5301). Re-saving keeps everything else; the - // file is then in the encoder's byte order. + // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps + // everything else; the file is then in the encoder's byte order. assert_eq!(d.channels.len(), 1, "{name}"); let out = tiff(d, true); - let kept = photocraft_codecs::decode(&out.bytes).unwrap().meta.alpha_is_channel; let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d, kept), &back); + assert_docs_eq(&expected(d), &back); } } @@ -319,7 +313,7 @@ fn advanced_blending_survives_layered_tiff_in_both_byte_orders() { assert_eq!(l.excluded_channels, 0b010, "{depth:?} {order:?}"); assert_eq!(l.blend_if, d.layers[1].blend_if, "{depth:?} {order:?}"); assert_eq!(back.layers[2].advanced.knockout, Knockout::Shallow, "{depth:?} {order:?}"); - assert_docs_eq(&expected(&d, photocraft_codecs::decode(&r.bytes).unwrap().meta.alpha_is_channel), &back); + assert_docs_eq(&expected(&d), &back); } } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63a92f6e81..3c3722a5e6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (TIFF alpha channels open and save, LZW/ZIP for scripted saves, #5301; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (TIFF compression for scripted saves, `tiffCompression`, #5301; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; 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 @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders; one alpha channel opens and saves (an unspecified extra sample, #5301), LZW/ZIP by `tiffCompression` (FILE-215-7: alpha channels beyond the first, or with transparency, not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders; LZW/ZIP for scripted saves by `tiffCompression` (FILE-215-7: extra alpha channels not written) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -142,7 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | A TIFF's alpha channel (Photoshop's unspecified extra sample) opens as a named alpha channel and the first alpha channel saves back; scripted saves take `tiffCompression` (#5301) | +| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 8bdbe86fe4..b320028936 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -106,7 +106,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 0777293cd7..d7e82c9aea 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -52,7 +52,7 @@ id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" status = "partial" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); a TIFF holds one extra sample: the transparency, or else the first alpha channel, written as an unspecified extra sample (Photoshop's alpha channel) and read back as a named alpha channel, flat or layered (#5301); further alpha channels and a Quick Mask are not written, and the save names them" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample)" [[item]] id = "FILE-215-8" From 9a167b2e8804b960e9c0232c54d7b286cf145b04 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sun, 18 Oct 2026 22:19:29 +0100 Subject: [PATCH 4/4] test(engine): a refused tiffCompression leaves the document as it was Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_cmds/tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index eaf1f9ce24..bf6b3387e2 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1315,10 +1315,13 @@ fn save_a_copy_writes_the_tiff_compression_asked_for() { assert_eq!(photocraft_io::import("a.tif", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); } let mut s = session(8, 8, 8); + let state = |s: &Session| s.active().map(|st| (st.revision, st.path.clone(), st.is_dirty())); + let before = state(&s); for bad in [json!("jpeg"), json!("LZW"), json!(5), json!(true)] { let out = join(&dir, "bad.tif"); let r = s.execute("file.saveACopy", json!({"path": out, "tiffCompression": bad})); assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + assert_eq!(state(&s), before, "{bad}: the document is as it was"); } }