Files
photocraft/crates/engine/src/pixels.rs
T
Dylan M. TaylorandGitHub 1c3db2e5c3 security(engine): bound script, batch, adjustment, import and export work (#8954)
* security(engine): bound script, batch, adjustment, import and export work from file input

Scripts calling scripts share depth and step budgets; batch inside a played action charges the playback tree; destructive adjustments and mask adjustments visit stored tiles when the content bounds are a huge sparse grid; Copy Layers checks group depth; aux flattening and brush import refuse oversized canvases; video footage frames are budgeted, Render Video names frames after the leaf of the document name and reads no linked files for restricted sessions; ATN names are clipped; profile paths are read as regular files only; prefs.set no longer treats colorSettingsX as colorSettings.

* style(engine): rustfmt

* engine: address review - refuse unbounded sparse adjustments, carry batch playback charges, charge played steps to scripts, clip ATN targets, stricter colorSettings paths, cap image-sequence frames
2026-10-23 22:09:29 -04:00

513 lines
24 KiB
Rust

//! Destructive pixel operations on layers (Image → Adjustments, flips, fills).
use photocraft_compose::{Buffer, adjust};
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
use photocraft_geom::{Rect, TILE_SIZE};
use photocraft_raster::{Surface, to_rgba};
/// Composite `layers` inside their visible composite bounds (effects included) and write the result directly into a
/// tiled surface. Large merges used to hold both a full-canvas RGBA buffer and a second converted
/// float buffer at once; banded rendering keeps only one compositor band plus the destination.
pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
let canvas = solo.bounds();
let area = solo
.layers
.iter()
.filter(|layer| layer.visible)
// Composite bounds include layer effects (a drop shadow reaches past the pixels); `None`
// means the layer may draw anywhere.
.map(|layer| photocraft_compose::composite_bounds(layer, canvas).unwrap_or(canvas))
.filter(|bounds| !bounds.is_empty())
.reduce(|a, b| a.union(&b))
.unwrap_or(Rect::EMPTY)
.intersect(&canvas);
if area.is_empty() {
return Surface::new(format);
}
// Also keeps a plain CMYK stack in its ink values (#3778).
photocraft_compose::composite_to_surface(solo, area, format, background)
}
/// [`composite_layers`] without cropping to the canvas: pixels of the layers that lie outside it are kept
/// (merging layers must not lose them, #5067).
pub fn composite_layers_uncropped(solo: &Document, format: photocraft_color::PixelFormat) -> Surface {
let canvas = solo.bounds();
// The canvas grown to every visible layer's content and effects, so no layer is clipped by `composite_bounds`.
let reach = solo
.layers
.iter()
.filter(|layer| layer.visible)
.map(|layer| {
let margin = if photocraft_compose::effects::has_effects(layer) { photocraft_compose::effects::margin(layer) } else { 0 };
let bounds = photocraft_compose::layer_bounds(layer, canvas);
if bounds.is_empty() { bounds } else { bounds.inflate(margin) }
})
.filter(|bounds| !bounds.is_empty())
.fold(canvas, |a, b| a.union(&b));
let area = solo
.layers
.iter()
.filter(|layer| layer.visible)
.map(|layer| photocraft_compose::composite_bounds(layer, reach).unwrap_or(canvas))
.filter(|bounds| !bounds.is_empty())
.reduce(|a, b| a.union(&b))
.unwrap_or(Rect::EMPTY);
if area.is_empty() {
return Surface::new(format);
}
photocraft_compose::composite_to_surface(solo, area, format, None)
}
/// Most bytes of tiles an adjustment may materialize beyond the ones a surface stores: two distant
/// tiles make the content bounds a grid of millions of tiles that hold nothing. Written tiles are
/// copies, so the peak is about twice this.
const MAX_UNSTORED_BYTES: u64 = 1 << 30;
/// Tiles in the grid that covers `r` (one extra per axis for a tile straddling its edge).
fn grid_tiles(r: Rect) -> u64 {
(u64::from(r.width()).div_ceil(TILE_SIZE as u64) + 1) * (u64::from(r.height()).div_ceil(TILE_SIZE as u64) + 1)
}
/// Bytes the tiles of the grid over `r` take beyond the `stored` ones, in format `fmt`.
fn unstored_bytes(r: Rect, stored: u64, fmt: &photocraft_color::PixelFormat) -> u64 {
grid_tiles(r).saturating_sub(stored).saturating_mul((TILE_SIZE as u64).pow(2)).saturating_mul(fmt.bytes_per_pixel() as u64)
}
/// The tile-sized pieces of `r`, each cut to `r`.
fn tile_rects(r: Rect) -> impl Iterator<Item = Rect> {
let t = i64::from(TILE_SIZE);
let cols = i64::from(r.x0).div_euclid(t)..=(i64::from(r.x1) - 1).div_euclid(t);
let rows = i64::from(r.y0).div_euclid(t)..=(i64::from(r.y1) - 1).div_euclid(t);
rows.flat_map(move |cy| cols.clone().map(move |cx| Rect::new((cx * t) as i32, (cy * t) as i32, ((cx + 1) * t) as i32, ((cy + 1) * t) as i32).intersect(&r)))
}
const TOO_SPARSE: &str = "the adjustment would have to fill too large an area around the pixels the layer holds";
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
/// Works tile by tile in parallel, so a large layer needs one tile's buffers per thread rather
/// than four full-layer copies, and the result is the same as one pass over the whole region.
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Result<(), String> {
use rayon::prelude::*;
let r = s.content_bounds();
if r.is_empty() {
return Ok(());
}
let fmt = s.format();
// Two distant tiles make the content bounds a grid of millions of tiles that hold nothing.
// Past a budget a transparent default needs only the stored tiles adjusted (the gaps stay
// transparent); an opaque default would change under the adjustment everywhere in that grid,
// so the adjustment is refused rather than left half applied.
let stored: Vec<_> = s.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&r).is_empty()).collect();
let transparent = fmt.alpha && s.default_pixel().last().is_some_and(|a| *a <= 0.0);
let over = unstored_bytes(r, stored.len() as u64, &fmt) > MAX_UNSTORED_BYTES;
if over && !transparent {
return Err(TOO_SPARSE.into());
}
let taken = if over { stored.into_iter().flat_map(|c| s.take_tiles(c.rect())).collect() } else { s.take_tiles(r) };
let done: Vec<_> = taken
.into_par_iter()
.flat_map_iter(|(tc, tile)| {
// A one-tile surface, so `adjusted` reads and writes this tile only.
let mut one = Surface::new(fmt);
one.put_tiles([(tc, tile)]);
let tr = tc.rect().intersect(&r);
let out = adjusted(&one, tr, adj, selection, mode);
one.write_region(tr, &out);
one.take_tiles(tc.rect())
})
.collect();
s.put_tiles(done);
Ok(())
}
#[cfg(test)]
#[path = "flatten_cmyk_tests.rs"]
mod flatten_cmyk_tests;
#[cfg(test)]
#[path = "pixels_tests.rs"]
mod tests;
/// Applies `adj` to a layer mask through the selection. Untouched mask pixels count (they read as
/// the mask's default value), and without a selection the default changes too, since a mask
/// reaches past the canvas: Invert turns a reveal-all mask into a hide-all one (#780).
pub fn adjust_mask(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) -> Result<(), String> {
let mode = photocraft_color::ColorMode::Grayscale;
if let Some(sel) = selection {
let r = sel.content_bounds();
// Tile by tile, and only where the selection stores something (it is unselected, so
// unchanged, elsewhere): its content bounds can span a grid of mostly nothing.
let sparse = sel.default_pixel().iter().all(|v| *v == 0.0);
let tiles: Vec<Rect> = if sparse {
sel.tiles().map(|(c, _)| c.rect().intersect(&r)).filter(|t| !t.is_empty()).collect()
} else if r.is_empty() {
Vec::new()
} else {
// Unselected pixels count too, so every tile of the span is adjusted, one at a time
// (not one read of the whole span), and the span is bounded by what that writes.
if unstored_bytes(r, 0, &s.format()) > MAX_UNSTORED_BYTES {
return Err(TOO_SPARSE.into());
}
tile_rects(r).collect()
};
for t in &tiles {
let out = adjusted(s, *t, adj, Some(sel), mode);
s.write_region(*t, &out);
}
if !tiles.is_empty() {
s.prune();
}
return Ok(());
}
let fmt = s.format();
let unit = Rect::from_xywh(0, 0, 1, 1);
let mut out = Surface::with_default(fmt, &adjusted(&Surface::with_default(fmt, &s.default_pixel()), unit, adj, None, mode));
// Everything the mask doesn't store reads as its default, which `out` already holds adjusted.
// So only the stored tiles are adjusted, not the (possibly huge) rectangle around them.
let stored: Vec<Rect> = s.tiles().map(|(c, _)| c.rect()).collect();
for r in stored {
out.write_region(r, &adjusted(s, r, adj, None, mode));
}
out.prune();
*s = out;
Ok(())
}
/// `s`'s pixels over `r` (untouched ones included) with `adj` applied through the selection, encoded.
fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) -> Vec<f32> {
let fmt = s.format();
if fmt.mode == mode && matches!(mode, photocraft_color::ColorMode::Lab | photocraft_color::ColorMode::Cmyk) && adjust::stored_channel_adjustment(adj) {
return adjusted_channels(s, r, selection, |ch| adjust::stored_channels(adj, fmt.mode, Some(fmt.sample), ch));
}
if fmt.mode == mode && mode == photocraft_color::ColorMode::Cmyk && adjust::cmy_adjustment(adj) {
return adjusted_cmy(s, r, adj, selection);
}
if let Adjustment::BrightnessContrast { brightness, contrast, legacy } = adj
&& fmt.mode == mode
&& mode == photocraft_color::ColorMode::Cmyk
{
let table = adjust::ink_brightness_contrast_lut(*brightness, *contrast, *legacy);
return adjusted_channels(s, r, selection, |ch| ch.iter_mut().for_each(|v| *v = adjust::ink_through(&table, *v)));
}
let n = fmt.channels();
let raw = s.read_region(r);
let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() };
let orig = buf.clone();
// 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip);
// integer depths keep the unrounded, clipped curves, except that 16-bit Posterize and
// Threshold use the 16-bit rules an adjustment layer measures against Photoshop, so the
// command matches its live preview (an adjustment layer) and Photoshop.
let depth = match (fmt.sample, adj) {
(photocraft_color::SampleType::F32, _) => Some(fmt.sample),
(photocraft_color::SampleType::U16, Adjustment::Posterize { .. } | Adjustment::Threshold { .. }) => Some(fmt.sample),
_ => None,
};
adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode);
mix_through_selection(&fmt, r, &orig.px, &buf.px, selection)
}
/// [`adjusted`] for an adjustment Photoshop applies to a Lab or CMYK layer's own channels: `op`
/// changes one pixel's colour channels, exact on the stored values with no trip through RGB, and
/// the selection mixes those values (alpha untouched).
fn adjusted_channels(s: &Surface, r: Rect, selection: Option<&Surface>, op: impl Fn(&mut [f32])) -> Vec<f32> {
let fmt = s.format();
let (n, colors) = (fmt.channels(), fmt.mode.color_channels());
let mut raw = s.read_region(r);
let w = (r.width() as usize).max(1);
let mut coverage = vec![1.0f32; w];
for (j, row) in raw.chunks_mut(w * n).enumerate() {
if let Some(sel) = selection {
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
}
for (px, &k) in row.chunks_exact_mut(n).zip(&coverage) {
if k <= 0.0 {
continue;
}
let Some(ch) = px.get_mut(..colors) else { continue };
let mut out = [0.0f32; 4];
let Some(out) = out.get_mut(..colors) else { continue };
out.copy_from_slice(ch);
op(out);
for (v, o) in ch.iter_mut().zip(out.iter()) {
*v += (o - *v) * k;
}
}
}
raw
}
/// [`adjusted`] for Hue/Saturation or Color Balance on a CMYK layer (`adjust::apply_cmy`): on its
/// stored C, M and Y, black kept, the whole region at once; the selection mixes the stored inks.
fn adjusted_cmy(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>) -> Vec<f32> {
let fmt = s.format();
let n = fmt.channels();
let mut raw = s.read_region(r);
let mut inks: Vec<[f32; 4]> = raw.chunks_exact(n).map(|p| std::array::from_fn(|c| p.get(c).copied().unwrap_or(0.0))).collect();
adjust::apply_cmy(adj, &mut inks);
let w = (r.width() as usize).max(1);
let mut coverage = vec![1.0f32; w];
for (j, (row, adjusted)) in raw.chunks_mut(w * n).zip(inks.chunks(w)).enumerate() {
if let Some(sel) = selection {
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
}
for ((px, ink), &k) in row.chunks_exact_mut(n).zip(adjusted).zip(&coverage) {
for (v, a) in px.iter_mut().zip(ink) {
*v += (a - *v) * k;
}
}
}
raw
}
/// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's
/// coverage, encoded in `fmt`: what a destructive edit writes back through a selection.
pub(crate) fn mix_through_selection(
fmt: &photocraft_color::PixelFormat,
r: Rect,
orig: &[[f32; 4]],
edited: &[[f32; 4]],
selection: Option<&Surface>,
) -> Vec<f32> {
let w = (r.width() as usize).max(1);
let mut out = Vec::with_capacity(orig.len() * fmt.channels());
// The selection's coverage (channel 0) one row at a time, each of its tiles looked up once
// per row rather than once per pixel.
let mut coverage = vec![1.0f32; w];
for (j, (edited_row, orig_row)) in edited.chunks(w).zip(orig.chunks(w)).enumerate() {
if let Some(sel) = selection {
sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage);
}
for ((a, o), k) in edited_row.iter().zip(orig_row).zip(&coverage) {
let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k);
let mut enc = [0.0f32; 8];
let m = photocraft_raster::from_rgba_into(fmt, mixed, &mut enc);
out.extend_from_slice(&enc[..m]);
}
}
out
}
/// Fill (respecting selection coverage) with a straight RGBA colour.
/// Works tile by tile in parallel on the encoded pixels; a fully selected tile of an opaque fill
/// that may set alpha becomes one shared solid tile.
pub(crate) fn try_fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) -> crate::Result<()> {
let fmt = s.format();
// Coverage 1 of an opaque colour replaces the colour (and the alpha, unless it is kept).
let opaque = color[3] == 1.0 && (!lock_transparency || !fmt.alpha) && selection.is_none_or(|m| m.format().sample != photocraft_color::SampleType::F32);
let mut enc = [0.0f32; 8];
let enc_len = opaque.then(|| photocraft_raster::from_rgba_into(&fmt, [color[0], color[1], color[2], 1.0], &mut enc));
let solid = enc_len.map(|n| s.solid_tile(enc.get(..n).unwrap_or(&[])));
let whole = |full: bool| solid.as_ref().filter(|_| full).map(|t| TileEdit::Put(t.clone()));
edit_tiles(s, area, selection, "filling pixels", whole, |k, px| {
if k <= 0.0 {
return false;
}
if k >= 1.0
&& let Some(m) = enc_len
&& let Some(src) = enc.get(..px.len().min(m))
{
px.copy_from_slice(src);
return true;
}
fill_pixel(&fmt, px, color, k, lock_transparency);
true
})
}
/// One pixel of [`try_fill_surface`]: `color` over `px` (encoded) at coverage `k`.
fn fill_pixel(fmt: &photocraft_color::PixelFormat, px: &mut [f32], color: [f32; 4], k: f32, lock_transparency: bool) {
let d = to_rgba(fmt, px);
let sa = color[3] * k;
let oa = sa + d[3] * (1.0 - sa);
let mut o = [0.0f32; 4];
if oa > 0.0 {
for c in 0..3 {
o[c] = (color[c] * sa + d[c] * d[3] * (1.0 - sa)) / oa;
}
}
o[3] = if lock_transparency { d[3] } else { oa };
let mut enc = [0.0f32; 8];
let m = photocraft_raster::from_rgba_into(fmt, o, &mut enc);
let n = px.len().min(m);
if let (Some(dst), Some(src)) = (px.get_mut(..n), enc.get(..n)) {
dst.copy_from_slice(src);
}
}
/// Clear pixels (make transparent) within the selection.
/// Works tile by tile in parallel on the encoded pixels; a fully selected tile is dropped (it
/// would only hold empty pixels), and tiles the selection leaves alone stay shared with history.
pub(crate) fn try_clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) -> crate::Result<()> {
let fmt = s.format();
let n = fmt.channels();
if !fmt.alpha {
return Ok(());
}
// Cleared pixels become all zeros; when that is also the default pixel, a cleared tile is no tile.
let empty_default = s.default_bytes().iter().all(|&b| b == 0);
let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop);
edit_tiles(s, area, selection, "erasing pixels", whole, |k, px| {
let Some(a) = px.get_mut(n - 1) else { return false };
*a *= 1.0 - k;
// Fully cleared pixels become the empty pixel, so bounds and tile pruning see them as gone.
if *a <= 0.0 {
px.fill(0.0);
}
true
})
}
/// Take the coverage a floating selection lifts out of `s` over `area`: each pixel loses its
/// selection coverage k of alpha, down to none, so a selection made from the layer's own
/// transparency leaves nothing behind (#7655), where [`try_clear_surface`] would leave a ghost.
pub(crate) fn try_lift_coverage(s: &mut Surface, area: Rect, selection: &Surface) -> crate::Result<()> {
let fmt = s.format();
let n = fmt.channels();
if !fmt.alpha {
return Ok(());
}
let empty_default = s.default_bytes().iter().all(|&b| b == 0);
let whole = |full: bool| (full && empty_default).then_some(TileEdit::Drop);
edit_tiles(s, area, Some(selection), "lifting pixels", whole, |k, px| {
if k <= 0.0 {
return false;
}
let Some(a) = px.get_mut(n - 1) else { return false };
*a = (*a - k).max(0.0);
if *a <= 0.0 {
px.fill(0.0);
}
true
})
}
/// A whole-tile shortcut of [`edit_tiles`].
enum TileEdit {
/// Replace the tile with this (shared) one.
Put(std::sync::Arc<photocraft_raster::Tile>),
/// Remove the tile (it reads as the default pixel again).
Drop,
}
/// Edit `s` over `area` tile by tile in parallel. `whole` may handle a tile lying wholly inside
/// `area` and fully selected (`true`) in one go; otherwise `pixel` edits each pixel of the area
/// with its selection coverage (decoded channel values in and out; `false` = left untouched).
/// A tile is copied only when one of its pixels actually changes (tiles are shared with undo
/// states), and pixels are decoded and re-encoded exactly as a whole-region float round trip
/// would, so the result is the same as one pass over `area`.
fn edit_tiles(
s: &mut Surface,
area: Rect,
selection: Option<&Surface>,
purpose: &str,
whole: impl Fn(bool) -> Option<TileEdit> + Sync,
pixel: impl Fn(f32, &mut [f32]) -> bool + Sync,
) -> crate::Result<()> {
use photocraft_raster::Tile;
use rayon::prelude::*;
use std::sync::Arc;
let fmt = s.format();
let (n, bpp) = (fmt.channels(), fmt.bytes_per_pixel());
let oom = |e| crate::EngineError::Other(format!("not enough memory while {purpose} ({e}); the document was not changed"));
let mut coords = crate::allocation::capacity(area.tiles().count(), purpose)?;
coords.extend(area.tiles());
// Untouched pixels read as the default where the surface has no tile.
let blank = if coords.iter().any(|&tc| s.tile(tc).is_none()) { Some(s.try_default_tile().map_err(oom)?) } else { None };
let dp = s.default_bytes();
let surf = &*s;
let edits = coords
.into_par_iter()
.map(|tc| -> crate::Result<Option<(photocraft_geom::TileCoord, Option<Arc<Tile>>)>> {
let tr = tc.rect().intersect(&area);
let mask = selection.map(|m| crate::fill_cmds::Mask::of(m, tc));
let full = tr == tc.rect() && mask.as_ref().is_none_or(|m| m.full(tr));
let current = surf.tile(tc);
match whole(full) {
Some(TileEdit::Put(t)) => return Ok(Some((tc, Some(t)))),
Some(TileEdit::Drop) => return Ok(current.map(|_| (tc, None))),
None => {}
}
let Some(src) = current.map(|t| &**t).or(blank.as_ref()) else { return Ok(None) };
let mut out: Option<Tile> = None;
let (mut px, mut enc) = ([0.0f32; 8], [0u8; 32]);
let (Some(px), Some(enc)) = (px.get_mut(..n), enc.get_mut(..bpp)) else { return Ok(None) };
for y in tr.y0..tr.y1 {
let row = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize;
for x in tr.x0..tr.x1 {
let i = (row + (x - tc.tx * TILE_SIZE) as usize) * bpp;
let Some(old) = src.bytes().get(i..i + bpp) else { continue };
for (c, v) in px.iter_mut().enumerate() {
*v = photocraft_color::read_sample(old, fmt.sample, c);
}
if !pixel(mask.as_ref().map_or(1.0, |m| m.at(x, y)), px) {
continue;
}
photocraft_raster::encode_pixel(&fmt, px, enc);
if enc == old {
continue;
}
if out.is_none() {
out = Some(src.try_clone().map_err(oom)?);
}
if let Some(dst) = out.as_mut().and_then(|t| t.bytes_mut().get_mut(i..i + bpp)) {
dst.copy_from_slice(enc);
}
}
}
Ok(match out {
Some(t) if bpp > 0 && t.bytes().chunks_exact(bpp).all(|p| p == dp) => current.map(|_| (tc, None)),
Some(t) => Some((tc, Some(Arc::new(t)))),
None => None,
})
})
.collect::<crate::Result<Vec<_>>>()?;
for (tc, tile) in edits.into_iter().flatten() {
match tile {
Some(t) => s.put_tiles([(tc, t)]),
None => drop(s.take_tiles(tc.rect())),
}
}
Ok(())
}
/// Remap a surface through a pixel-coordinate mapping (used for flips/rotations).
pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surface {
let r = s.content_bounds();
let mut out = Surface::with_default(s.format(), &s.default_pixel());
let n = s.channels();
let raw = s.read_region(r);
let w = r.width() as usize;
for (i, px) in raw.chunks_exact(n).enumerate() {
let (x, y) = (r.x0 + (i % w) as i32, r.y0 + (i / w) as i32);
let (nx, ny) = map(x, y);
out.write_pixel(nx, ny, px);
}
out
}
/// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down).
pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
let format = doc.pixel_format();
let stack = vec![lower.clone(), upper.clone()];
let mut tmp = Document::new("merge", doc.size, format.mode, format.sample);
// The pair is read (and a CMYK one separated again) through the document's own profile.
tmp.icc_profile = doc.icc_profile.clone();
tmp.layers = stack;
// A Background stays canvas-sized; any other layer keeps the pixels outside the canvas (#5067).
let s = if doc.is_background(lower.id) { composite_layers(&tmp, format, None) } else { composite_layers_uncropped(&tmp, format) };
let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s));
merged.id = lower.id;
merged.blend = lower.blend;
merged.opacity = 1.0;
// Merge Down replaces the lower layer, so keep its editing restrictions.
// In particular, a merged Background must remain opaque and position-locked.
merged.locks = lower.locks;
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
// with the lower layer's colour label and link.
merged.clipped = lower.clipped && upper.clipped;
merged.label = lower.label;
merged.link_group = lower.link_group;
merged
}