Files
photocraft/crates/io/src/psd_import.rs
T
2026-10-24 00:51:37 +02:00

1318 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! PSD/PSB → [`Document`].
use std::sync::Arc;
use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{
AlphaChannel, Document, Effects, FillCache, Group, Layer, LayerContent, LayerMask, ShapeLayer, SmartContentsId, SmartObject, SmartSource, TextLayer,
};
use photocraft_geom::{Rect, Size, TILE_SIZE};
use photocraft_psd::layer::{CHANNEL_REAL_USER_MASK, CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK};
use photocraft_psd::resources::ids;
use photocraft_psd::tagged::BlockData;
use photocraft_psd::{ColorMode as PsdMode, LayerNode, LayerRecord, PsdFile, TaggedBlock};
use photocraft_raster::Surface;
use crate::adjust_map::{self, ADJUSTMENT_KEYS};
use crate::blocks;
use crate::pixels::{interleave, max_sample, sample_for_depth, zero_sample};
/// Resources that are mapped to document fields or regenerated on export.
pub(crate) const MAPPED_RESOURCES: [u16; 19] = [
ids::GLOBAL_ANGLE,
ids::GLOBAL_ALTITUDE,
ids::RESOLUTION_INFO,
ids::ICC_PROFILE,
ids::XMP,
ids::EXIF,
ids::VERSION_INFO,
ids::THUMBNAIL,
ids::THUMBNAIL_PS4,
ids::LAYER_STATE,
ids::LAYER_GROUP_INFO,
1032, // grid and guides
1045, // unicode alpha names
1006, // pascal alpha names
1069, // layer selection ids
1072, // layer group(s) enabled id
crate::channel_map::DISPLAY_INFO,
crate::channel_map::DISPLAY_INFO_OLD,
crate::channel_map::QUICK_MASK_INFO,
];
/// Blocks regenerated from document fields on export; not kept in
/// `Layer::psd_blocks`.
pub(crate) const REGENERATED: [&[u8; 4]; 9] = [b"luni", b"lyid", b"lsct", b"lsdk", b"iOpa", b"lspf", b"lclr", b"lfx2", b"lfxs"];
pub(crate) struct Ctx<'a> {
pub file: &'a PsdFile,
pub fmt: PixelFormat,
pub mask_fmt: PixelFormat,
pub cc: usize,
pub cmyk: bool,
pub warnings: Vec<String>,
/// Decoding encountered damaged PSD data; interactive opens ask whether to continue.
pub damaged: bool,
/// Document resolution (type sizes are converted to points with it).
pub dpi: f32,
/// The parsed `Txt2` block (type settings EngineData lacks, e.g. optical kerning).
pub txt2: Option<photocraft_text::engine_data::Value>,
/// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id).
pub filter_effects: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
/// Placed layers naming the same source share editable contents, not their placement.
pub smart_contents: std::collections::HashMap<String, SmartContentsId>,
/// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file).
pub plld_sizes: std::collections::HashMap<String, Option<(f64, f64)>>,
/// Cancellation and progress for background opens (checked per layer record).
pub ctl: photocraft_raster::Interrupt<'a>,
/// Layer records decoded so far, out of `total` (progress).
pub done: usize,
pub total: usize,
}
fn doc_mode(m: PsdMode) -> Option<ColorMode> {
Some(match m {
PsdMode::Bitmap => ColorMode::Bitmap,
PsdMode::Grayscale => ColorMode::Grayscale,
PsdMode::Indexed => ColorMode::Indexed,
PsdMode::Rgb => ColorMode::Rgb,
PsdMode::Cmyk => ColorMode::Cmyk,
PsdMode::Multichannel => ColorMode::Multichannel,
PsdMode::Duotone => ColorMode::Duotone,
PsdMode::Lab => ColorMode::Lab,
PsdMode::Unknown(_) => return None,
})
}
// Real-mask metadata without a -3 channel still selects the synthetic -2 mask.
fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
rec.channel(CHANNEL_REAL_USER_MASK)?;
rec.layer_mask()?.real
}
/// The (density, feather) a shape layer's mask parameters give its path, which is its vector
/// mask: (1, 0) without them.
fn shape_mask_params(rec: &LayerRecord) -> (f32, f32) {
let p = rec.layer_mask().and_then(|m| m.parameters);
let density = p.and_then(|p| p.vector_density).map_or(1.0, |d| f32::from(d) / 255.0);
let feather = p.and_then(|p| p.vector_feather).filter(|f| f.is_finite()).map_or(0.0, |f| (f as f32).clamp(0.0, 1000.0));
(density, feather)
}
/// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer
/// with a vector mask: when it fills with a pattern, which the shape renderer can't draw (`.1`
/// true when its vector mask is also soft: a density below 100 % or a feather). Other soft
/// shapes stay shapes with that density and feather.
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<(photocraft_doc::Fill, bool)> {
if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() {
return None;
}
let k = fill_key?;
let fill = rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))?;
let p = rec.layer_mask().and_then(|m| m.parameters);
let soft = p.is_some_and(|p| p.vector_density.is_some_and(|d| d < 255) || p.vector_feather.is_some_and(|f| f > 0.0));
// A pattern-filled shape can't be rasterized on its own (the compositor resolves the
// document's patterns for fill layers), and its stored pixels may be the unclipped fill
// (our own export writes fill layers so, #1907): it is a pattern fill layer seen through
// its vector mask.
let pattern = matches!(fill, photocraft_doc::Fill::Pattern { .. });
pattern.then_some((fill, soft))
}
/// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record.
/// An empty `id` leaves the contents unavailable (the stored pixels stay the rendering).
#[derive(Default)]
struct Placement {
id: String,
transform: photocraft_geom::Affine,
perspective: Option<[f64; 9]>,
warp: Option<photocraft_geom::warp::Warp>,
}
impl Ctx<'_> {
fn warn(&mut self, s: impl Into<String>) {
self.warnings.push(s.into());
}
/// Records a PSD decoding failure and marks the import as potentially incomplete.
/// Unlike compatibility warnings, this requires interactive confirmation.
fn warn_unreadable_data(&mut self, s: impl Into<String>) {
self.damaged = true;
self.warn(s);
}
/// Source id, transform, (Distort/Perspective) projective placement and warp of a placed
/// layer that has only a `PlLd` block, as Photoshop CS2 and older write it (#2465). `PlLd`
/// stores the placed corners but not the source size, which comes from the embedded file.
fn plld_placement(&mut self, data: &[u8], name: &str) -> Placement {
let Some((id, p)) = blocks::parse_plld(data) else {
self.warn(format!("layer \"{name}\": its placed-layer data (PlLd) could not be read; the smart object's contents are unavailable"));
return Placement::default();
};
let warp = match blocks::parse_plld_warp(data) {
blocks::PlldWarp::None => None,
blocks::PlldWarp::Warp(w) => Some(w),
blocks::PlldWarp::Unreadable => {
// Re-rendering from the source would unwarp the layer: keep it as saved (its
// warped pixels), as before PlLd was read.
self.warn(format!(
"layer \"{name}\": its smart object warp (PlLd) could not be read; the layer keeps its warped pixels but its contents are unavailable"
));
return Placement::default();
}
};
let (file, ctl) = (self.file, self.ctl);
let size = *self
.plld_sizes
.entry(id.clone())
.or_insert_with(|| crate::linked::source_size_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.data.as_slice())), &id, &ctl));
// Without the source (missing or unreadable), it is taken as placed at 100 %.
let (transform, perspective) = blocks::plld_transform(&p, size, warp.as_ref());
Placement { id, transform, perspective, warp }
}
/// Decodes a PSD layer channel, marking the document damaged if its data is unreadable.
fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option<Vec<u8>> {
rec.channel(id)?;
match rec.decode_channel(id, self.file.header.depth, self.file.header.version) {
Ok(p) => Some(p),
Err(e) => {
self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded ({e}); treated as empty"));
None
}
}
}
/// [`Self::channel_plane`] without the warnings sink, so a layer's channels decode
/// concurrently (their RLE rows are the bulk of the open time on full-canvas layers).
fn channel_plane_decoded(&self, rec: &LayerRecord, id: i16) -> Option<Vec<u8>> {
rec.channel(id)?;
rec.decode_channel(id, self.file.header.depth, self.file.header.version).ok()
}
/// Builds a raster surface from the layer channels, leaving the surface empty if
/// any present channel fails to decode rather than inventing replacement pixels.
fn record_surface(&mut self, rec: &LayerRecord, name: &str) -> Surface {
let r = rec.rect;
if r.is_empty() || r.size().is_err() {
return Surface::new(self.fmt);
}
let (w, h) = r.size().unwrap_or((0, 0));
let s = self.fmt.sample;
let ids: Vec<i16> = (0..self.cc as i16).chain([CHANNEL_TRANSPARENCY]).collect();
let planes: Vec<Option<Vec<u8>>> = if cfg!(target_arch = "wasm32") {
ids.iter().map(|&id| self.channel_plane(rec, id, name)).collect()
} else {
// The channels decode independently; their RLE rows are the bulk of the open
// time for full-canvas layers, so they run side by side and the warnings land
// in the same order afterwards.
// par_map reuses the crate's worker pool (one OS thread per channel per layer
// would spawn thousands on a file with many layers).
let decoded: Vec<Option<Vec<u8>>> = crate::pixels::par_map(ids.to_vec(), |id| self.channel_plane_decoded(rec, id));
let mut planes: Vec<Option<Vec<u8>>> = Vec::with_capacity(decoded.len());
for (&id, p) in ids.iter().zip(decoded) {
if p.is_none() && rec.channel(id).is_some() {
self.warn_unreadable_data(format!("layer \"{name}\": channel {id} could not be decoded; treated as empty"));
}
planes.push(p);
}
planes
};
// A channel that is in the file but could not be decoded leaves the layer empty, as the
// warning says. Filling in for it would paint an opaque black layer over the document.
if ids.iter().zip(planes.iter()).any(|(&id, p)| p.is_none() && rec.channel(id).is_some()) {
return Surface::new(self.fmt);
}
// Only decoded channel data justifies a buffer: the decoders bound their output by the
// bytes in the file, but a bare rectangle can declare 300000² pixels in a few bytes (#755).
if planes.iter().all(Option::is_none) {
self.warn_unreadable_data(format!("layer \"{name}\": no channel data for its {w}x{h} bounds; treated as empty"));
return Surface::new(self.fmt);
}
let refs: Vec<Option<&[u8]>> = planes.iter().map(|p| p.as_deref()).collect();
let mut fill: Vec<Vec<u8>> = vec![zero_sample(s); self.cc];
fill.push(max_sample(s));
let mut invert = vec![self.cmyk; self.cc];
invert.push(false);
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true);
}
let mut surf = Surface::from_interleaved(self.fmt, Rect::new(r.left, r.top, r.right, r.bottom), &bytes);
surf.prune();
surf
}
/// Restores the selected user mask from its channel and metadata, recording an
/// integrity failure if the mask channel cannot be decoded.
fn record_mask(&mut self, rec: &LayerRecord, name: &str) -> Option<LayerMask> {
let m = rec.layer_mask()?;
let (id, rect, default, flags) = match selected_real_mask(rec) {
Some(real) => (CHANNEL_REAL_USER_MASK, real.rect, real.background, real.flags),
_ => {
rec.channel(CHANNEL_USER_MASK)?;
(CHANNEL_USER_MASK, m.rect, m.default_color, m.flags)
}
};
let mut surface = Surface::with_default(self.mask_fmt, &[f32::from(default) / 255.0]);
if !rect.is_empty()
&& let Ok((w, h)) = rect.size()
&& let Some(plane) = self.channel_plane(rec, id, name)
{
let s = self.mask_fmt.sample;
let bytes = interleave(&[Some(&plane)], &[zero_sample(s)], w * h, s, &[false]);
surface.write_interleaved(Rect::new(rect.left, rect.top, rect.right, rect.bottom), &bytes);
surface.prune();
}
let params = m.parameters;
Some(LayerMask {
surface,
enabled: flags & 2 == 0,
linked: flags & 1 == 0,
density: params.and_then(|p| p.user_density).map_or(1.0, |d| f32::from(d) / 255.0),
feather: params.and_then(|p| p.user_feather).map_or(0.0, |f| f as f32),
})
}
/// The filter mask of the smart object whose `placed` id is `placed`, from the `FEid` cache:
/// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white
/// mask is no mask.
fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option<LayerMask> {
let item = self.filter_effects.iter().find(|i| i.id == placed)?;
match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
Ok(m) => m,
Err(e) => {
self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored"));
None
}
}
}
fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option<photocraft_psd::BlendMode>) {
l.visible = rec.is_visible();
l.opacity = f32::from(rec.opacity) / 255.0;
l.fill_opacity = f32::from(rec.fill_opacity()) / 255.0;
l.clipped = rec.clipping != 0;
let key = blend_override.unwrap_or(rec.blend_mode).key();
l.blend = match BlendMode::from_psd_key(key) {
Some(b) => b,
None => {
self.warn(format!("layer \"{}\": unknown blend mode {:?}; using Normal", l.name, String::from_utf8_lossy(&key)));
BlendMode::Normal
}
};
if let Some(Ok(BlockData::Protection(v))) = rec.block(b"lspf").and_then(TaggedBlock::parsed) {
l.locks = blocks::locks_from_lspf(v);
}
if rec.flags.transparency_protected() {
l.locks.transparency = true;
}
if let Some(Ok(BlockData::SheetColor(v))) = rec.block(b"lclr").and_then(TaggedBlock::parsed) {
l.label = blocks::label_from_index(v);
}
// `lmfx` (multiple instances per kind) supersedes `lfx2` when present;
// it stays in `psd_blocks`, `lfx2` in `Effects::psd_raw`.
// Groups store their effects under `lfxs` (same layout as `lfx2`).
let single = rec.block(b"lfx2").or_else(|| rec.block(b"lfxs"));
if let Some(fx) = rec.block(b"lmfx").or(single) {
let (enabled, items) = crate::effects_map::parse_lfx2(&fx.data).unwrap_or_else(|| (blocks::effects_enabled(&fx.data), Vec::new()));
l.effects = Effects { enabled, items, psd_raw: single.map(|b| Arc::new(b.data.clone())), reference: None };
} else if let Some(fx) = rec.block(b"lrFX")
&& !rec.section_type().is_folder()
&& let Some((enabled, items)) = crate::effects_map::parse_lrfx(&fx.data)
{
// Legacy effects only on non-group layers (Photoshop ignores
// them on groups, see psd-tools effects/shape-fx.psd).
l.effects = Effects { enabled, items, psd_raw: None, reference: None };
}
// Effects reference point (`fxrp`: two f64, x then y); also left in `psd_blocks`, where
// export overwrites it from the field.
if let Some(b) = rec.block(b"fxrp")
&& let (Some(x), Some(y)) = (b.data.get(..8), b.data.get(8..16))
{
let f = |s: &[u8]| f64::from_be_bytes(s.try_into().unwrap_or([0; 8]));
l.effects.reference = Some((f(x), f(y)));
}
// Advanced Blending channel restrictions (`brst`: the u32 ids of channels left out);
// also kept in `psd_blocks`, where export rewrites it from the field.
if let Some(b) = rec.block(b"brst") {
l.excluded_channels = crate::blocks::parse_brst(&b.data);
}
// Advanced Blending (`knko`, `infx`, `clbl`, `tsly`, `lmgm`, `vmgm`); the blocks stay in
// `psd_blocks`, where export rewrites them from the field in place.
l.advanced = crate::blocks::advanced_from_blocks(|k| rec.block(k).map(|b| b.data.as_slice()));
// Blend If lives in the layer record's blending ranges.
l.blend_if = crate::blocks::blend_if_from_ranges(&rec.blending_ranges);
l.psd_id = rec.layer_id();
// The layer's XMP packet (`shmd` › `cust` › `layerXMP`) becomes the field; the rest of
// `shmd` stays in `psd_blocks`, where export puts the packet back.
if let Some(i) = l.psd_blocks.iter().position(|(k, _)| k == b"shmd")
&& let Some((xmp, rest)) = photocraft_psd::metadata::take_layer_xmp(&l.psd_blocks[i].1)
{
l.xmp = Some(Arc::from(xmp));
match rest {
Some(data) => l.psd_blocks[i].1 = Arc::new(data),
None => {
l.psd_blocks.remove(i);
}
}
}
let name = l.name.clone();
l.mask = self.record_mask(rec, &name);
}
fn layer_from_record(&mut self, rec: &LayerRecord) -> Layer {
let name = rec.name();
let fill_key = [b"SoCo", b"GdFl", b"PtFl"].into_iter().find(|k| rec.block(k).is_some());
let vector_key = [b"vsms", b"vmsk"].into_iter().find(|k| rec.block(k).is_some());
let adj_key = ADJUSTMENT_KEYS.into_iter().find(|k| rec.block(k).is_some());
// `SoLE` (a linked smart object) before `PlLd`: a `PlLd` alone is Photoshop CS2's record.
let smart_key = [b"SoLd", b"SoLE", b"PlLd"].into_iter().find(|k| rec.block(k).is_some());
let blocks = preserved_blocks(rec);
// `psd_raw` shares the Arc of the matching `psd_blocks` entry.
let principal = |k: &[u8; 4]| blocks.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.clone());
let mut fill_cache = None;
let content = if let Some(k) = adj_key {
let data = rec.block(k).map(|b| b.data.clone()).unwrap_or_default();
let cged = rec.block(b"CgEd").map(|b| &b.data[..]);
let adj = adjust_map::parse(
k,
&data,
cged,
match self.fmt.mode {
ColorMode::Rgb => adjust_map::Channels::Rgb,
ColorMode::Grayscale => adjust_map::Channels::Gray,
ColorMode::Cmyk => adjust_map::Channels::Cmyk,
ColorMode::Lab => adjust_map::Channels::Lab,
_ => adjust_map::Channels::Other,
},
);
// Kept verbatim for saving, but it renders as nothing: say so rather than open the
// layer silently without its effect (#1763).
if matches!(adj, photocraft_doc::Adjustment::Unsupported { .. }) {
// Photoshop 2026's Light adjustment shares the Brightness/Contrast key (#6542).
let light = k == b"brit" && adjust_map::is_extended_brit(cged);
let why = if light {
" (Light adjustments, with Exposure, Highlights, Shadows, Whites and Blacks, aren't supported yet)".to_string()
} else {
adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default()
};
self.warn(format!(
"layer \"{name}\": its {} settings could not be read{why}; the layer is kept as saved but has no effect",
if light { "Light" } else { adjust_map::label(k) }
));
}
LayerContent::Adjustment(adj)
} else if let Some(block) = rec.block(b"TySh") {
// Only canonical type data with a safe transform can become a text layer. A retained
// foreign-order block must never be handed to the big-endian reader (#4758). Canonical
// data the typed reader can't decode stays a type layer as before (usually without
// text), so its TySh is saved back verbatim instead of being dropped with the raster.
let parsed = (!block.is_foreign_order())
.then(|| {
photocraft_text::psd::text_layer_from_tysh(&block.data, self.dpi).or_else(|| {
let (text, transform) = blocks::parse_tysh(&block.data).unwrap_or_default();
photocraft_text::psd::transform_is_safe(&transform).then(|| TextLayer { text, transform, ..Default::default() })
})
})
.flatten();
if let Some(mut t) = parsed {
if let Some(txt2) = &self.txt2 {
photocraft_text::psd::apply_txt2(&mut t, &block.data, txt2);
photocraft_text::psd::apply_txt2_frame(&mut t, &block.data, txt2);
}
// Photoshop's pixels stay until edited. Writers without type pixels are
// rendered by text_import::prepare instead of retaining an empty cache.
let cache = self.record_surface(rec, &name);
let drawn = !cache.content_bounds().is_empty() || photocraft_text::psd::engine_text_is_blank(&block.data);
t.cache = drawn.then_some(cache);
t.psd_raw = principal(b"TySh");
LayerContent::Text(t)
} else {
self.warn(format!("layer \"{name}\": type data in another byte order or with unsafe type geometry; kept as pixels"));
LayerContent::Raster(self.record_surface(rec, &name))
}
} else if let Some(k) = smart_key {
let Placement { id, transform, perspective, warp } = match rec.block(k) {
Some(b) if k == b"PlLd" => self.plld_placement(&b.data, &name),
Some(b) => {
let (id, transform) = blocks::parse_smart(k, &b.data);
// Distort / Perspective: the fourth corner (the affine `transform` drops it).
let perspective = blocks::parse_smart_perspective(k, &b.data);
Placement { id, transform, perspective, warp: blocks::parse_placed_warp(k, &b.data) }
}
None => Placement::default(),
};
let contents_id =
if id.is_empty() { SmartContentsId::fresh() } else { *self.smart_contents.entry(id.clone()).or_insert_with(SmartContentsId::fresh) };
// Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`).
let placed = rec.block(b"SoLd").or_else(|| rec.block(b"SoLE")).and_then(|b| crate::smart_map::parse_sold(&b.data));
let stack = placed.as_ref().and_then(|p| p.stack.clone()).unwrap_or_default();
let filter_mask = match &placed {
Some(p) if !stack.filters.is_empty() => self.filter_mask(&p.placed, &stack, &name),
_ => None,
};
// Photoshop stores the filtered pixels without the filter mask and mixes the unfiltered
// ones (`FEid`) back in through it; our smart objects hold the mixed result.
let stored = self.record_surface(rec, &name);
let shown = stack.enabled && stack.filters.iter().any(|f| f.visible);
let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) {
(Some(p), Some(m)) => {
let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
match unfiltered {
Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m),
None => stored,
}
}
_ => stored,
};
LayerContent::Smart(SmartObject {
contents_id,
source: SmartSource::Linked { path: id },
transform,
smart_filters: stack.filters,
cache: Some(cache),
psd_raw: principal(k),
filters_enabled: stack.enabled,
filter_mask,
warp,
stack_mode: None,
perspective,
})
} else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
// A pattern-filled shape is a pattern fill layer seen through its vector mask (soft
// or not). Unless that mask is soft, the stored pixels stay its rendering (a soft
// mask's pixels lack the fill beyond the path).
if !soft && matches!(f, photocraft_doc::Fill::Pattern { .. }) && !rec.rect.is_empty() {
fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) });
}
LayerContent::Fill(f)
} else if (vector_key.is_some() && (fill_key.is_some() || rec.block(b"vstk").is_some())) || rec.block(b"vscg").is_some() {
let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)));
let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() };
let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone());
crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi);
(sh.density, sh.feather) = shape_mask_params(rec);
// No stored pixels (32-bit documents, files saved without layer pixels): render the
// shape from its path and fill, as Photoshop does when it opens the file. A soft
// shape's stored pixels hold the shape alone, without the fill its density and feather
// show beyond the path: render it too.
let soft = sh.has_soft_mask() && sh.fill.is_some();
if soft || ((rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted)) {
let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32);
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas));
}
LayerContent::Shape(sh)
} else if let Some(k) = fill_key {
match rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)) {
Some(f) => {
// Keep Photoshop's rendering of the fill so it composites exactly.
if !rec.rect.is_empty() {
fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) });
}
LayerContent::Fill(f)
}
None => {
self.warn(format!("layer \"{name}\": unreadable {} fill; imported as pixels", String::from_utf8_lossy(k)));
LayerContent::Raster(self.record_surface(rec, &name))
}
}
} else if let Some(k) = unknown_adjustment_key(rec) {
// An adjustment type no mapper knows: kept verbatim like an unreadable one, not
// opened as an empty pixel layer without a word (#6575).
let key = String::from_utf8_lossy(&k).into_owned();
self.warn(format!(
"layer \"{name}\": its adjustment settings could not be read (unknown adjustment type \"{key}\"); the layer is kept as saved but has no effect"
));
let raw = rec.block(&k).map(|b| b.data.as_slice()).unwrap_or_default().into();
LayerContent::Adjustment(photocraft_doc::Adjustment::Unsupported { psd_key: key, raw })
} else {
LayerContent::Raster(self.record_surface(rec, &name))
};
let rendered = matches!(content, LayerContent::Shape(_) | LayerContent::Text(_) | LayerContent::Smart(_));
let mut l = Layer::new(name, content);
l.psd_blocks = blocks;
l.fill_cache = fill_cache;
self.apply_common(&mut l, rec, None);
// Mask flag bit 3: the user mask was rendered from vector data. For
// shape/text/smart layers the cached pixels already include that
// coverage, so applying it again would double-mask.
if rendered && selected_real_mask(rec).is_none() && rec.layer_mask().is_some_and(|m| m.flags & 8 != 0) {
l.mask = None;
}
if !matches!(l.content, LayerContent::Shape(_)) {
self.apply_vector_mask(&mut l, rec);
}
l
}
/// Typed vector mask from `vsms`/`vmsk` (non-shape layers). A user mask that Photoshop
/// rendered from the vector data (flag bit 3, no separate real mask) is dropped: the
/// compositor rasterizes the vector mask itself.
fn apply_vector_mask(&mut self, l: &mut Layer, rec: &LayerRecord) {
let Some(b) = rec.block(b"vsms").or_else(|| rec.block(b"vmsk")) else { return };
let Some(mut vm) = crate::vector_map::vector_mask_from_block(&b.data, self.file.header.width, self.file.header.height) else { return };
if let Some(m) = rec.layer_mask() {
if let Some(p) = m.parameters {
vm.density = p.vector_density.map_or(1.0, |d| f32::from(d) / 255.0);
vm.feather = p.vector_feather.map_or(0.0, |f| f as f32);
}
if m.flags & 8 != 0 && selected_real_mask(rec).is_none() {
l.mask = None;
}
}
l.vector_mask = Some(vm);
}
fn build(&mut self, nodes: &[LayerNode], depth: usize) -> Vec<Layer> {
let layers = self.file.layers();
let ctl = self.ctl;
nodes
.iter()
// A cancelled open stops decoding; the caller discards the partial document.
.take_while(|_| !ctl.cancelled())
.map(|n| match n {
LayerNode::Layer { index } => {
let l = self.layer_from_record(&layers[*index]);
self.done += 1;
// Layers are 10–95 % of an open (the merged image and channels the rest).
self.ctl.progress(0.1 + 0.85 * self.done as f32 / self.total.max(1) as f32);
l
}
LayerNode::Group { index, children, .. } => {
let rec = &layers[*index];
// The recursion here (and in every later consumer of the tree) is bounded by
// the document model's nesting cap; a deeper subtree is not imported.
let children = if depth >= photocraft_doc::MAX_GROUP_DEPTH {
self.warn(format!(
"group `{}` nests deeper than {} groups; its contents were not imported",
rec.name(),
photocraft_doc::MAX_GROUP_DEPTH
));
Vec::new()
} else {
self.build(children, depth + 1)
};
let sd = rec.section_divider();
let expanded = sd.is_none_or(|s| s.kind != photocraft_psd::SectionType::ClosedFolder);
let artboard = crate::comps_map::ARTBOARD_KEYS.iter().find_map(|k| rec.block(k)).and_then(|b| crate::comps_map::parse_artboard(&b.data));
let mut l = Layer::new(rec.name(), LayerContent::Group(Group { children, expanded, artboard }));
l.psd_blocks = preserved_blocks(rec);
self.apply_common(&mut l, rec, sd.and_then(|s| s.blend_mode));
self.apply_vector_mask(&mut l, rec);
l
}
})
.collect()
}
}
/// Sets `Layer::link_group` from resource 1026's per-record ids (`nodes` and `layers` correspond).
fn apply_link_groups(nodes: &[LayerNode], layers: &mut [Layer], ids: &[u16]) {
fn rec(nodes: &[LayerNode], layers: &mut [Layer], ids: &[u16], depth: usize) {
for (n, l) in nodes.iter().zip(layers.iter_mut()) {
let index = match n {
LayerNode::Layer { index } => *index,
LayerNode::Group { index, children, .. } => {
// Matches the importer's nesting cap: deeper children were not imported.
if depth < photocraft_doc::MAX_GROUP_DEPTH
&& let LayerContent::Group(g) = &mut l.content
{
rec(children, &mut g.children, ids, depth + 1);
}
*index
}
};
l.link_group = ids.get(index).copied().filter(|&g| g != 0).map(u64::from);
}
}
rec(nodes, layers, ids, 0);
}
/// Rejects a file whose groups nest deeper than the document model's cap, with the actionable
/// limit error. Nesting past the cap could never be saved (.pcraft refuses it).
pub(crate) fn check_group_depth(file: &PsdFile) -> Result<(), crate::IoError> {
if group_depth(file) > photocraft_doc::MAX_GROUP_DEPTH {
return Err(crate::IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH)));
}
Ok(())
}
/// Deepest group nesting of the file's layer records (a layer inside this many groups is the
/// deepest), computed like `layer_tree`'s stack — iteratively, so no file can make it recurse.
pub(crate) fn group_depth(file: &PsdFile) -> usize {
use photocraft_psd::tagged::SectionType;
let (mut open, mut depth) = (0usize, 0usize);
for rec in file.layers() {
match rec.section_type() {
SectionType::BoundingDivider => {
open += 1;
depth = depth.max(open);
}
SectionType::OpenFolder | SectionType::ClosedFolder => open = open.saturating_sub(1),
_ => {}
}
}
depth
}
/// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in
/// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects,
/// masks, metadata, and the fill, type, placed and shape content read before.
const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [
b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp",
b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd",
b"FMsk", b"PxSc", b"PxSD", b"cinf", b"ffxi", b"shpa", b"anFX", b"SoCo", b"GdFl", b"PtFl", b"TySh", b"tySh", b"SoLd", b"SoLE", b"PlLd", b"plLd",
];
/// The key of an adjustment layer whose type no mapper knows (#6575), e.g. Photoshop 4's
/// `hue ` or a type newer than PhotoCraft. Photoshop writes every adjustment layer without
/// pixels and with "pixel data irrelevant" set (every one in the corpus; pixel, type and smart
/// layers never have it). Such a record that is not a group and carries exactly one block that
/// is neither a known layer-information key nor a known adjustment is that adjustment. More than
/// one candidate is a guess, so the layer stays an (empty) pixel layer, as before; either way no
/// pixels can be lost and the block is saved back verbatim.
fn unknown_adjustment_key(rec: &LayerRecord) -> Option<[u8; 4]> {
let marked = rec.flags.bit4_useful() && rec.flags.pixel_data_irrelevant();
if !marked || !rec.rect.is_empty() || rec.block(b"lsct").is_some() || rec.block(b"lsdk").is_some() {
return None;
}
let mut unknown = rec.blocks.iter().filter(|b| !LAYER_INFO_KEYS.contains(&&b.key) && !ADJUSTMENT_KEYS.contains(&&b.key));
let only = unknown.next()?;
// A key that isn't four printable ASCII bytes names no adjustment (nor saves back as one).
(unknown.next().is_none() && only.key.iter().all(|c| c.is_ascii_graphic() || *c == b' ')).then_some(only.key)
}
fn preserved_blocks(rec: &LayerRecord) -> Vec<([u8; 4], Arc<Vec<u8>>)> {
rec.blocks.iter().filter(|b| !REGENERATED.contains(&&b.key)).map(|b| (b.key, Arc::new(b.data.clone()))).collect()
}
fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec<u8> {
interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false])
}
fn unicode_names(data: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut at = 0;
while at + 4 <= data.len() {
let n = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize;
at += 4;
// Checked: `n * 2` overflows a 32-bit `usize` for a hostile length.
let Some(end) = n.checked_mul(2).and_then(|len| at.checked_add(len)) else { break };
let Some(b) = data.get(at..end) else { break };
let units: Vec<u16> = b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
out.push(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string());
at = end;
}
out
}
/// Plane `i` of a decoded merged image whose planes are `plane` bytes each, or `None` when it is
/// short or the offsets overflow.
fn merged_plane(all: &[u8], i: usize, plane: usize) -> Option<&[u8]> {
all.get(i.checked_mul(plane)?..i.checked_add(1)?.checked_mul(plane)?)
}
/// The merged image of a file the layered path does not open (Bitmap, Indexed, Duotone; 1-bit
/// files) as RGBA8. What `composite_rgba8` can't read (Multichannel) shows its first channels as
/// RGB. An output over `cap` bytes is refused, never allocated (#5200).
fn flat_composite(
file: &PsdFile,
merged: Option<&Result<Vec<u8>, photocraft_psd::PsdError>>,
cap: u64,
) -> Result<photocraft_psd::RgbaImage, photocraft_psd::PsdError> {
use photocraft_psd::PsdError;
match file.composite_rgba8() {
Ok(img) => return Ok(img),
// Over the output budget: the fallback below would make the same allocation.
Err(e @ PsdError::LimitExceeded(_)) => return Err(e),
Err(_) => {}
}
let all = match merged {
Some(Ok(all)) => all,
Some(Err(e)) => return Err(e.clone()),
None => return Err(PsdError::Invalid("no merged image".into())),
};
let h = &file.header;
let (w, hh) = (h.width as usize, h.height as usize);
let len = w
.checked_mul(hh)
.and_then(|n| n.checked_mul(4))
.filter(|&len| len as u64 <= cap)
.ok_or(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES"))?;
let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?;
let mut data = Vec::new();
data.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for RGBA output"))?;
data.resize(len, 255u8);
for c in 0..3.min(usize::from(h.channels)) {
let bytes = merged_plane(all, c, plane).ok_or_else(|| PsdError::Invalid("channel size mismatch".into()))?;
let p = photocraft_psd::pixels::plane_to_u8(bytes, h.depth, w, hh)?;
for (px, v) in data.as_chunks_mut::<4>().0.iter_mut().zip(&p) {
px[c] = *v;
}
}
Ok(photocraft_psd::RgbaImage { left: 0, top: 0, width: h.width, height: h.height, data })
}
/// Writes RGBA8 `data` covering `canvas` into `s`, a band of tile rows at a time (no full-size
/// float copy of the image).
fn write_rgba8(s: &mut Surface, canvas: Rect, data: &[u8]) {
let fmt = s.format();
let row = (canvas.width() as usize).saturating_mul(4);
let mut vals = Vec::new();
let mut y = canvas.y0;
while y < canvas.y1 {
let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1));
let start = ((y - canvas.y0) as usize).saturating_mul(row);
let Some(rows) = data.get(start..start.saturating_add((band.height() as usize).saturating_mul(row))) else { break };
vals.clear();
for p in rows.as_chunks::<4>().0 {
vals.extend(photocraft_raster::from_rgba(&fmt, p.map(|x| f32::from(x) / 255.0)));
}
s.write_region(band, &vals);
y = band.y1;
}
}
pub(crate) fn parse_guides(data: &[u8], doc: &mut Document) {
let rd = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
let Some(count) = rd(12) else { return };
for i in 0..count as usize {
let at = 16 + i * 5;
let (Some(loc), Some(&dir)) = (rd(at), data.get(at + 4)) else { break };
let pos = f64::from(loc as i32) / 32.0;
if dir == 1 {
doc.guides.horizontal.push(pos);
} else {
doc.guides.vertical.push(pos);
}
}
}
/// Converts a parsed PSD into a document. Never fails: problems become warnings.
/// A flattened file's merged image as its layer: like Photoshop, an opaque one is the locked
/// Background (Photoshop 27.11 opens psd-tools' flat Bitmap and Indexed files that way), so it is
/// written back as one.
fn flat_background(s: Surface) -> Layer {
// A band of tile rows at a time (no full-size float copy), stopping at the first
// transparent pixel.
let opaque = {
let r = s.content_bounds();
let n = s.channels();
let mut vals = Vec::new();
let mut y = r.y0;
let mut opaque = !r.is_empty();
while opaque && y < r.y1 {
let band = Rect::new(r.x0, y, r.x1, y.saturating_add(TILE_SIZE).min(r.y1));
s.read_region_into(band, &mut vals);
opaque = vals.chunks_exact(n).all(|p| p.last().is_some_and(|a| *a >= 1.0));
y = band.y1;
}
opaque
};
let mut l = Layer::new("Background", LayerContent::Raster(s));
l.locks.transparency = opaque;
l.locks.position = opaque;
l
}
/// Photoshop opens a file's bottom layer as its Background when that layer's record has no
/// transparency (`-1`) channel, whatever its name and lock flags say: it is then locked and shown
/// as "Background" (measured on Photoshop 27.11 with files from psd-tools and ag-psd whose bottom
/// layer is called "Фон", "bg" or "Layer 1" and carries no locks).
fn mark_background(file: &PsdFile, layers: &mut [Layer]) {
let Some(rec) = file.layers().first() else { return };
let Some(bottom) = layers.first_mut() else { return };
let opaque_record = !rec.channels.is_empty() && rec.channels.iter().all(|c| c.id >= 0);
if opaque_record && matches!(bottom.content, LayerContent::Raster(_)) && !bottom.clipped {
bottom.locks.transparency = true;
bottom.locks.position = true;
}
}
/// Converts a parsed PSD into a document, preserving best-effort recovery warnings.
pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
// Never cancelled, so always `Some`; the fallback is unreachable.
psd_to_document_with(file, &photocraft_raster::Interrupt::NONE)
.unwrap_or_else(|| (Document::new("Untitled", Size::new(1, 1), ColorMode::Rgb, SampleType::U8), Vec::new()))
}
/// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports
/// progress. `None` when cancelled.
pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec<String>)> {
psd_to_document_decoded(file, ctl, None)
}
/// Whether the import reads the merged composite: for a flattened or multichannel file (it *is*
/// the image), a file without layers, or extra alpha/spot channels behind the colour ones.
pub(crate) fn merged_needed(file: &PsdFile) -> bool {
let h = &file.header;
let mode = doc_mode(h.color_mode).unwrap_or(ColorMode::Rgb);
// A layered mode is its own pixel format, so these are the document's colour channels.
let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1;
!layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha())
}
/// A damaged merged preview need not prevent opening independently stored layers (#6152).
/// Other modes are imported flattened, so their merged image cannot be discarded.
pub(crate) fn can_recover_layers(file: &PsdFile) -> bool {
matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty()
}
/// [`psd_to_document_with`] with the merged image already decoded (by
/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again.
pub(crate) fn psd_to_document_decoded(
file: &PsdFile,
ctl: &photocraft_raster::Interrupt,
decoded: Option<photocraft_psd::Result<Vec<u8>>>,
) -> Option<(Document, Vec<String>)> {
psd_to_document_decoded_integrity(file, ctl, decoded).map(|(document, warnings, _)| (document, warnings))
}
/// Also reports whether unrecoverable channel, layer or merged-image data was discarded.
/// Interactive opens use this to ask whether to continue. Both ordinary imports and
/// the low-level conversion API retain their existing best-effort behaviour.
pub(crate) fn psd_to_document_decoded_integrity(
file: &PsdFile,
ctl: &photocraft_raster::Interrupt,
decoded: Option<photocraft_psd::Result<Vec<u8>>>,
) -> Option<(Document, Vec<String>, bool)> {
let h = &file.header;
let mut warnings = Vec::new();
let mode = doc_mode(h.color_mode).unwrap_or_else(|| {
warnings.push(format!("unknown color mode {}; importing as RGB", h.color_mode.as_u16()));
ColorMode::Rgb
});
let layered = matches!(mode, ColorMode::Grayscale | ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Lab) && h.depth != 1;
let multichannel = mode == ColorMode::Multichannel && h.depth != 1;
let depth = if layered || multichannel { sample_for_depth(h.depth) } else { SampleType::U8 };
let mut doc = Document::new("Untitled", Size::new(h.width, h.height), mode, depth);
// Resources.
for r in &file.resources {
match r.id {
ids::RESOLUTION_INFO => {
if let Ok(ri) = photocraft_psd::ResolutionInfo::from_bytes(&r.data) {
// Fixed 16.16 hRes/vRes are pixels per inch. The unit shorts only
// say how to show that number (1 = px/in, 2 = px/cm).
let (x, y) = (ri.h_res(), ri.v_res());
doc.resolution_dpi = x as f32;
warnings.extend(crate::unequal_resolution_warning(x, y));
}
}
ids::ICC_PROFILE => doc.icc_profile = Some(Arc::new(r.data.clone())),
ids::XMP => doc.metadata.xmp = Some(String::from_utf8_lossy(&r.data).into()),
ids::EXIF => doc.metadata.exif = Some(Arc::new(r.data.clone())),
1032 => parse_guides(&r.data, &mut doc),
ids::GLOBAL_ANGLE | ids::GLOBAL_ALTITUDE => {
if let Some(b) = r.data.get(..4) {
let v = i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as f32;
if r.id == ids::GLOBAL_ANGLE {
doc.global_light.angle = v;
} else {
doc.global_light.altitude = v;
}
}
}
id if crate::vector_map::SAVED_PATHS.contains(&id) => match crate::vector_map::path_from_resource(&r.data, h.width, h.height) {
Some(path) => doc.paths.push(photocraft_doc::NamedPath {
name: String::from_utf8_lossy(&r.name).into_owned(),
path,
psd_raw: Some(Arc::new(r.data.clone())),
}),
None => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))),
},
crate::vector_map::WORK_PATH => {
doc.work_path = crate::vector_map::path_from_resource(&r.data, h.width, h.height);
}
crate::vector_map::CLIPPING_PATH => {
// Kept raw (layout beyond the name is not modelled); the name is exposed.
let n = usize::from(r.data.first().copied().unwrap_or(0));
let name = r.data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 });
doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone())));
}
id if MAPPED_RESOURCES.contains(&id) => {}
id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))),
}
}
crate::guides_map::apply_colors(&mut doc);
for b in &file.global_blocks {
doc.metadata.psd_global_blocks.push((b.signature, b.key, Arc::new(b.data.clone())));
}
// A 16/32-bit file whose layer block can't be parsed would otherwise open as a flattened
// file without a word (#2700). The raw block is kept, but a PSD save writes the document's
// own layers in its place (see `psd_export`).
let mut damaged = false;
if let Some((b, e)) = file.unreadable_layer_block() {
damaged = true;
warnings.push(format!(
"the layers ({} block) could not be read ({e}); only the merged image was opened. Saving as PSD replaces the unreadable layer data",
b.key_str()
));
}
doc.patterns = crate::pattern_map::from_global_blocks(&doc);
// Notes (`Anno`) and the measurement scale (resource 1074); raw data stays for verbatim export.
doc.notes = crate::annotations_map::notes_from_blocks(&doc);
if crate::annotations_map::anno_unreadable(&doc) {
warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into());
}
if let Some(scale) = crate::annotations_map::raw_scale(&doc) {
doc.measurement.scale = scale;
}
let fmt = doc.pixel_format();
let cc = fmt.mode.color_channels();
let mut cx = Ctx {
file,
fmt,
mask_fmt: PixelFormat::new(ColorMode::Grayscale, depth, false),
cc,
cmyk: fmt.mode == ColorMode::Cmyk,
warnings,
damaged,
dpi: doc.resolution_dpi,
txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)),
filter_effects: Vec::new(),
smart_contents: Default::default(),
plld_sizes: Default::default(),
ctl: *ctl,
done: 0,
total: file.layers().len(),
};
for b in file.global_blocks.iter().filter(|b| matches!(&b.key, b"FEid" | b"FXid")) {
// Smart-filter caches can be large: a cancelled open stops between blocks.
if ctl.cancelled() {
return None;
}
match photocraft_psd::filter_effects::FilterEffects::parse(&b.data) {
Ok(fx) => cx.filter_effects.extend(fx.items),
Err(e) => cx.warn(format!("smart filter masks ({}) could not be read: {e}", b.key_str())),
}
}
if ctl.cancelled() {
return None;
}
let (w, hh) = (h.width as usize, h.height as usize);
// Pixels per plane and bytes per merged plane, checked: a 1-bit plane can decode within the
// budget while `w * hh` does not fit a 32-bit `usize` (wasm32).
let sizes = w.checked_mul(hh).zip(h.row_bytes().checked_mul(hh));
let canvas = Rect::new(0, 0, h.width as i32, h.height as i32);
// The merged composite is decoded only when something consumes it: a flattened or
// multichannel file (it *is* the image), or extra alpha/spot channels behind the colour
// ones. A normal layered Photoshop save carries a merged composite nobody reads - roughly
// half the file's bytes, decoded and thrown away on every open before this.
let merged = if merged_needed(file) {
let m = decoded.unwrap_or_else(|| file.decode_merged());
if ctl.cancelled() {
return None;
}
ctl.progress(0.1);
if let Err(e) = &m {
cx.warn_unreadable_data(format!("merged image could not be decoded: {e}"));
if can_recover_layers(file) {
cx.warn_unreadable_data("extra alpha/spot channels in the merged image could not be recovered");
}
}
Some(m)
} else {
if let Some(Err(e)) = decoded {
cx.warn_unreadable_data(format!("merged image could not be decoded; opened the available layers instead: {e}"));
}
None
};
if multichannel {
// Ink channels only (no layers); see `multichannel_map`.
if let Some(Ok(all)) = &merged {
let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default();
crate::multichannel_map::import(file, all, &mut doc, &names, &mut cx.warnings);
}
if !file.layers().is_empty() {
cx.warn(format!("Multichannel documents have no layers: {} layer records were not imported", file.layers().len()));
}
} else if layered {
// Capped like `build` (#2940): a group past the document's nesting cap keeps no children,
// so no file can make this tree, or anything walking or dropping it, recurse deeper.
let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH);
// Colours stored in the document's CMYK space (Gradient Map stops…) convert through
// 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 stored without a transparency channel (-1),
// locked whatever its (localized) name and whether or not the file carries an `lspf`
// block. Its name is kept.
mark_background(file, &mut doc.layers);
// 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)
{
apply_link_groups(&tree, &mut doc.layers, &groups);
}
if doc.layers.is_empty()
&& let Some(Ok(all)) = &merged
// Always `Some` here: a decoded 8/16/32-bit plane holds at least `w * hh` bytes.
&& let Some((n, plane)) = sizes
{
// Flattened file: the merged image becomes the background layer.
let alpha_idx = if file.merged_has_alpha() { Some(cc) } else { None };
let mut planes: Vec<Option<&[u8]>> = (0..cc).map(|c| merged_plane(all, c, plane)).collect();
planes.push(alpha_idx.and_then(|a| merged_plane(all, a, plane)));
let mut fill = vec![zero_sample(depth); cc];
fill.push(max_sample(depth));
let mut inv = vec![cx.cmyk; cc];
inv.push(false);
let mut bytes = interleave(&planes, &fill, n, depth, &inv);
if fmt.mode == ColorMode::Lab && depth == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, cc + 1, true);
}
let mut s = Surface::from_interleaved(fmt, canvas, &bytes);
if alpha_idx.is_some() {
// Undo Photoshop's white matting of the merged image.
// A band of tile rows at a time (no full-size float copy of the image).
let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]);
let mut vals = Vec::new();
let mut y = canvas.y0;
while y < canvas.y1 {
let band = Rect::new(canvas.x0, y, canvas.x1, y.saturating_add(TILE_SIZE).min(canvas.y1));
s.read_region_into(band, &mut vals);
for px in vals.chunks_exact_mut(cc + 1) {
let a = px[cc];
for c in 0..cc {
px[c] = if a <= 0.0 { 0.0 } else { crate::pixels::unmatte(px[c], a, white[c]) };
}
}
s.write_region(band, &vals);
y = band.y1;
}
}
s.prune();
let mut bg = Layer::new("Background", LayerContent::Raster(s));
if alpha_idx.is_none() {
bg.locks.transparency = true;
bg.locks.position = true;
}
doc.layers.push(bg);
}
} else {
if !file.layers().is_empty() {
cx.warn(format!("{:?} documents are imported flattened: {} layer records were not imported", h.color_mode, file.layers().len()));
} else {
cx.warn(format!("{:?} {}-bit document converted to {:?} 8-bit for editing", h.color_mode, h.depth, fmt.mode));
}
if h.color_mode == PsdMode::Indexed && file.color_mode_data.len() >= 768 {
// Planar palette: 256 reds, 256 greens, 256 blues (the Color Table).
let m = &file.color_mode_data;
// Resource 1046 says how many entries are used, 1047 which one is transparent.
let u16_of = |id: u16| file.resource(id).and_then(|r| r.data.get(..2)).map(|b| u16::from_be_bytes([b[0], b[1]]));
let count = u16_of(crate::palette_map::COLOR_TABLE_COUNT).map_or(256, usize::from).clamp(1, 256);
let colors = (0..count).map(|i| [m[i], m[256 + i], m[512 + i]]).collect();
let transparent = u16_of(crate::palette_map::TRANSPARENCY_INDEX).filter(|&i| usize::from(i) < count).map(|i| i as u8);
doc.color_table = Some(photocraft_doc::ColorTable { colors, transparent });
} else if h.color_mode == PsdMode::Duotone && !file.color_mode_data.is_empty() {
// The inks (`duotone_map`); the block is also kept raw, written back while they stay
// unedited. An ink whose colour is unknown (a colour book without its Lab equivalent)
// would show black and darken the image: the grey plate is shown instead, as before.
let mut unknown = false;
let alternate = file.resource(crate::duotone_map::ALTERNATE_DUOTONE_COLORS).map(|r| r.data.as_slice());
let inks = crate::duotone_map::parse(&file.color_mode_data, alternate, &mut unknown).filter(|_| !unknown);
if inks.is_none() {
cx.warn("duotone inks are not interpreted (shown as grayscale)");
}
let inks = inks.unwrap_or_else(|| vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3])]);
doc.duotone = Some(photocraft_doc::Duotone { inks, psd_raw: Some(file.color_mode_data.as_slice().into()) });
} else if !file.color_mode_data.is_empty() {
cx.warn("color mode data is not preserved");
}
match flat_composite(file, merged.as_ref(), photocraft_psd::compression::MAX_DECODED_BYTES) {
Ok(mut img) => {
// Resource 1047: pixels of the transparent entry are transparent.
if h.color_mode == PsdMode::Indexed
&& let Some(t) = doc.color_table.as_ref().and_then(|t| t.transparent)
&& let Some(Ok(all)) = &merged
&& let Some(indices) = sizes.and_then(|(_, plane)| merged_plane(all, 0, plane))
{
for (px, &i) in img.data.as_chunks_mut::<4>().0.iter_mut().zip(indices) {
if i == t {
px[3] = 0;
}
}
}
let mut s = Surface::new(fmt);
write_rgba8(&mut s, canvas, &img.data);
drop(img);
s.prune();
doc.layers.push(flat_background(s));
}
// A merged image that failed to decode was reported above.
Err(_) if matches!(merged, Some(Err(_))) => {}
Err(e) => cx.warn_unreadable_data(format!("the image could not be read: {e}")),
}
}
// Extra (alpha / spot) channels of the merged image.
if layered
&& let Some(Ok(all)) = &merged
&& let Some((n, plane)) = sizes
{
let first = cc + usize::from(file.merged_has_alpha());
let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default();
// Photoshop lists every extra channel, the merged transparency first when there is one.
let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc));
let names = names.get(skip..).unwrap_or_default();
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
let Some(p) = merged_plane(all, idx, plane) else {
cx.warn_unreadable_data(format!("merged extra channel {idx} is incomplete and was not imported"));
break;
};
let mut s = Surface::new(cx.mask_fmt);
s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n));
s.prune();
let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1));
doc.channels.push(AlphaChannel::new(name, s));
}
// Channel options (overlay colour, opacity, colour indicates) and spot inks.
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels);
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels);
}
// Saved in Quick Mask mode: that channel becomes the document's Quick Mask again.
if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO)
&& r.data.len() >= 2
&& let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first)
&& k < doc.channels.len()
{
doc.quick_mask = Some(doc.channels.remove(k));
}
}
// Layer comps (resource 1065 + per-layer `cmls`); the raw data stays for verbatim export.
let raw_comps = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == crate::comps_map::LAYER_COMPS).map(|(_, _, d)| d.clone());
let comp_warnings;
(doc.layer_comps, doc.last_applied_comp, doc.last_document_state, comp_warnings) =
crate::comps_map::decode_comps(raw_comps.as_deref().map(Vec::as_slice), &doc);
cx.warnings.extend(comp_warnings);
// Slices (resource 1050), after layer ids are known; the raw data stays for verbatim export.
crate::slices_map::import(&mut doc);
// Character and paragraph styles from the type layers' engine data.
cx.warnings.extend(crate::text_styles_map::import(&mut doc));
if ctl.cancelled() {
return None;
}
ctl.progress(1.0);
Some((doc, cx.warnings, cx.damaged))
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_psd::{Compression, PsdError, Version, testgen};
/// #5200: the Multichannel fallback checks its RGBA output against the budget before
/// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes).
#[test]
fn flat_fallback_refuses_an_output_over_the_cap() {
let file = testgen::merged_only(Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 2, 2);
assert!(file.composite_rgba8().is_err(), "Multichannel goes through the fallback");
let merged = file.decode_merged();
assert!(matches!(flat_composite(&file, Some(&merged), 15), Err(PsdError::LimitExceeded(_))));
let img = flat_composite(&file, Some(&merged), 16).unwrap();
assert_eq!(img.data.len(), 16);
}
/// #5200: a composite over the budget is not retried by the fallback (which allocated the
/// refused buffer: 14.4 GB for this 60000x60000 Bitmap header).
#[test]
fn flat_fallback_does_not_retry_an_over_budget_composite() {
let mut file = testgen::small(Version::Psb, Compression::Raw);
file.header.color_mode = PsdMode::Bitmap;
file.header.depth = 1;
file.header.channels = 1;
file.header.width = 60_000;
file.header.height = 60_000;
let merged = Ok(vec![0u8; 16]);
let cap = photocraft_psd::compression::MAX_DECODED_BYTES;
assert!(matches!(flat_composite(&file, Some(&merged), cap), Err(PsdError::LimitExceeded(_))));
}
/// #5202: a hostile name length ends the list instead of overflowing.
#[test]
fn unicode_names_stop_at_a_hostile_length() {
let mut data = 1u32.to_be_bytes().to_vec();
data.extend([0, b'A']);
data.extend(u32::MAX.to_be_bytes());
data.extend([0, b'B']);
assert_eq!(unicode_names(&data), ["A"]);
}
/// Verifies an unreadable layer channel sets the structured damage indicator.
#[test]
fn unreadable_layer_channel_marks_conversion_damaged() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == 0).unwrap();
channel.data.clear();
assert!(file.layers()[0].decode_channel(0, file.header.depth, file.header.version).is_err());
let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap();
assert!(damaged);
assert!(warnings.iter().any(|w| w.contains("could not be decoded")));
}
/// Verifies an unreadable user-mask channel sets the structured damage indicator.
#[test]
fn unreadable_mask_channel_marks_conversion_damaged() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
let channel = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == CHANNEL_USER_MASK).unwrap();
channel.data.clear();
let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap();
assert!(damaged);
assert!(warnings.iter().any(|w| w.contains("could not be decoded")));
}
/// A layer whose bounds hold pixels but whose colour and transparency channels are missing
/// lost its pixel data: the open asks before continuing.
#[test]
fn layer_bounds_without_channel_data_mark_conversion_damaged() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
file.layers_mut()[0].channels.retain(|c| c.id == CHANNEL_USER_MASK);
let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap();
assert!(damaged);
assert!(warnings.iter().any(|w| w.contains("no channel data")));
}
/// A layer without pixels (an empty layer, or a text or shape layer written without a
/// pixel cache) has empty bounds: nothing is missing, so the file is not damaged even when
/// it lists no colour or transparency channel.
#[test]
fn empty_layer_without_channel_data_is_not_damaged() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
file.layers_mut()[0].channels.retain(|c| c.id == CHANNEL_USER_MASK);
file.layers_mut()[0].rect = Default::default();
let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap();
assert!(!damaged, "an empty layer marked the PSD damaged: {warnings:?}");
}
/// Verifies healthy layer channels do not mark the PSD as damaged.
#[test]
fn valid_layer_channels_keep_conversion_editable() {
let file = testgen::small(Version::Psd, Compression::Raw);
let (_, warnings, damaged) = psd_to_document_decoded_integrity(&file, &photocraft_raster::Interrupt::NONE, None).unwrap();
assert!(!damaged, "unexpectedly marked good PSD damaged: {warnings:?}");
}
#[test]
fn merged_plane_checks_offsets() {
let all = [1u8, 2, 3, 4];
assert_eq!(merged_plane(&all, 1, 2), Some(&[3u8, 4][..]));
assert_eq!(merged_plane(&all, 2, 2), None);
assert_eq!(merged_plane(&all, usize::MAX, 2), None);
}
}