mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
32552a61a1
commit
16dd4b19ad
33 files changed
+213
-115
No files matched your search
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
})?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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![]));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in new issue
Block a user