fix(layers): identify the Background by structure, never by its English name

Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
  in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
  "Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
  no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
  lock the Background's position like every other place that creates one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-12 05:57:11 -04:00
1 parent 32552a61a1
commit 16dd4b19ad
33 files changed
+213 -115

No files matched your search

+3 -3
View File
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
}
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
/// named "Background" (how PSD import and File › New create it).
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
pub fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
l.has_background_traits()
}
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
+23
View File
@@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
assert!(got[3] <= 1.0);
}
/// The Background is found by its structure, not its English name: a file from a German or
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
/// bottom layer merely called "Background", without the Background's locks, is not one.
#[test]
fn deep_knockout_reveals_a_localized_background() {
for depth in DEPTHS {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let mut d = doc(depth, true);
d.layers[0].name = name.into();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, WHITE, name);
}
let mut d = doc(depth, true);
d.layers[0].locks = Default::default();
let green = solid(&d, "green", full(&d), GREEN);
d.layers.push(green);
d.layers.push(knocker(&d, Knockout::Deep));
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
}
}
+50
View File
@@ -636,6 +636,13 @@ pub struct Layer {
}
impl Layer {
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
/// transparency and position locked. Only the bottom layer of a document with them is the
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
pub fn has_background_traits(&self) -> bool {
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
}
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
Layer {
id: LayerId::fresh(),
@@ -1040,6 +1047,25 @@ impl Document {
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
}
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
/// stores it without a transparency channel and transparency-protected; File › New and opaque
/// flat files create it the same way).
pub fn background(&self) -> Option<&Layer> {
self.layers.first().filter(|l| Layer::has_background_traits(l))
}
/// [`Self::background`], mutably.
pub fn background_mut(&mut self) -> Option<&mut Layer> {
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
}
/// Whether `id` is the document's Background layer ([`Self::background`]).
pub fn is_background(&self, id: LayerId) -> bool {
self.background().is_some_and(|b| b.id == id)
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
@@ -1205,6 +1231,30 @@ mod tests {
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
}
/// The Background is the locked bottom pixel layer, whatever it is called.
#[test]
fn background_is_structural_not_named() {
let mut d = doc();
let id = d.layers[0].id;
assert!(d.is_background(id));
for name in ["Hintergrund", "背景", "Arrière-plan"] {
d.layers[0].name = name.into();
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
}
// A layer called "Background" without the locks, or above the bottom, is not it.
let mut top = Layer::raster("Background", d.pixel_format());
top.locks.transparency = true;
top.locks.position = true;
let top_id = top.id;
d.layers.push(top);
assert!(!d.is_background(top_id));
d.layers[0].locks.position = false;
assert!(d.background().is_none());
d.layers[0].locks.position = true;
d.layers[0].clipped = true;
assert!(d.background_mut().is_none());
}
#[test]
fn max_group_depth_counts_nesting_without_recursing() {
let mut d = doc();
+1
View File
@@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
*active = Some(bg.id);
doc.layers = vec![bg];
+1 -1
View File
@@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"angle": 0.0}));
}
let st = s.active().ok_or(EngineError::NoDocument)?;
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
compound(s, "Straighten", |s| {
+7 -8
View File
@@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::extra_cmds::is_background;
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.new.backgroundFromLayer";
/// The document's Background layer, if it has one (always the bottom layer).
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
doc.background().map(|l| l.id)
}
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
@@ -155,7 +154,7 @@ mod tests {
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
let l = &doc(&s).layers[0];
assert!(is_background(l) && l.name == "Background", "{depth}");
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
assert_eq!(s.active().unwrap().active_layer, Some(a));
// Its pixels stay; transparency takes the background colour (blue).
let red = pixel(&s, 3, 3);
@@ -170,10 +169,10 @@ mod tests {
assert!(s.undo());
assert_eq!(order(&s), [l0, a, b], "{depth}");
let l = doc(&s).layer(a).unwrap();
assert!(!is_background(l) && l.name == "A");
assert!(!doc(&s).is_background(l.id) && l.name == "A");
assert!(s.redo());
assert_eq!(order(&s), [a, l0, b], "{depth}");
assert!(is_background(&doc(&s).layers[0]));
assert!(doc(&s).is_background(doc(&s).layers[0].id));
}
}
@@ -191,7 +190,7 @@ mod tests {
assert_eq!(d.layers[1].id, l0);
assert_eq!(d.layers[2].id, a);
let bg = &d.layers[0];
assert!(is_background(bg) && !bg.visible, "visibility is kept");
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
assert_eq!(bg.opacity, 1.0);
}
@@ -211,7 +210,7 @@ mod tests {
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
// A group or an adjustment layer can't become the Background.
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.groupLayers", json!({})).unwrap();
@@ -220,7 +219,7 @@ mod tests {
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
assert!(!s.is_enabled(CMD));
assert!(s.execute(CMD, json!({})).is_err());
assert!(!doc(&s).layers.iter().any(is_background));
assert!(doc(&s).background().is_none());
}
#[test]
+2 -2
View File
@@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
if locks.pixels || locks.all {
return Err(format!("the layer \"{}\" is locked", l.name));
}
if locks.transparency && !crate::extra_cmds::is_background(l) {
if locks.transparency && !d.doc.is_background(l.id) {
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
}
Ok(())
@@ -217,7 +217,7 @@ mod tests {
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&s), "Background");
let d = s.active().unwrap();
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
assert!(d.doc.is_background(d.active_layer.unwrap()));
}
#[test]
+1 -1
View File
@@ -1416,7 +1416,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
let from_background = doc.is_background(id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if from_background {
+4 -12
View File
@@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
}
}
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
fn float_background(l: &mut Layer) {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
}
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
let locks = l.locks.union(group);
if locks.all || locks.pixels || (position && locks.position) {
@@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
let canvas = doc.bounds();
let selection = doc.selection.clone();
let group = crate::transform_cmds::group_locks(doc, id);
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
if moves {
crate::extra_cmds::background_to_layer(doc, id);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
}
if moves {
float_background(l);
}
check_locks(l, group, moves)?;
let LayerContent::Raster(surf) = &mut l.content else {
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
+7 -6
View File
@@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
let mut copies = scratch_doc(&d.doc);
for id in ids {
let mut l = d.doc.layer(*id)?.clone();
if crate::extra_cmds::is_background(&l) {
if d.doc.is_background(*id) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
@@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
/// transparency, so there the area is filled with the background colour instead (Photoshop).
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let bg = doc.is_background(id);
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
if bg {
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
@@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
}
let lowest = visible[0];
let base = doc.layers[lowest].clone();
let is_background = base.name == "Background" && base.locks.transparency;
let is_background = lowest == 0 && doc.is_background(base.id);
// Composite of visible layers only (hidden ones stay where they are).
let mut solo = doc.clone();
solo.layers.retain(|l| l.visible);
@@ -1363,7 +1364,7 @@ mod tests {
let mut s = fresh();
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
}
@@ -1609,9 +1610,9 @@ mod tests {
let st = s.active().unwrap();
assert_eq!(st.doc.layers.len(), 4);
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
assert!(!crate::extra_cmds::is_background(pasted));
assert!(!st.doc.is_background(pasted.id));
assert_eq!(pasted.locks, Default::default());
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
}
/// #2428: a smart object copied in one document pastes into another as a smart object, in
+5 -16
View File
@@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
}
fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
/// Photoshop converts the Background to a normal layer before erasing to transparency.
fn unlock_background(l: &mut Layer) {
if is_background(l) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
}
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
let locks = doc.effective_locks(l.id);
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
@@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let bg = s.tools.background;
let dmg = s.edit("Magic Eraser", |doc, _| {
let selection = doc.selection.clone();
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
// Photoshop converts the Background to a normal layer before erasing to transparency.
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
crate::extra_cmds::background_to_layer(doc, id);
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
@@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = pixels_locked(&d.doc, l) {
return Err(e);
}
if d.doc.effective_locks(id).transparency && !is_background(l) {
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
}
}
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Background Eraser", |doc, _| {
let selection = doc.selection.clone();
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
unlock_background(l);
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
})?;
+11 -8
View File
@@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
}
pub(crate) fn is_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
}
fn has_background(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
}
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
@@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
}
@@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
fn layer_from_background(s: &mut Session) -> Result<Value> {
let id = s.edit("Layer From Background", |doc, active| {
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
unlock_background(l);
*active = Some(l.id);
Ok(l.id)
@@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
background_to_layer(doc, id);
}
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
unlock_background(l);
}
}
+2 -2
View File
@@ -58,7 +58,7 @@ impl CutParts {
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
let mut rest = if mask { surf.clone() } else { full.clone() };
let mut piece = Surface::new(with_alpha);
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
let fill = !copy && (mask || doc.is_background(layer));
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
if !b.is_empty() {
@@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
let locks = doc.effective_locks(l.id);
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
locks.all || (locks.position && !doc.is_background(l.id))
}
fn float(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
let gid = match content {
Some(id) => {
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
}
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
+3 -6
View File
@@ -298,7 +298,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
// an extending Crop adds is filled with the extension (background) colour.
if extends
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let old = old_canvas.translate(dx, dy);
@@ -379,16 +379,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
// A shrink or no-op resize must not silently convert the Background.
if ext[3] == 0.0
&& (nw > ow as u32 || nh > oh as u32)
&& let Some(bg) = doc.layers.first_mut()
&& crate::extra_cmds::is_background(bg)
&& let Some(bg) = doc.background_mut()
{
crate::extra_cmds::unlock_background(bg);
}
// The locked Background layer is extended with the extension colour.
if ext[3] > 0.0
&& let Some(bg) = doc.layers.first_mut()
&& bg.name == "Background"
&& bg.locks.transparency
&& let Some(bg) = doc.background_mut()
&& let LayerContent::Raster(surf) = &mut bg.content
{
let fmt = surf.format();
+1 -1
View File
@@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let mut l = src_layer.duplicate();
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
if sdoc.is_background(*id) {
l.name = ddoc.next_layer_name("Layer");
l.locks = Default::default();
}
+2 -6
View File
@@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result<Vec<Lay
return Ok(ids);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
}
@@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
}
pub(crate) fn is_locked_background(l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
}
fn select_all_layers(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
if ids.is_empty() {
return Err(EngineError::Other("there are no layers to select".into()));
}
+2 -1
View File
@@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
let canvas = doc.bounds();
let selection = doc.selection.clone();
let locks = doc.effective_locks(id);
let background = doc.is_background(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
@@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).
if float_bg && l.locks.position && l.name == "Background" {
if float_bg && background {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
+2 -1
View File
@@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
let canvas = doc.bounds();
let fmt = doc.pixel_format();
let dpi = doc.resolution_dpi;
let background_id = doc.background().map(|l| l.id);
for l in doc.layers.iter_mut() {
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
let background = Some(l.id) == background_id;
if background && let Some(surf) = l.surface_mut() {
// The Background stays a Background: rotated pixels over the background colour.
let src = surf.content_bounds();
+7 -5
View File
@@ -14,7 +14,7 @@
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -154,12 +154,13 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<
}
/// Maps every layer's pixels and masks through `h` into `clip`.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
/// `background_id` is the id of the document's Background, if it has one.
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32], background_id: Option<LayerId>) -> Result<()> {
for l in layers {
crate::allocation::checkpoint("a perspective crop")?;
let background = crate::extra_cmds::is_background(l);
let background = background_id == Some(l.id);
match &mut l.content {
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?,
LayerContent::Raster(surf) => {
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
*surf = if background {
@@ -188,7 +189,8 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32)
let (w, ht) = size;
let clip = Rect::from_xywh(0, 0, w, ht);
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
let background_id = doc.background().map(|l| l.id);
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
}
+1 -1
View File
@@ -126,7 +126,7 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
let canvas = doc.bounds();
for (path, _, l) in doc.walk().iter().rev() {
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|| crate::layer_multi_cmds::is_locked_background(l)
|| doc.is_background(l.id)
|| doc.effective_locks(l.id).all
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
{
+2 -6
View File
@@ -191,13 +191,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mode = doc.mode;
let effects: Vec<_> = effects.iter().cloned().map(|e| e.in_mode(mode)).collect();
for id in &ids {
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
// Styles can't sit on the Background: Photoshop turns it into a normal layer first.
if l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) {
l.name = "Layer 0".into();
l.locks.transparency = false;
l.locks.position = false;
}
crate::extra_cmds::background_to_layer(doc, *id);
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
if add {
l.effects.items.extend(effects.iter().cloned());
} else {
+1 -1
View File
@@ -50,7 +50,7 @@ fn has_layer_for_slice(s: &Session) -> std::result::Result<(), String> {
if matches!(l.content, LayerContent::Adjustment(_)) {
return Err("adjustment layers have no bounds to slice".into());
}
if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) {
if d.doc.is_background(id) {
return Err("the Background layer can't have a layer-based slice".into());
}
if d.doc.slices.list.iter().any(|s| s.layer == Some(id)) {
+1 -1
View File
@@ -666,7 +666,7 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool {
/// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so
/// the document looks the same before and after.
pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result<Layer> {
let background = l.name == "Background" && l.locks.position;
let background = doc.is_background(l.id);
let mut inner = l.clone();
inner.visible = true;
inner.opacity = 1.0;
+2 -1
View File
@@ -32,9 +32,10 @@ fn contents(s: &Session, doc: &Document, sm: &SmartObject, intent: Intent, bpc:
if src.depth != doc.depth {
crate::image_cmds::convert_layers_depth(&mut src.layers, doc.depth);
}
let background_id = src.background().map(|l| l.id);
let mut layers = std::mem::take(&mut src.layers);
for l in &mut layers {
if crate::extra_cmds::is_background(l) {
if Some(l.id) == background_id {
l.name = "Layer 0".into();
l.locks = Default::default();
}
+12 -15
View File
@@ -201,10 +201,10 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
}
/// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's
/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]).
fn transform_locks(l: &Layer, group: Locks) -> Locks {
/// position lock doesn't count: Photoshop turns it into a normal layer first.
fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks {
let mut own = l.locks;
if own.position && l.name == "Background" {
if background {
own.position = false;
own.transparency = false;
}
@@ -217,29 +217,24 @@ fn transform_locks(l: &Layer, group: Locks) -> Locks {
pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
let Some(l) = doc.layer(id) else { return false };
// An explicit stack rather than recursion: documents can nest deeply.
let mut stack = vec![(l, group_locks(doc, id))];
while let Some((l, group)) = stack.pop() {
let locks = transform_locks(l, group);
let mut stack = vec![(l, group_locks(doc, id), doc.is_background(id))];
while let Some((l, group, background)) = stack.pop() {
let locks = transform_locks(l, group, background);
if locks.position || locks.all {
return true;
}
if let LayerContent::Group(g) = &l.content {
stack.extend(g.children.iter().map(|c| (c, locks)));
stack.extend(g.children.iter().map(|c| (c, locks, false)));
}
}
false
}
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
crate::allocation::checkpoint("transforming pixels")?;
let locks = transform_locks(l, group);
// Photoshop turns the Background into a normal layer before transforming it.
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
let locks = transform_locks(l, group, false);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
@@ -493,6 +488,8 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = group_locks(doc, id);
// Photoshop turns the Background into a normal layer before transforming it.
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
if !h.is_identity() {
+3 -5
View File
@@ -110,12 +110,9 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
Ok(w)
}
/// A Background is turned into a normal layer by the caller first
/// ([`crate::extra_cmds::background_to_layer`]).
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
let locks = l.locks.union(group);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
@@ -270,6 +267,7 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let sel = doc.selection.clone();
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = crate::transform_cmds::group_locks(doc, id);
crate::extra_cmds::background_to_layer(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?;
let snapshot = doc.clone();
+1
View File
@@ -151,6 +151,7 @@ impl Builder {
bg.prune();
let mut l = Layer::new("Background", LayerContent::Raster(bg));
l.locks.transparency = true;
l.locks.position = true;
layers.insert(0, l);
}
self.doc.layers = layers;
+10
View File
@@ -706,6 +706,16 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
// its own profile, as the compositor and the PSD writer read them.
let space = photocraft_compose::cmyk_space(&doc);
doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0));
// Photoshop's Background: the bottom record, a transparency-protected pixel layer stored
// without a transparency channel (-1). It is also position-locked, whatever its
// (localized) name and whether or not the file carries an `lspf` block.
if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut())
&& rec.flags.transparency_protected()
&& !rec.channels.iter().any(|c| c.id == -1)
&& matches!(bottom.content, LayerContent::Raster(_))
{
bottom.locks.position = true;
}
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed)
+39
View File
@@ -0,0 +1,39 @@
//! A PSD's Background is recognised by its structure (the bottom record, transparency-protected,
//! stored without a transparency channel), whatever its name in the Photoshop language that
//! saved the file.
use photocraft_io::psd_to_document;
use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen};
/// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`.
fn file(name: &str, transparency_channel: bool) -> PsdFile {
let mut f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
let bottom = f.layers_mut().first_mut().unwrap();
bottom.name = name.bytes().filter(u8::is_ascii).collect();
bottom.blocks.retain(|b| &b.key != b"luni" && &b.key != b"lspf");
bottom.blocks.push(TaggedBlock::unicode_name(name));
bottom.flags.set_transparency_protected(true);
if !transparency_channel {
bottom.channels.retain(|c| c.id != -1);
}
f
}
#[test]
fn a_localized_background_is_the_background() {
for name in ["Hintergrund", "背景", "Arrière-plan"] {
let (d, _) = psd_to_document(&file(name, false));
let bg = d.background().unwrap_or_else(|| panic!("{name}: no Background"));
assert_eq!(bg.name, name);
assert!(bg.locks.transparency && bg.locks.position, "{name}");
assert_eq!(bg.id, d.layers[0].id);
}
}
#[test]
fn a_protected_layer_with_transparency_is_not_the_background() {
// Lock Transparent Pixels on an ordinary bottom layer: it keeps its transparency channel.
let (d, _) = psd_to_document(&file("Background", true));
assert!(d.layers[0].locks.transparency);
assert!(d.background().is_none());
}
+1 -1
View File
@@ -2085,7 +2085,7 @@ const STUDIO_TAB_GAP: f32 = 4.0;
fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String {
let Some(st) = app.session.documents().get(i) else { return String::new() };
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id));
let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some());
let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() };
let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default();
+4 -3
View File
@@ -3,15 +3,16 @@
//! Rulers & Grids toggles. Every edit dispatches an engine or view command, so the panel stays thin.
use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
use photocraft_doc::{ColorMode, Document, Layer, LayerContent, LayerId, SampleType};
use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{PhotocraftApp, icons, widgets};
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack.
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack,
/// whatever its (localized) name ([`Document::background`]).
pub fn is_background(doc: &Document, l: &Layer) -> bool {
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id)
doc.is_background(l.id)
}
/// Does the Properties panel show the Document view? (No layer selected, or the Background layer.)
+1 -1
View File
@@ -2685,7 +2685,7 @@ fn layer_row(
let name_color = if l.visible { t.text } else { t.text_faint };
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
let italic = !t.pro && l.name == "Background" && l.locks.transparency;
let italic = !t.pro && doc.is_background(l.id);
// Photoshop rows show only the name; the kind sub-label is a Studio-theme addition.
let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_));
let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| {