mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
429b9fc6a5
commit
be446f89d3
4 files changed
+78
-11
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user