Transparent PNG, TIFF and other flat images open as Layer 0, not Background (#1913)

A flat image with transparency (alpha channel or tRNS) opens as a normal, unlocked Layer 0, as in
Photoshop; opaque images still open as the locked Background. A lone Layer 0 saves back to PNG natively.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
LloydNicholsonandClaude Opus 5.5 authored and GitHub committed 2026-10-09 15:43:14 -04:00
1 parent 429b9fc6a5
commit be446f89d3
4 files changed
+78 -11

No files matched your search

+2 -1
View File
@@ -136,7 +136,8 @@ fn info_prints_layer_tree_json() {
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["width"], 10);
assert_eq!(v["height"], 6);
assert_eq!(v["layers"][0]["name"], "Background");
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
assert_eq!(v["layers"][0]["name"], "Layer 0");
assert!(v.get("history").is_none());
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
assert_eq!(out.trim().lines().count(), 1);
+12 -5
View File
@@ -51,7 +51,8 @@ pub fn import_tiff_page(name: &str, bytes: &[u8], page: Option<usize>) -> Result
image_to_document(name, &img.oriented(orientation)?)
}
/// A decoded flat image as a single-layer document.
/// A decoded flat image as a single-layer document: a locked "Background" when it is opaque, a
/// normal "Layer 0" when it has transparency.
pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult, IoError> {
// What the decoder noticed (frames or pages left out, data ending early) comes first.
let mut warnings: Vec<String> = img.warnings.iter().map(ToString::to_string).collect();
@@ -87,12 +88,18 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
}
}
s.prune();
let mut bg = Layer::new("Background", LayerContent::Raster(s));
if !img.layout().has_alpha() {
// As in Photoshop: an opaque image opens as the locked Background layer, one with
// transparency (an alpha channel, or a tRNS chunk the decoder expands to one) as a normal
// layer named "Layer 0", since a Background can't hold transparency.
let layer = if img.layout().has_alpha() {
Layer::new("Layer 0", LayerContent::Raster(s))
} else {
let mut bg = Layer::new("Background", LayerContent::Raster(s));
bg.locks.transparency = true;
bg.locks.position = true;
}
doc.layers.push(bg);
bg
};
doc.layers.push(layer);
doc.icc_profile = img.icc.clone().map(Arc::new);
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
doc.metadata.xmp = img.meta.xmp.clone();
+3 -2
View File
@@ -18,8 +18,9 @@
//! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively
//! with no source save path, what isn't imported listed in the warnings; a file
//! whose native data can't be read opens as its embedded preview; see `affinity`.
//! * Every other format goes through `photocraft-codecs` as a single
//! "Background" layer (depth and Gray/RGB/CMYK model preserved).
//! * Every other format goes through `photocraft-codecs` as a single layer
//! (depth and Gray/RGB/CMYK model preserved): a locked "Background" when the
//! image is opaque, a normal "Layer 0" when it has transparency.
//!
//! Exports to PSD render the merged composite with
//! `photocraft_compose::flatten`; flat exports report what is lost.
+61 -3
View File
@@ -209,14 +209,72 @@ fn extension_forms() {
}
}
/// Asserts how a flat file opened: the locked Background when opaque, a normal "Layer 0" (as in
/// Photoshop) when it has transparency.
fn assert_opens_as(what: &str, d: &photocraft_doc::Document, transparent: bool) {
let [l] = &d.layers[..] else { panic!("{what}: {} layers", d.layers.len()) };
if transparent {
assert_eq!(l.name, "Layer 0", "{what}");
assert!(!l.locks.transparency && !l.locks.position, "{what}: unlocked");
} else {
assert_eq!(l.name, "Background", "{what}");
assert!(l.locks.transparency && l.locks.position, "{what}: locked");
}
}
#[test]
fn background_lock_follows_alpha() {
let r = export(&single(ColorMode::Rgb, SampleType::U8, false), "png", &ExportOptions::default()).unwrap();
let d = import("a.png", &r.bytes).unwrap().document;
assert!(d.layers[0].locks.transparency);
assert_opens_as("opaque PNG", &import("a.png", &r.bytes).unwrap().document, false);
let r = export(&single(ColorMode::Rgb, SampleType::U8, true), "png", &ExportOptions::default()).unwrap();
assert_opens_as("RGBA PNG", &import("a.png", &r.bytes).unwrap().document, true);
}
#[test]
fn transparent_flat_images_open_as_layer_0() {
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType as CS};
let (w, h) = (6u32, 4u32);
let n = (w * h) as usize;
let enc = |layout: ChannelLayout, sample: CS, format: Format| {
let ch = layout.channels();
let img = match sample {
CS::U8 => Image::from_u8(w, h, layout, (0..n * ch).map(|i| if (i + 1) % ch == 0 && i < ch * 5 { 0 } else { 200 }).collect()),
_ => Image::from_u16(w, h, layout, &(0..n * ch).map(|i| if (i + 1) % ch == 0 && i < ch * 5 { 0 } else { 50_000 }).collect::<Vec<_>>()),
}
.unwrap();
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
};
let cases: Vec<(&str, Vec<u8>, bool)> = vec![
("a.png", enc(ChannelLayout::Rgba, CS::U8, Format::Png), true),
("a16.png", enc(ChannelLayout::Rgba, CS::U16, Format::Png), true),
("ga.png", enc(ChannelLayout::GrayA, CS::U8, Format::Png), true),
("a.tif", enc(ChannelLayout::Rgba, CS::U8, Format::Tiff), true),
("ga.tif", enc(ChannelLayout::GrayA, CS::U16, Format::Tiff), true),
("pal.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], Some(0)).unwrap(), true),
("pal_opaque.png", photocraft_codecs::encode_png_indexed(w, h, &[0, 1].repeat(n / 2), &[[255, 0, 0], [0, 0, 255]], None).unwrap(), false),
("rgb.png", enc(ChannelLayout::Rgb, CS::U8, Format::Png), false),
("g.png", enc(ChannelLayout::Gray, CS::U16, Format::Png), false),
("rgb.tif", enc(ChannelLayout::Rgb, CS::U8, Format::Tiff), false),
("rgb.jpg", enc(ChannelLayout::Rgb, CS::U8, Format::Jpeg), false),
];
for (name, bytes, transparent) in cases {
let d = import(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}")).document;
assert_opens_as(name, &d, transparent);
// Photoshop numbers the next new layer after "Layer 0" as "Layer 1".
assert_eq!(d.next_layer_name("Layer"), "Layer 1", "{name}");
}
}
#[test]
fn layer_0_saves_back_to_png_and_reopens_as_layer_0() {
let r = export(&single(ColorMode::Rgb, SampleType::U8, true), "png", &ExportOptions::default()).unwrap();
let d = import("a.png", &r.bytes).unwrap().document;
assert!(!d.layers[0].locks.transparency);
assert_opens_as("first open", &d, true);
let again = export(&d, "b.png", &ExportOptions::default()).unwrap();
assert!(again.warnings.iter().all(|w| !w.contains("flattened")), "a lone Layer 0 is written natively: {:?}", again.warnings);
let back = import("b.png", &again.bytes).unwrap().document;
assert_opens_as("reopened", &back, true);
pixels_eq(&d, &back, 0.0);
}
#[test]