1727 lines
86 KiB
Rust
1727 lines
86 KiB
Rust
//! [`Document`] → PSD/PSB.
|
||
|
||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||
use photocraft_doc::{Document, Layer, LayerContent, LayerMask, SmartContentsId};
|
||
use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement};
|
||
use photocraft_psd::layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask as PsdMask, MaskData, MaskParameters};
|
||
use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource};
|
||
use photocraft_psd::{
|
||
BlendMode as PsdBlend, ColorMode as PsdMode, Compression, Header, ImageData, LayerRecord, PsdFile, Rect as PsdRect, SectionType, TaggedBlock, Version,
|
||
};
|
||
use photocraft_raster::{Interrupt, Surface};
|
||
|
||
use crate::adjust_map;
|
||
use crate::blocks;
|
||
use crate::pixels::{deinterleave, encode_be, psd_depth};
|
||
use crate::psd_import::REGENERATED;
|
||
|
||
const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
|
||
const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024;
|
||
const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024;
|
||
|
||
/// Options for [`document_to_psd_with`].
|
||
#[derive(Debug, Clone)]
|
||
pub struct PsdExportOptions {
|
||
/// Write PSB even when the document fits PSD limits.
|
||
pub force_psb: bool,
|
||
/// Matte the merged image against white where it is translucent, as Photoshop does
|
||
/// (`false` keeps straight colour under the alpha: for containers that store the composite
|
||
/// with its own alpha, such as a layered TIFF).
|
||
pub merged_matte: bool,
|
||
/// Compression of the merged image (32-bit float documents are always written raw).
|
||
pub merged_compression: Compression,
|
||
}
|
||
|
||
impl Default for PsdExportOptions {
|
||
fn default() -> Self {
|
||
PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle }
|
||
}
|
||
}
|
||
|
||
struct Ex {
|
||
fmt: PixelFormat,
|
||
/// Document resolution (for generated type-layer data).
|
||
dpi: f32,
|
||
/// Character/paragraph styles written into every type layer's engine data.
|
||
text_styles: photocraft_doc::TextStyles,
|
||
mask_fmt: PixelFormat,
|
||
cc: usize,
|
||
cmyk: bool,
|
||
version: Version,
|
||
next_id: u32,
|
||
/// PSD ids assigned to document layers (kept from `psd_id` when unique).
|
||
layer_ids: std::collections::HashMap<photocraft_doc::LayerId, u32>,
|
||
/// The generated `Txt2` object number of each type layer (a preserved `TySh` keeps its own
|
||
/// `TextIndex`, others get the next free number; see `photocraft_text::psd::build_txt2`),
|
||
/// written into the `TySh` data this export re-serializes.
|
||
text_index: std::collections::HashMap<photocraft_doc::LayerId, i32>,
|
||
canvas: photocraft_geom::Rect,
|
||
records: Vec<LayerRecord>,
|
||
warnings: Vec<String>,
|
||
/// Guides (artboard blocks list the guides inside each board).
|
||
guides: photocraft_doc::Guides,
|
||
/// Layer comps to regenerate into each layer's `cmls`; None = preserved data still matches.
|
||
comps: Option<(Vec<photocraft_doc::LayerComp>, Option<photocraft_doc::LayerComp>)>,
|
||
/// Smart objects: embedded files and filter caches for the global blocks.
|
||
smart: SmartOut,
|
||
/// The document's patterns, which Pattern Fill layers are rendered from.
|
||
patterns: Vec<photocraft_doc::Pattern>,
|
||
}
|
||
|
||
/// How deep embedded documents are exported inside each other before a smart object is written
|
||
/// as pixels instead.
|
||
const MAX_NESTING: u32 = 8;
|
||
|
||
/// An embedded smart-object source as written to `lnk2`.
|
||
#[derive(Clone, Debug)]
|
||
struct Source {
|
||
uuid: String,
|
||
/// Pixel size of the source document.
|
||
size: (f64, f64),
|
||
dpi: f64,
|
||
}
|
||
|
||
fn contents_uuid(contents_id: SmartContentsId, bytes: &[u8]) -> String {
|
||
let mut identity = blake3::Hasher::new();
|
||
identity.update(&contents_id.0.to_le_bytes());
|
||
identity.update(bytes);
|
||
crate::smart_map::uuid_from(identity.finalize().as_bytes())
|
||
}
|
||
|
||
/// Smart-object state collected while emitting layers, written into the global blocks at the end.
|
||
#[derive(Default)]
|
||
struct SmartOut {
|
||
/// Nesting depth of this export (embedded documents are exported recursively).
|
||
depth: u32,
|
||
/// The document's preserved global blocks (embedded files and filter caches from PSD import).
|
||
globals: Vec<photocraft_doc::PsdGlobalBlock>,
|
||
/// uuids of the files in the preserved linked-layer blocks.
|
||
known: std::collections::HashSet<String>,
|
||
/// uuids the layers reference.
|
||
used: std::collections::HashSet<String>,
|
||
/// Unreferenced preserved files may be dropped (every smart layer's file id is known).
|
||
prune_ok: bool,
|
||
/// Files to embed.
|
||
add: Vec<crate::linked::LinkedFile>,
|
||
/// Sources converted so far, by contents identity and bytes (`Err` = can't be embedded).
|
||
converted: std::collections::HashMap<(SmartContentsId, [u8; 32]), Result<Source, String>>,
|
||
/// Each placed layer has its own id, even when it duplicates an imported layer.
|
||
placed_used: std::collections::HashSet<String>,
|
||
/// Original instances reserve their imported placement regardless of layer order.
|
||
placed_owners: std::collections::HashMap<String, photocraft_doc::LayerId>,
|
||
/// Legacy records without a placed id stay unchanged for the original instance.
|
||
unplaced_owners: std::collections::HashMap<SmartContentsId, photocraft_doc::LayerId>,
|
||
/// The imported identity that keeps each original linked UUID.
|
||
linked_contents: std::collections::HashMap<String, SmartContentsId>,
|
||
/// Independent copies of preserved linked items, including unavailable external sources.
|
||
linked_copies: std::collections::BTreeMap<(String, SmartContentsId), photocraft_doc::PsdGlobalBlock>,
|
||
/// Embedded documents decoded while converting them, by uuid (for the filter caches).
|
||
docs: std::collections::HashMap<String, Document>,
|
||
/// Source composites (document pixel format, source space) by uuid; `None` = unreadable.
|
||
composites: std::collections::HashMap<String, Option<(std::sync::Arc<Surface>, photocraft_geom::Rect)>>,
|
||
/// Preserved `FEid` items (by placed id).
|
||
old_fx: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
|
||
/// Placed ids whose preserved `FEid` item stays.
|
||
keep_fx: std::collections::HashSet<String>,
|
||
/// Regenerated `FEid` items.
|
||
new_fx: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
|
||
}
|
||
|
||
/// `FEid` data padded to 4 bytes inside the block length: Photoshop counts the padding in the
|
||
/// length and can't open a file whose `FEid` is padded outside it.
|
||
fn padded_fx(fx: &photocraft_psd::filter_effects::FilterEffects) -> Vec<u8> {
|
||
let mut d = fx.to_bytes();
|
||
d.resize(d.len().next_multiple_of(4), 0);
|
||
d
|
||
}
|
||
|
||
const LINK_KEYS: [&[u8; 4]; 4] = [b"lnk2", b"lnk3", b"lnkD", b"lnkE"];
|
||
const FX_KEYS: [&[u8; 4]; 2] = [b"FEid", b"FXid"];
|
||
|
||
impl SmartOut {
|
||
fn new(doc: &Document, depth: u32) -> Self {
|
||
let globals = doc.metadata.psd_global_blocks.clone();
|
||
let known = globals.iter().filter(|(_, k, _)| LINK_KEYS.contains(&k)).flat_map(|(_, _, d)| crate::linked::block_uuids(d)).collect();
|
||
let old_fx = globals
|
||
.iter()
|
||
.filter(|(_, k, _)| FX_KEYS.contains(&k))
|
||
.filter_map(|(_, _, d)| photocraft_psd::filter_effects::FilterEffects::parse(d).ok())
|
||
.flat_map(|fx| fx.items)
|
||
.collect();
|
||
let mut linked_contents = std::collections::HashMap::new();
|
||
let mut placed_owners = std::collections::HashMap::new();
|
||
let mut unplaced_owners = std::collections::HashMap::new();
|
||
let mut layers = doc.walk();
|
||
// Duplicating a layer clears psd_id and gives it a newer runtime id.
|
||
layers.sort_by_key(|(_, _, layer)| (layer.psd_id.is_none(), layer.id));
|
||
for (_, _, layer) in layers {
|
||
if let LayerContent::Smart(sm) = &layer.content
|
||
&& let photocraft_doc::SmartSource::Linked { path } = &sm.source
|
||
&& let Some(placed) = sm.psd_raw.as_ref().and_then(|d| crate::smart_map::parse_sold(d))
|
||
&& placed.idnt == *path
|
||
{
|
||
linked_contents.entry(path.clone()).or_insert(sm.contents_id);
|
||
if placed.placed.is_empty() {
|
||
unplaced_owners.entry(sm.contents_id).or_insert(layer.id);
|
||
} else {
|
||
placed_owners.entry(placed.placed).or_insert(layer.id);
|
||
}
|
||
}
|
||
}
|
||
SmartOut { depth, globals, known, prune_ok: true, old_fx, linked_contents, placed_owners, unplaced_owners, ..Default::default() }
|
||
}
|
||
|
||
/// The document's global blocks with the linked-layer files and filter caches brought up to
|
||
/// date: unreferenced files dropped (when every reference is known), new files appended,
|
||
/// filter caches kept or regenerated per smart object. Unchanged blocks stay byte-identical.
|
||
fn finish(&self, blocks: Vec<photocraft_doc::PsdGlobalBlock>) -> Vec<photocraft_doc::PsdGlobalBlock> {
|
||
use std::sync::Arc;
|
||
let keep = |u: &str| !self.prune_ok || self.used.contains(u);
|
||
let mut out = Vec::with_capacity(blocks.len() + 2);
|
||
let mut added = false;
|
||
let mut fx_placed = false;
|
||
for (sig, key, data) in blocks {
|
||
if LINK_KEYS.contains(&&key) {
|
||
let add: &[crate::linked::LinkedFile] = if !added && &key == b"lnk2" { &self.add } else { &[] };
|
||
added |= &key == b"lnk2";
|
||
match crate::linked::rebuild_block(&data, &keep, add) {
|
||
None => out.push((sig, key, data)),
|
||
Some(d) if d.is_empty() => {}
|
||
Some(d) => out.push((sig, key, Arc::new(d))),
|
||
}
|
||
} else if FX_KEYS.contains(&&key) {
|
||
let Ok(mut fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) else {
|
||
out.push((sig, key, data));
|
||
continue;
|
||
};
|
||
let before = fx.items.len();
|
||
fx.items.retain(|i| self.keep_fx.contains(&i.id));
|
||
let fresh = !fx_placed && !self.new_fx.is_empty();
|
||
if fresh {
|
||
fx.items.extend(self.new_fx.iter().cloned());
|
||
fx_placed = true;
|
||
}
|
||
if fx.items.len() == before && !fresh {
|
||
out.push((sig, key, data));
|
||
} else if !fx.items.is_empty() {
|
||
out.push((sig, key, Arc::new(padded_fx(&fx))));
|
||
}
|
||
} else {
|
||
out.push((sig, key, data));
|
||
}
|
||
}
|
||
if !added && !self.add.is_empty() {
|
||
let data: Vec<u8> = self.add.iter().flat_map(crate::linked::encode_linked_file).collect();
|
||
out.push((*b"8BIM", *b"lnk2", Arc::new(data)));
|
||
}
|
||
for (sig, key, data) in self.linked_copies.values() {
|
||
if let Some((_, _, block)) = out.iter_mut().find(|(s, k, _)| s == sig && k == key) {
|
||
Arc::make_mut(block).extend_from_slice(data);
|
||
} else {
|
||
out.push((*sig, *key, data.clone()));
|
||
}
|
||
}
|
||
if !fx_placed && !self.new_fx.is_empty() {
|
||
let fx = photocraft_psd::filter_effects::FilterEffects { version: 3, items: self.new_fx.clone() };
|
||
out.push((*b"8BIM", *b"FEid", Arc::new(padded_fx(&fx))));
|
||
if !out.iter().any(|(_, k, _)| k == b"FMsk") {
|
||
// Filter mask overlay: RGB red at 50 %, Photoshop's default.
|
||
out.push((*b"8BIM", *b"FMsk", Arc::new(vec![0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 50])));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image).
|
||
fn source_geometry(name: &str, bytes: &[u8]) -> Result<((f64, f64), f64), String> {
|
||
if crate::is_psd(bytes) {
|
||
let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]])));
|
||
let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?;
|
||
return Ok(((w, h), 72.0));
|
||
}
|
||
let doc = crate::import(name, bytes).map_err(|e| e.to_string())?.document;
|
||
Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi)))
|
||
}
|
||
|
||
/// The source size of a smart object whose file we can't decode: its rendered bounds taken back
|
||
/// through the inverse transform (the source's top-left is the origin).
|
||
fn size_from_layer(sm: &photocraft_doc::SmartObject) -> (f64, f64) {
|
||
let r = sm.cache.as_ref().map(Surface::content_bounds).filter(|r| !r.is_empty());
|
||
let (Some(r), Some(inv)) = (r, sm.transform.inverse()) else { return (1.0, 1.0) };
|
||
let [a, b, c, d, e, f] = inv.m;
|
||
let (mut w, mut h) = (1.0f64, 1.0f64);
|
||
for (x, y) in [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)] {
|
||
let (x, y) = (f64::from(x), f64::from(y));
|
||
w = w.max((a * x + c * y + e).round());
|
||
h = h.max((b * x + d * y + f).round());
|
||
}
|
||
(w, h)
|
||
}
|
||
|
||
fn upsert(raw: &mut Vec<([u8; 4], Vec<u8>)>, key: &[u8; 4], data: Vec<u8>, before: Option<&[u8; 4]>) {
|
||
if let Some(e) = raw.iter_mut().find(|(k, _)| k == key) {
|
||
e.1 = data;
|
||
return;
|
||
}
|
||
match before.and_then(|b| raw.iter().position(|(k, _)| k == b)) {
|
||
Some(i) => raw.insert(i, (*key, data)),
|
||
None => raw.push((*key, data)),
|
||
}
|
||
}
|
||
|
||
fn psd_mode(m: ColorMode) -> PsdMode {
|
||
match m {
|
||
ColorMode::Grayscale => PsdMode::Grayscale,
|
||
ColorMode::Cmyk => PsdMode::Cmyk,
|
||
ColorMode::Lab => PsdMode::Lab,
|
||
_ => PsdMode::Rgb,
|
||
}
|
||
}
|
||
|
||
fn to_psd_rect(r: photocraft_geom::Rect) -> PsdRect {
|
||
PsdRect { top: r.y0, left: r.x0, bottom: r.y1, right: r.x1 }
|
||
}
|
||
|
||
fn q255(v: f32) -> u8 {
|
||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||
}
|
||
|
||
impl Ex {
|
||
fn compression(&self) -> Compression {
|
||
if self.fmt.sample == SampleType::F32 { Compression::ZipPrediction } else { Compression::Rle }
|
||
}
|
||
|
||
fn encode(&self, id: i16, plane: &[u8], w: usize, h: usize) -> ChannelData {
|
||
let depth = psd_depth(self.fmt.sample);
|
||
// Photoshop writes empty layer/mask channels as just the raw compression code.
|
||
// A ZIP stream for a zero-sized channel makes readers try to decompress an
|
||
// invalid image extent (notably psd-tools for 32-bit adjustment layers).
|
||
let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() };
|
||
ChannelData::encode(id, compression, plane, w, h, depth, self.version)
|
||
.or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version))
|
||
.unwrap_or(ChannelData { id, compression: Some(Compression::Raw), data: plane.to_vec() })
|
||
}
|
||
|
||
fn empty_channels(&self) -> Vec<ChannelData> {
|
||
(-1..self.cc as i16).map(|id| self.encode(id, &[], 0, 0)).collect()
|
||
}
|
||
|
||
fn pixel_channels(&mut self, s: &Surface, name: &str) -> (PsdRect, Vec<ChannelData>) {
|
||
let s = if s.format() != self.fmt {
|
||
self.warnings.push(format!("layer \"{name}\": pixels converted from {:?} to {:?}", s.format(), self.fmt));
|
||
s.convert(self.fmt)
|
||
} else {
|
||
s.clone()
|
||
};
|
||
let r = s.content_bounds();
|
||
if r.is_empty() {
|
||
return (PsdRect::default(), self.empty_channels());
|
||
}
|
||
let mut bytes = s.to_interleaved(r);
|
||
if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 {
|
||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
|
||
}
|
||
let mut invert = vec![self.cmyk; self.cc];
|
||
invert.push(false);
|
||
let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert);
|
||
drop(bytes);
|
||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||
// Alpha (-1) first, then the colour channels; each compressed on its own thread.
|
||
let order: Vec<(i16, &Vec<u8>)> =
|
||
std::iter::once((-1, &planes[self.cc])).chain(planes.iter().take(self.cc).enumerate().map(|(c, p)| (c as i16, p))).collect();
|
||
let ch = crate::pixels::par_map(order, |(id, plane)| self.encode(id, plane, w, h));
|
||
(to_psd_rect(r), ch)
|
||
}
|
||
|
||
fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option<ChannelData>) {
|
||
let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() };
|
||
let default = s.default_pixel().first().copied().unwrap_or(0.0);
|
||
let r = s.content_bounds();
|
||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||
let plane = if r.is_empty() { Vec::new() } else { deinterleave(&s.to_interleaved(r), 1, self.mask_fmt.sample, &[false]).remove(0) };
|
||
let mut flags = 0u8;
|
||
if !m.linked {
|
||
flags |= PsdMask::FLAG_RELATIVE;
|
||
}
|
||
if !m.enabled {
|
||
flags |= PsdMask::FLAG_DISABLED;
|
||
}
|
||
let parameters = mask_parameters(Some((m.density, m.feather)), vector);
|
||
if parameters.is_some() {
|
||
flags |= PsdMask::FLAG_PARAMETERS;
|
||
}
|
||
let pm = PsdMask {
|
||
rect: if r.is_empty() { PsdRect::default() } else { to_psd_rect(r) },
|
||
default_color: q255(default),
|
||
flags,
|
||
trailing: if parameters.is_none() { vec![0, 0] } else { Vec::new() },
|
||
parameters,
|
||
real: None,
|
||
real_first: true,
|
||
};
|
||
(MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)))
|
||
}
|
||
|
||
/// Ids for section dividers: above every layer id.
|
||
fn fresh_id(&mut self) -> u32 {
|
||
self.next_id += 1;
|
||
self.next_id
|
||
}
|
||
|
||
fn layer_id(&mut self, l: &Layer) -> u32 {
|
||
match self.layer_ids.get(&l.id) {
|
||
Some(id) => *id,
|
||
None => self.fresh_id(),
|
||
}
|
||
}
|
||
|
||
fn record(&mut self, l: &Layer, extra: Vec<TaggedBlock>, pixels: Option<&Surface>) -> LayerRecord {
|
||
let id = self.layer_id(l);
|
||
let (rect, mut channels) = match pixels {
|
||
Some(s) => self.pixel_channels(s, &l.name),
|
||
None => (PsdRect::default(), self.empty_channels()),
|
||
};
|
||
let mut mask = MaskData::None;
|
||
// Vector mask density/feather live in the mask parameters, with or without a user mask.
|
||
let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather));
|
||
if let Some(m) = &l.mask {
|
||
let (md, ch) = self.mask(m, vector);
|
||
mask = md;
|
||
channels.extend(ch);
|
||
} else if let Some(parameters) = mask_parameters(None, vector) {
|
||
// Parameters only: no user mask channel, so nothing hides on re-import.
|
||
mask = MaskData::Mask(PsdMask {
|
||
rect: PsdRect::default(),
|
||
default_color: 255,
|
||
flags: PsdMask::FLAG_PARAMETERS,
|
||
parameters: Some(parameters),
|
||
real: None,
|
||
trailing: Vec::new(),
|
||
real_first: true,
|
||
});
|
||
}
|
||
let mut flags = LayerFlags(0);
|
||
flags.set_hidden(!l.visible);
|
||
flags.set_transparency_protected(l.locks.transparency);
|
||
let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)];
|
||
let lspf = blocks::lspf_from_locks(&l.locks);
|
||
if lspf != 0 {
|
||
blocks.push(TaggedBlock::protection(lspf));
|
||
}
|
||
if l.label != photocraft_doc::LabelColor::None {
|
||
blocks.push(TaggedBlock::sheet_color(blocks::label_index(l.label)));
|
||
}
|
||
blocks.push(TaggedBlock::fill_opacity(q255(l.fill_opacity)));
|
||
// Effects: the original lfx2 while the effects are unchanged (or
|
||
// could not be decoded at all); otherwise regenerated.
|
||
let fx = &l.effects;
|
||
for effect in &fx.items {
|
||
use photocraft_doc::effects::Effect;
|
||
let modes = match effect {
|
||
Effect::DropShadow(s) | Effect::InnerShadow(s) => [Some(s.common.blend), None],
|
||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => [Some(g.common.blend), None],
|
||
Effect::Stroke(s) => [Some(s.common.blend), None],
|
||
Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => {
|
||
[Some(common.blend), None]
|
||
}
|
||
Effect::Satin(s) => [Some(s.common.blend), None],
|
||
Effect::BevelEmboss(b) => [Some(b.highlight.blend), Some(b.shadow.blend)],
|
||
};
|
||
for mode in modes.into_iter().flatten() {
|
||
if !mode.has_psd_equivalent() {
|
||
self.warnings.push(format!(
|
||
"layer \"{}\", {}: {} has no PSD equivalent and was written as Normal; save as .pcraft to preserve it",
|
||
l.name,
|
||
effect.label(),
|
||
mode.label()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
let lfx2 = match effects_unchanged(l) {
|
||
Some(true) | None if fx.psd_raw.is_some() => fx.psd_raw.as_ref().map(|r| r.to_vec()),
|
||
_ => (!fx.items.is_empty()).then(|| crate::effects_map::write_lfx2(fx.enabled, &fx.items)),
|
||
};
|
||
if let Some(d) = lfx2 {
|
||
let key = if l.is_group() { *b"lfxs" } else { *b"lfx2" };
|
||
blocks.push(TaggedBlock::new(key, d));
|
||
}
|
||
blocks.extend(extra);
|
||
// Layer-level blocks keep their pad byte inside the length, as Photoshop writes them:
|
||
// readers such as psd-tools do not skip a pad after an odd length, so a regenerated
|
||
// odd-length `lfx2` misaligned every block after it (#200).
|
||
for b in &mut blocks {
|
||
if b.data.len() % 2 == 1 && b.padding.is_none() {
|
||
b.data.push(0);
|
||
}
|
||
}
|
||
if !l.blend.has_psd_equivalent() {
|
||
self.warnings.push(format!(
|
||
"layer \"{}\": {} has no PSD equivalent and was written as Normal; save as .pcraft to preserve it",
|
||
l.name,
|
||
l.blend.label()
|
||
));
|
||
}
|
||
LayerRecord {
|
||
rect,
|
||
channels,
|
||
blend_mode: PsdBlend::from_key(l.blend.psd_key()),
|
||
opacity: q255(l.opacity),
|
||
clipping: u8::from(l.clipped),
|
||
flags,
|
||
filler: 0,
|
||
mask,
|
||
blending_ranges: crate::blocks::ranges_from_blend_if(&l.blend_if, self.cc),
|
||
name: legacy_name(&l.name),
|
||
blocks,
|
||
extra_trailing: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Content blocks for `l`: preserved raw blocks (with `psd_raw`
|
||
/// overrides) plus regenerated adjustment/fill blocks. A preserved
|
||
/// adjustment or fill block is reused verbatim while it still decodes to
|
||
/// the layer's current parameters; otherwise it is regenerated.
|
||
fn content_blocks(&mut self, l: &Layer) -> Vec<TaggedBlock> {
|
||
let mut raw: Vec<([u8; 4], Vec<u8>)> = l.psd_blocks.iter().filter(|(k, _)| !REGENERATED.contains(&k)).map(|(k, d)| (*k, d.to_vec())).collect();
|
||
// A stale multi-effects block would override the regenerated lfx2.
|
||
if effects_unchanged(l) == Some(false) {
|
||
raw.retain(|(k, _)| k != b"lmfx");
|
||
}
|
||
let mut regenerated: Vec<([u8; 4], Vec<u8>)> = Vec::new();
|
||
// `keys` is in the importer's priority order (`psd_raw` came from the first key present),
|
||
// not file order: Photoshop writes `PlLd` before `SoLd`, and overwriting the first match
|
||
// in file order put `soLD` data under the `PlLd` key (#200).
|
||
let set_principal = |raw: &mut Vec<([u8; 4], Vec<u8>)>, keys: &[&[u8; 4]], data: Option<&std::sync::Arc<Vec<u8>>>| {
|
||
let Some(d) = data else { return };
|
||
match keys.iter().find_map(|k| raw.iter().position(|(rk, _)| rk == *k)) {
|
||
Some(i) => raw[i].1 = d.to_vec(),
|
||
None => raw.push((*keys[0], d.to_vec())),
|
||
}
|
||
};
|
||
let fill_rule = |raw: &mut Vec<([u8; 4], Vec<u8>)>, regenerated: &mut Vec<([u8; 4], Vec<u8>)>, f: &photocraft_doc::Fill| {
|
||
let keep = raw.iter().any(|(k, d)| matches!(k, b"SoCo" | b"GdFl" | b"PtFl") && blocks::parse_fill(k, d).as_ref() == Some(f));
|
||
if !keep {
|
||
raw.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl"));
|
||
// Photoshop pads layer-level blocks to a multiple of 4 inside the length (every
|
||
// one in its own files is). A fill descriptor of 2 mod 4 bytes, as a pattern
|
||
// name of the wrong length gives, left the record's extra data unaligned.
|
||
let (key, mut data) = blocks::write_fill(f);
|
||
data.resize(data.len().next_multiple_of(4), 0);
|
||
regenerated.push((key, data));
|
||
}
|
||
};
|
||
match &l.content {
|
||
LayerContent::Adjustment(a) => {
|
||
let channels = 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,
|
||
};
|
||
let cged = raw.iter().find(|(k, _)| k == b"CgEd").map(|(_, d)| d.clone());
|
||
let keep = raw.iter().any(|(k, d)| {
|
||
// A previously saved PhotoCraft v2 Photo Filter block may be 18 bytes.
|
||
// It parses here, but Photoshop requires four-byte alignment, so repair
|
||
// that legacy record on re-save rather than preserving the broken bytes.
|
||
let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0;
|
||
!legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a
|
||
});
|
||
// A Gradient Map whose stops are unchanged keeps the file's own block (stops in
|
||
// the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither
|
||
// updated in place.
|
||
let keep = keep
|
||
|| (matches!(a, photocraft_doc::Adjustment::GradientMap { .. })
|
||
&& raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels)));
|
||
if !keep {
|
||
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd");
|
||
let w = adjust_map::write(a);
|
||
if w.is_empty() {
|
||
self.warnings.push(format!("layer \"{}\": {} adjustment is not yet written to PSD", l.name, a.label()));
|
||
}
|
||
// Photoshop's `clrL` descriptor has no interpolation setting, so the
|
||
// reopened layer renders trilinear.
|
||
if matches!(a, photocraft_doc::Adjustment::ColorLookup { tetrahedral: true, .. }) {
|
||
self.warnings.push(format!(
|
||
"layer \"{}\": Color Lookup tetrahedral interpolation is saved as trilinear (PSD has no interpolation setting)",
|
||
l.name
|
||
));
|
||
}
|
||
regenerated.extend(w);
|
||
}
|
||
}
|
||
LayerContent::Fill(f) => fill_rule(&mut raw, &mut regenerated, f),
|
||
LayerContent::Shape(sh) => {
|
||
if let Some(f) = &sh.fill {
|
||
fill_rule(&mut raw, &mut regenerated, f);
|
||
}
|
||
let (w, h) = (self.canvas.width(), self.canvas.height());
|
||
crate::vector_map::shape_blocks(sh, &mut raw, w, h, self.dpi);
|
||
}
|
||
LayerContent::Text(t) => {
|
||
// Text layers without PSD data (created here) get a generated TySh.
|
||
let generated = t.psd_raw.is_none().then(|| std::sync::Arc::new(photocraft_text::psd::build_tysh(t, self.dpi, None)));
|
||
let src = t.psd_raw.as_ref().or(generated.as_ref());
|
||
// Character/paragraph style sheets from the document's styles.
|
||
let styled = src.and_then(|d| crate::text_styles_map::export_tysh(d, t, &self.text_styles, self.dpi)).map(std::sync::Arc::new);
|
||
// `TextIndex` names the layer's object in the document's `Txt2` (where the
|
||
// auto-kern mode lives); left at 0, every type layer would claim object 0 (#1348).
|
||
// Keep an unchanged preserved TySh byte-for-byte (corpus_tysh_lossless).
|
||
// Duplicate or out-of-range file TextIndex values must be renumbered,
|
||
// however, even when no style sheet needed rebuilding: otherwise two
|
||
// layers may read the same Txt2 object and acquire the wrong kerning.
|
||
let idx = self.text_index.get(&l.id).copied().unwrap_or(0);
|
||
let rewrite = styled.as_ref().or(generated.as_ref()).or_else(|| src.filter(|d| photocraft_text::psd::text_index(d.as_slice()) != Some(idx)));
|
||
let fresh = rewrite.and_then(|d| photocraft_text::psd::set_text_index(d.as_slice(), idx)).map(std::sync::Arc::new);
|
||
set_principal(&mut raw, &[b"TySh"], fresh.as_ref().or(styled.as_ref().or(src)));
|
||
if !raw.iter().any(|(k, _)| k == b"TySh") {
|
||
self.warnings.push(format!("layer \"{}\": text layer written as pixels (no TySh data)", l.name));
|
||
}
|
||
}
|
||
LayerContent::Smart(sm) => {
|
||
let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d));
|
||
if sm.psd_raw.is_some() && parsed.is_none() {
|
||
// Placed-layer data we don't parse (`PlLd` only): kept verbatim.
|
||
self.smart.prune_ok = false;
|
||
set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref());
|
||
} else {
|
||
self.smart_blocks(l, sm, parsed, &mut raw);
|
||
}
|
||
}
|
||
LayerContent::Raster(_) | LayerContent::Group(_) => {}
|
||
}
|
||
// Effects reference point: written from the field (in place, keeping block order).
|
||
match l.effects.reference {
|
||
Some((x, y)) => {
|
||
let data: Vec<u8> = x.to_be_bytes().into_iter().chain(y.to_be_bytes()).collect();
|
||
match raw.iter_mut().find(|(k, _)| k == b"fxrp") {
|
||
Some(e) => e.1 = data,
|
||
None => raw.push((*b"fxrp", data)),
|
||
}
|
||
}
|
||
None => raw.retain(|(k, _)| k != b"fxrp"),
|
||
}
|
||
// Advanced Blending channel restrictions: written from the field (in place).
|
||
match crate::blocks::brst_data(l.excluded_channels) {
|
||
Some(data) => match raw.iter_mut().find(|(k, _)| k == b"brst") {
|
||
Some(e) => e.1 = data,
|
||
None => raw.push((*b"brst", data)),
|
||
},
|
||
None => raw.retain(|(k, _)| k != b"brst"),
|
||
}
|
||
// Advanced Blending: written from the field (in place when the block was imported).
|
||
crate::blocks::put_advanced(&l.advanced, &mut raw);
|
||
if !matches!(l.content, LayerContent::Shape(_)) {
|
||
self.vector_mask_block(l, &mut raw);
|
||
}
|
||
crate::comps_map::artboard_block(&self.guides, l, &mut raw);
|
||
if let Some((comps, last)) = &self.comps {
|
||
// Ids are assigned to every layer before emitting.
|
||
let id = self.layer_ids.get(&l.id).copied().unwrap_or(0);
|
||
crate::comps_map::set_cmls(&mut raw, crate::comps_map::write_cmls(comps, last.as_ref(), l, id));
|
||
}
|
||
regenerated.into_iter().chain(raw).map(|(k, d)| TaggedBlock::new(k, d)).collect()
|
||
}
|
||
|
||
/// Keeps, regenerates or removes the `vmsk`/`vsms` block of a non-shape layer.
|
||
fn vector_mask_block(&mut self, l: &Layer, raw: &mut Vec<([u8; 4], Vec<u8>)>) {
|
||
let (w, h) = (self.canvas.width(), self.canvas.height());
|
||
let pos = raw.iter().position(|(k, _)| k == b"vsms" || k == b"vmsk");
|
||
match (&l.vector_mask, pos) {
|
||
(None, Some(_)) => raw.retain(|(k, _)| k != b"vsms" && k != b"vmsk"),
|
||
(None, None) => {}
|
||
(Some(vm), Some(i)) if crate::vector_map::vector_mask_block_matches(&raw[i].1, vm, w, h) => {}
|
||
(Some(vm), pos) => {
|
||
let data = crate::vector_map::vector_mask_bytes(vm, w, h);
|
||
match pos {
|
||
Some(i) => raw[i].1 = data,
|
||
None => raw.push((*b"vmsk", data)),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Placed-layer blocks (`PlLd` + `SoLd`) of a smart object, its embedded file (`lnk2`) and
|
||
/// filter cache (`FEid`). An imported placed layer that nothing changed is written verbatim;
|
||
/// otherwise the blocks are regenerated (from the imported descriptor where the source is
|
||
/// the same). Without a source that can be embedded the layer is written as pixels.
|
||
fn smart_blocks(&mut self, l: &Layer, sm: &photocraft_doc::SmartObject, template: Option<crate::smart_map::Placed>, raw: &mut Vec<([u8; 4], Vec<u8>)>) {
|
||
use crate::smart_map::{FilterStack, PlacedSpec, filter_fx, plld_bytes, sold_bytes, uuid_from};
|
||
let src = match self.smart_source(sm, template.as_ref()) {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
raw.retain(|(k, _)| !matches!(k, b"SoLd" | b"PlLd" | b"SoLE"));
|
||
self.warnings.push(format!("layer \"{}\": smart object written as pixels ({e})", l.name));
|
||
return;
|
||
}
|
||
};
|
||
self.smart.used.insert(src.uuid.clone());
|
||
let stack = FilterStack {
|
||
enabled: sm.filters_enabled,
|
||
filters: sm.smart_filters.clone(),
|
||
mask_enabled: sm.filter_mask.as_ref().is_none_or(|m| m.enabled),
|
||
mask_linked: sm.filter_mask.as_ref().is_some_and(|m| m.linked),
|
||
};
|
||
let same_source = template.as_ref().filter(|t| t.idnt == src.uuid);
|
||
let unchanged = same_source.zip(sm.psd_raw.as_ref()).is_some_and(|(t, data)| self.placed_unchanged(t, data, sm, &stack));
|
||
let placed = match same_source {
|
||
Some(t) if t.placed.is_empty() && unchanged && self.smart.unplaced_owners.get(&sm.contents_id) == Some(&l.id) => String::new(),
|
||
Some(t) if self.smart.placed_owners.get(&t.placed) == Some(&l.id) && self.smart.placed_used.insert(t.placed.clone()) => t.placed.clone(),
|
||
_ => {
|
||
let seed = format!("{}:{}", src.uuid, l.id.0);
|
||
let mut suffix = 0u64;
|
||
loop {
|
||
let id = uuid_from(if suffix == 0 { seed.clone() } else { format!("{seed}:{suffix}") }.as_bytes());
|
||
if !self.smart.placed_owners.contains_key(&id) && self.smart.placed_used.insert(id.clone()) {
|
||
break id;
|
||
}
|
||
suffix += 1;
|
||
}
|
||
}
|
||
};
|
||
if let (Some(t), Some(data)) = (same_source, sm.psd_raw.as_ref())
|
||
&& t.placed == placed
|
||
&& unchanged
|
||
{
|
||
let keys: &[&[u8; 4]] = if raw.iter().any(|(k, _)| k == b"SoLd") { &[b"SoLd"] } else { &[b"SoLE", b"SoLd"] };
|
||
match keys.iter().find_map(|k| raw.iter().position(|(rk, _)| rk == *k)) {
|
||
Some(i) => raw[i].1 = data.to_vec(),
|
||
None => raw.push((*b"SoLd", data.to_vec())),
|
||
}
|
||
if !stack.filters.is_empty() {
|
||
self.smart.keep_fx.insert(t.placed.clone());
|
||
}
|
||
return;
|
||
}
|
||
let size = same_source.and_then(|t| crate::smart_map::stored_size(&t.descriptor)).unwrap_or(src.size);
|
||
let size = if size.0 > 0.0 && size.1 > 0.0 { size } else { size_from_layer(sm) };
|
||
let mut warnings = Vec::new();
|
||
let fx = (!stack.filters.is_empty()).then(|| filter_fx(&stack, &l.name, &mut warnings));
|
||
let spec = PlacedSpec {
|
||
idnt: &src.uuid,
|
||
placed: &placed,
|
||
transform: sm.transform,
|
||
perspective: sm.perspective,
|
||
size,
|
||
dpi: src.dpi,
|
||
warp: sm.warp.as_ref(),
|
||
filter_fx: fx,
|
||
};
|
||
let sold = sold_bytes(same_source.map(|t| &t.descriptor), &spec, &mut warnings);
|
||
let plld = plld_bytes(&spec, &mut warnings);
|
||
warnings.dedup();
|
||
let key = if same_source.is_some() && raw.iter().any(|(k, _)| k == b"SoLE") && !raw.iter().any(|(k, _)| k == b"SoLd") { *b"SoLE" } else { *b"SoLd" };
|
||
raw.retain(|(k, _)| k == &key || !matches!(k, b"SoLd" | b"SoLE"));
|
||
upsert(raw, b"PlLd", plld, Some(&key));
|
||
upsert(raw, &key, sold, None);
|
||
if !stack.filters.is_empty() {
|
||
let bounds = sm.cache.as_ref().map_or(self.canvas, |c| c.content_bounds().union(&self.canvas));
|
||
let item = match (sm.stack_mode, self.source_composite(&src.uuid)) {
|
||
(None, Some((img, img_bounds))) => {
|
||
let unfiltered = match &sm.perspective {
|
||
Some(p) => {
|
||
photocraft_algo::warp::place_source_projective(&img, img_bounds, &photocraft_algo::transform::Homography(*p), sm.warp.as_ref())
|
||
}
|
||
None => photocraft_algo::warp::place_source(&img, img_bounds, &sm.transform, sm.warp.as_ref()),
|
||
};
|
||
match unfiltered {
|
||
Ok(unfiltered) => crate::smart_map::feid_item(&placed, &unfiltered, sm.filter_mask.as_ref(), bounds, self.fmt),
|
||
Err(e) => {
|
||
warnings.push(format!("smart filter mask was not re-rendered for PSD export ({e})"));
|
||
None
|
||
}
|
||
}
|
||
}
|
||
_ => None,
|
||
};
|
||
match item {
|
||
Some(i) => self.smart.new_fx.push(i),
|
||
// Without the unfiltered pixels Photoshop re-renders from the source itself; only
|
||
// the filter mask has nowhere to go.
|
||
None if sm.filter_mask.is_some() => self.warnings.push(format!("layer \"{}\": the smart filter mask was not written to PSD", l.name)),
|
||
None => {}
|
||
}
|
||
}
|
||
self.warnings.extend(warnings.into_iter().map(|w| if w.starts_with("layer ") { w } else { format!("layer \"{}\": {w}", l.name) }));
|
||
}
|
||
|
||
/// Whether an imported placed layer still says what the smart object is: transform, warp,
|
||
/// filter stack and filter mask as stored.
|
||
fn placed_unchanged(&self, t: &crate::smart_map::Placed, data: &[u8], sm: &photocraft_doc::SmartObject, stack: &crate::smart_map::FilterStack) -> bool {
|
||
if t.transform != sm.transform || blocks::parse_placed_warp(b"SoLd", data) != sm.warp || t.stack.clone().unwrap_or_default() != *stack {
|
||
return false;
|
||
}
|
||
if stack.filters.is_empty() {
|
||
return true;
|
||
}
|
||
let old = self.smart.old_fx.iter().find(|i| i.id == t.placed);
|
||
let mask = old.map(|i| crate::smart_map::mask_from_item(i, self.mask_fmt.sample, stack));
|
||
match mask {
|
||
Some(Ok(m)) => m == sm.filter_mask,
|
||
Some(Err(_)) => false,
|
||
None => sm.filter_mask.is_none(),
|
||
}
|
||
}
|
||
|
||
/// The `lnk2` file a smart object's source is written as: a preserved embedded file, or a new
|
||
/// one (a `.pcraft` source becomes a PSB of the nested document; image files are embedded as
|
||
/// they are; a linked file is read and embedded).
|
||
fn smart_source(&mut self, sm: &photocraft_doc::SmartObject, template: Option<&crate::smart_map::Placed>) -> Result<Source, String> {
|
||
use photocraft_doc::SmartSource;
|
||
match &sm.source {
|
||
// A file of the imported document, or one its placed-layer data names (a missing
|
||
// link stays a smart object, as in Photoshop).
|
||
SmartSource::Linked { path } if self.smart.known.contains(path) || template.is_some_and(|t| t.idnt == *path) => {
|
||
let stored = template.filter(|t| t.idnt == *path).and_then(|t| crate::smart_map::stored_size(&t.descriptor));
|
||
let geometry = stored.map(|size| (size, f64::from(self.dpi))).or_else(|| {
|
||
let meta = photocraft_doc::Metadata { psd_global_blocks: self.smart.globals.clone(), ..Default::default() };
|
||
let f = crate::linked::find_linked_file(&meta, path)?;
|
||
source_geometry(&f.file_name, &f.bytes).ok()
|
||
});
|
||
let (size, dpi) = geometry.unwrap_or((size_from_layer(sm), f64::from(self.dpi)));
|
||
let original = self.smart.linked_contents.entry(path.clone()).or_insert(sm.contents_id);
|
||
// An absent Idnt is not a shared-source identity; import treats each as fresh.
|
||
let uuid = if path.is_empty() || *original == sm.contents_id {
|
||
path.clone()
|
||
} else {
|
||
let uuid = contents_uuid(sm.contents_id, path.as_bytes());
|
||
let key = (path.clone(), sm.contents_id);
|
||
if !self.smart.linked_copies.contains_key(&key) {
|
||
let copy = self
|
||
.smart
|
||
.globals
|
||
.iter()
|
||
.find_map(|(sig, k, data)| {
|
||
LINK_KEYS
|
||
.contains(&k)
|
||
.then(|| crate::linked::copy_item(data, path, &uuid))
|
||
.flatten()
|
||
.map(|data| (*sig, *k, std::sync::Arc::new(data)))
|
||
})
|
||
.ok_or_else(|| format!("the linked source {path} has no readable record to copy independently"))?;
|
||
self.smart.linked_copies.insert(key, copy);
|
||
}
|
||
uuid
|
||
};
|
||
Ok(Source { uuid, size, dpi })
|
||
}
|
||
SmartSource::Linked { path } => {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let bytes = std::fs::read(path).map_err(|e| format!("the linked file {path} can't be read: {e}"))?;
|
||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string();
|
||
self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)"));
|
||
self.embed(sm.contents_id, &name, &bytes)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
Err(format!("the linked file {path} can't be read here"))
|
||
}
|
||
SmartSource::Embedded { file_name, bytes } => self.embed(sm.contents_id, file_name, bytes),
|
||
}
|
||
}
|
||
|
||
/// The composite of the source `uuid` (an embedded file of this export or of the imported
|
||
/// document) in the document's pixel format, its top-left at the origin; decoded once.
|
||
fn source_composite(&mut self, uuid: &str) -> Option<(std::sync::Arc<Surface>, photocraft_geom::Rect)> {
|
||
if let Some(c) = self.smart.composites.get(uuid) {
|
||
return c.clone();
|
||
}
|
||
let doc = match self.smart.docs.get(uuid) {
|
||
Some(d) => Some(d.clone()),
|
||
None => {
|
||
let file = self.smart.add.iter().find(|f| f.uuid == uuid).cloned().or_else(|| {
|
||
let meta = photocraft_doc::Metadata {
|
||
psd_global_blocks: self.smart.globals.iter().chain(self.smart.linked_copies.values()).cloned().collect(),
|
||
..Default::default()
|
||
};
|
||
crate::linked::find_linked_file(&meta, uuid)
|
||
});
|
||
file.and_then(|f| crate::import(&f.file_name, &f.bytes).ok()).map(|r| r.document)
|
||
}
|
||
};
|
||
let c = doc.map(|d| {
|
||
let mut s = Surface::new(self.fmt);
|
||
let fmt = self.fmt;
|
||
let _ = photocraft_compose::render_bands(&d, d.bounds(), 0, |band| -> Result<(), ()> {
|
||
let mut vals = Vec::with_capacity(band.px.len() * fmt.channels());
|
||
let mut v = [0.0f32; 5];
|
||
for p in &band.px {
|
||
let n = photocraft_raster::from_rgba_into(&fmt, *p, &mut v);
|
||
vals.extend_from_slice(&v[..n]);
|
||
}
|
||
s.write_region(band.rect, &vals);
|
||
Ok(())
|
||
});
|
||
s.prune();
|
||
(std::sync::Arc::new(s), d.bounds())
|
||
});
|
||
self.smart.composites.insert(uuid.to_string(), c.clone());
|
||
c
|
||
}
|
||
|
||
/// Adds `bytes` as an embedded file (converted to PSB when it is a `.pcraft` bundle), once
|
||
/// per contents identity and bytes. Equal bytes in independent objects stay independent.
|
||
fn embed(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result<Source, String> {
|
||
let key = (contents_id, *blake3::hash(bytes).as_bytes());
|
||
if let Some(r) = self.smart.converted.get(&key) {
|
||
return r.clone();
|
||
}
|
||
let r = self.convert_source(contents_id, file_name, bytes);
|
||
self.smart.converted.insert(key, r.clone());
|
||
r
|
||
}
|
||
|
||
fn convert_source(&mut self, contents_id: SmartContentsId, file_name: &str, bytes: &[u8]) -> Result<Source, String> {
|
||
let (name, data, size, dpi, nested) = if photocraft_format::is_pcraft(bytes) {
|
||
if self.smart.depth >= MAX_NESTING {
|
||
return Err(format!("smart objects nest more than {MAX_NESTING} deep"));
|
||
}
|
||
let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?;
|
||
let (file, warnings) =
|
||
document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE);
|
||
let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?;
|
||
let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a);
|
||
self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}")));
|
||
let size = (f64::from(doc.size.width), f64::from(doc.size.height));
|
||
(format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc))
|
||
} else {
|
||
// Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer.
|
||
let (size, dpi) = source_geometry(file_name, bytes).unwrap_or(((0.0, 0.0), f64::from(self.dpi)));
|
||
(file_name.to_string(), bytes.to_vec(), size, dpi, None)
|
||
};
|
||
let uuid = contents_uuid(contents_id, &data);
|
||
if let Some(d) = nested {
|
||
self.smart.docs.insert(uuid.clone(), d);
|
||
}
|
||
if !self.smart.known.contains(&uuid) && !self.smart.add.iter().any(|f| f.uuid == uuid) {
|
||
self.smart.add.push(crate::linked::LinkedFile { uuid: uuid.clone(), file_name: name, bytes: data });
|
||
}
|
||
Ok(Source { uuid, size, dpi })
|
||
}
|
||
|
||
/// Pixels for a fill layer: Photoshop's cached rendering while valid,
|
||
/// otherwise our own rendering of the fill over the canvas.
|
||
fn fill_pixels(&self, l: &Layer, f: &photocraft_doc::Fill) -> Surface {
|
||
if let Some(c) = &l.fill_cache
|
||
&& c.fill == *f
|
||
{
|
||
return c.surface.clone();
|
||
}
|
||
if self.cmyk
|
||
&& let Some(s) =
|
||
photocraft_compose::gradient_fill::render_cmyk_fill(f, self.canvas, photocraft_compose::fill_frame(l, self.canvas), self.fmt.sample)
|
||
{
|
||
return s;
|
||
}
|
||
// In the frame the layer's masks give it, like the compositor (masks are stored apart).
|
||
// Readers composite these pixels (ours keeps them as the fill's rendering), so a pattern
|
||
// fill must be rendered from the document's patterns, not left transparent (#1907).
|
||
let buf = photocraft_compose::render_fill_content(l, f, self.canvas, &self.patterns);
|
||
let mut s = Surface::new(self.fmt);
|
||
let vals: Vec<f32> = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&self.fmt, *p)).collect();
|
||
s.write_region(self.canvas, &vals);
|
||
s.prune();
|
||
s
|
||
}
|
||
|
||
/// Write the records of `layers` (and their groups' children). `tick` runs after each one:
|
||
/// it reports progress and returns `false` to stop early (a cancelled save, #3389).
|
||
fn emit(&mut self, layers: &[Layer], tick: &mut dyn FnMut() -> bool) {
|
||
for l in layers {
|
||
let extra = self.content_blocks(l);
|
||
match &l.content {
|
||
LayerContent::Group(g) => {
|
||
let id = self.fresh_id();
|
||
self.records.push(LayerRecord {
|
||
channels: self.empty_channels(),
|
||
blending_ranges: BlendingRanges::full(self.cc),
|
||
name: b"</Layer group>".to_vec(),
|
||
blocks: vec![
|
||
TaggedBlock::unicode_name("</Layer group>"),
|
||
TaggedBlock::layer_id(id),
|
||
TaggedBlock::section_divider(SectionType::BoundingDivider, None, None),
|
||
],
|
||
..Default::default()
|
||
});
|
||
self.emit(&g.children, tick);
|
||
let kind = if g.expanded { SectionType::OpenFolder } else { SectionType::ClosedFolder };
|
||
let lsct = TaggedBlock::section_divider(kind, Some(PsdBlend::from_key(l.blend.psd_key())), None);
|
||
let r = self.record(l, std::iter::once(lsct).chain(extra).collect(), None);
|
||
self.records.push(r);
|
||
}
|
||
LayerContent::Raster(s) => {
|
||
let r = self.record(l, extra, Some(s));
|
||
self.records.push(r);
|
||
}
|
||
LayerContent::Adjustment(_) => {
|
||
let r = self.record(l, extra, None);
|
||
self.records.push(r);
|
||
}
|
||
LayerContent::Fill(f) => {
|
||
let px = self.fill_pixels(l, f);
|
||
let r = self.record(l, extra, Some(&px));
|
||
self.records.push(r);
|
||
}
|
||
LayerContent::Text(t) => {
|
||
let r = self.record(l, extra, t.cache.as_ref());
|
||
self.records.push(r);
|
||
}
|
||
LayerContent::Shape(sh) => {
|
||
let r = self.record(l, extra, sh.cache.as_ref());
|
||
self.records.push(r);
|
||
}
|
||
LayerContent::Smart(sm) => {
|
||
let r = self.record(l, extra, sm.cache.as_ref());
|
||
self.records.push(r);
|
||
}
|
||
}
|
||
if !tick() {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resource 1026 (Layer › Link Layers): one u16 group id per layer record, in the order
|
||
/// [`Ex::emit`] writes records (a group's bounding divider, its children, then the group).
|
||
/// Imported ids that fit in a u16 are kept, so unchanged files write the same bytes; otherwise
|
||
/// groups are renumbered by first appearance. `None` when no layer is linked.
|
||
fn link_group_resource(layers: &[Layer]) -> Option<Vec<u8>> {
|
||
fn walk(layers: &[Layer], out: &mut Vec<Option<u64>>) {
|
||
for l in layers {
|
||
if let LayerContent::Group(g) = &l.content {
|
||
out.push(None);
|
||
walk(&g.children, out);
|
||
}
|
||
out.push(l.link_group);
|
||
}
|
||
}
|
||
let mut per = Vec::new();
|
||
walk(layers, &mut per);
|
||
if per.iter().all(Option::is_none) {
|
||
return None;
|
||
}
|
||
let fits = per.iter().flatten().all(|&g| (1..=u64::from(u16::MAX)).contains(&g));
|
||
let mut seen: Vec<u64> = Vec::new();
|
||
let ids = per.iter().map(|g| match *g {
|
||
None => 0u16,
|
||
Some(g) if fits => g as u16,
|
||
Some(g) => {
|
||
let i = seen.iter().position(|&s| s == g).unwrap_or_else(|| {
|
||
seen.push(g);
|
||
seen.len() - 1
|
||
});
|
||
u16::try_from(i + 1).unwrap_or(u16::MAX)
|
||
}
|
||
});
|
||
Some(ids.flat_map(u16::to_be_bytes).collect())
|
||
}
|
||
|
||
/// Whether the layer's effects still equal what its preserved `lmfx`/`lfx2`
|
||
/// decodes to. `None` when there is nothing preserved or it can't be decoded.
|
||
/// Block keys a smart object's `psd_raw` may be written under, in priority order. The data says
|
||
/// which it is: `PlLd` holds a `plcL` structure, `SoLd`/`SoLE` a `soLD` one (Adobe PSD spec,
|
||
/// "Placed Layer" and "Placed Layer Data"). Writing one under the other's key makes the block
|
||
/// unreadable.
|
||
fn smart_keys(data: Option<&[u8]>) -> &'static [&'static [u8; 4]] {
|
||
match data.and_then(|d| d.get(..4)) {
|
||
Some(b"plcL") => &[b"PlLd"],
|
||
Some(b"soLD") => &[b"SoLd", b"SoLE"],
|
||
_ => &[b"SoLd", b"PlLd", b"SoLE"],
|
||
}
|
||
}
|
||
|
||
fn effects_unchanged(l: &Layer) -> Option<bool> {
|
||
let src = l.psd_blocks.iter().find(|(k, _)| k == b"lmfx").map(|(_, d)| d.clone()).or_else(|| l.effects.psd_raw.clone())?;
|
||
let (m, items) = crate::effects_map::parse_lfx2(&src)?;
|
||
Some(m == l.effects.enabled && items == l.effects.items)
|
||
}
|
||
|
||
fn legacy_name(s: &str) -> Vec<u8> {
|
||
s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect()
|
||
}
|
||
|
||
pub(crate) fn unicode_names_resource(names: &[&str]) -> Vec<u8> {
|
||
let mut v = Vec::new();
|
||
for n in names {
|
||
let units: Vec<u16> = n.encode_utf16().chain(std::iter::once(0)).collect();
|
||
v.extend_from_slice(&(units.len() as u32).to_be_bytes());
|
||
for u in units {
|
||
v.extend_from_slice(&u.to_be_bytes());
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec<u8> {
|
||
let mut v = Vec::new();
|
||
for n in names {
|
||
let b = legacy_name(n);
|
||
v.push(b.len() as u8);
|
||
v.extend_from_slice(&b);
|
||
}
|
||
v
|
||
}
|
||
|
||
fn guides_resource(doc: &Document) -> Vec<u8> {
|
||
let mut v = Vec::new();
|
||
v.extend_from_slice(&1u32.to_be_bytes());
|
||
v.extend_from_slice(&576u32.to_be_bytes());
|
||
v.extend_from_slice(&576u32.to_be_bytes());
|
||
let n = doc.guides.horizontal.len() + doc.guides.vertical.len();
|
||
v.extend_from_slice(&(n as u32).to_be_bytes());
|
||
for (list, dir) in [(&doc.guides.vertical, 0u8), (&doc.guides.horizontal, 1u8)] {
|
||
for p in list {
|
||
v.extend_from_slice(&((p * 32.0).round() as i32).to_be_bytes());
|
||
v.push(dir);
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
fn cmyk_of(fmt: &PixelFormat) -> bool {
|
||
fmt.mode == ColorMode::Cmyk
|
||
}
|
||
|
||
/// The merged image's planes (colour channels, then alpha), big-endian, matted against white
|
||
/// when `matte`; with whether any pixel needs the alpha channel and whether any is below 1.
|
||
/// `ctl` gets the fraction of rows done after each band; once it is cancelled the rest of the
|
||
/// image is left blank (the caller discards it).
|
||
fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl: &Interrupt) -> (Vec<u8>, bool, bool) {
|
||
let sample = fmt.sample;
|
||
let cc = fmt.mode.color_channels();
|
||
let bps = sample.bytes();
|
||
let n = doc.size.area() as usize;
|
||
let plane = n * bps;
|
||
let mut planes = vec![0u8; plane * (cc + 1)];
|
||
let (mut has_alpha, mut translucent) = (false, false);
|
||
let white = photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0]);
|
||
let canvas = doc.bounds();
|
||
let w = canvas.width() as usize;
|
||
let space = photocraft_compose::cmyk_space(doc);
|
||
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
|
||
let rows = canvas.height().max(1) as f32;
|
||
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
|
||
if ctl.cancelled() {
|
||
return Err(());
|
||
}
|
||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||
has_alpha |= band.px.iter().any(|p| match sample {
|
||
SampleType::U8 => q255(p[3]) < 255,
|
||
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||
SampleType::F32 => p[3] != 1.0,
|
||
});
|
||
translucent |= band.px.iter().any(|p| p[3] < 1.0);
|
||
let start = (band.rect.y0 - canvas.y0) as usize * w;
|
||
// Converted and encoded on all cores, then copied into each plane.
|
||
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
|
||
// The composite came through the document's CMYK profile: convert back through it too.
|
||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||
let mut out: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * bps); cc + 1];
|
||
let mut v = [0.0f32; 5];
|
||
for p in &band.px[range.clone()] {
|
||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||
for c in 0..=cc {
|
||
// Matte against white like Photoshop (see `pixels::matte`).
|
||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||
encode_be(x, sample, &mut out[c]);
|
||
}
|
||
}
|
||
(range.start, out)
|
||
})
|
||
});
|
||
for (at, part) in parts {
|
||
for (c, src) in part.iter().enumerate() {
|
||
let o = c * plane + (start + at) * bps;
|
||
planes[o..o + src.len()].copy_from_slice(src);
|
||
}
|
||
}
|
||
ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows);
|
||
Ok(())
|
||
});
|
||
(planes, has_alpha, translucent)
|
||
}
|
||
|
||
/// Converts a document to a PSD file model (see [`document_to_psd_with`]).
|
||
pub fn document_to_psd(doc: &Document) -> PsdFile {
|
||
document_to_psd_with(doc, &PsdExportOptions::default()).0
|
||
}
|
||
|
||
/// Converts a document to a PSD/PSB file model, returning warnings about
|
||
/// anything that could not be represented. The merged composite is rendered
|
||
/// with `photocraft_compose::flatten`.
|
||
pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile, Vec<String>) {
|
||
document_to_psd_nested(doc, opts, 0, &Interrupt::NONE)
|
||
}
|
||
|
||
/// [`document_to_psd_with`] for a background save (#3389): reports progress through `ctl` after
|
||
/// each layer record and each band of the merged composite. Once `ctl` is cancelled it stops
|
||
/// early and returns an **incomplete** model: callers check `ctl` and discard it.
|
||
pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOptions, ctl: &Interrupt) -> (PsdFile, Vec<String>) {
|
||
document_to_psd_nested(doc, opts, 0, ctl)
|
||
}
|
||
|
||
/// How much of a PSD conversion the merged composite weighs, in layer records: rendering it
|
||
/// (all layers composited) and encoding its planes cost about two full-canvas layers.
|
||
const MERGED_WEIGHT: f32 = 2.0;
|
||
/// The part of the merged composite's share filled once its bands are rendered and converted; the
|
||
/// rest is compressing its planes.
|
||
const MERGED_RENDERED: f32 = 0.8;
|
||
|
||
/// Adds headroom for compressed channel streams and their per-row length tables.
|
||
fn estimate_encoded_size(raw_bytes: u64, rows: u64, structural_bytes: u64) -> Option<u64> {
|
||
raw_bytes.checked_add(raw_bytes.checked_add(63)?.checked_div(64)?)?.checked_add(rows.checked_mul(8)?)?.checked_add(structural_bytes)
|
||
}
|
||
|
||
fn add_plane_estimate(raw_bytes: &mut u64, rows: &mut u64, width: u64, height: u64, channels: u64, bytes_per_sample: u64) -> Option<()> {
|
||
let plane_bytes = width.checked_mul(height)?.checked_mul(channels)?.checked_mul(bytes_per_sample)?;
|
||
let plane_rows = height.checked_mul(channels)?;
|
||
*raw_bytes = raw_bytes.checked_add(plane_bytes)?;
|
||
*rows = rows.checked_add(plane_rows)?;
|
||
Some(())
|
||
}
|
||
|
||
fn exceeds_psd_size_limit(estimated_size: Option<u64>) -> bool {
|
||
estimated_size.is_none_or(|size| size > PSD_MAX_FILE_SIZE)
|
||
}
|
||
|
||
fn surface_dimensions(surface: &Surface) -> Option<(u64, u64)> {
|
||
let bounds = surface.content_bounds();
|
||
Some((u64::from(bounds.width()), u64::from(bounds.height())))
|
||
}
|
||
|
||
pub(crate) fn estimate_psd_size(doc: &Document) -> Option<u64> {
|
||
let mut raw_bytes = 0;
|
||
let mut rows = 0;
|
||
let mut structural_bytes = PSD_ESTIMATE_FIXED_OVERHEAD;
|
||
|
||
let width = u64::from(doc.size.width);
|
||
let height = u64::from(doc.size.height);
|
||
|
||
if doc.mode == ColorMode::Multichannel {
|
||
let channels = u64::try_from(doc.channels.len().clamp(1, 56)).ok()?;
|
||
let sample_bytes = u64::try_from(if doc.depth == SampleType::F32 { SampleType::U16.bytes() } else { doc.depth.bytes() }).ok()?;
|
||
add_plane_estimate(&mut raw_bytes, &mut rows, width, height, channels, sample_bytes)?;
|
||
} else {
|
||
let format = doc.pixel_format();
|
||
let color_channels = u64::try_from(format.mode.color_channels()).ok()?;
|
||
let bytes_per_sample = u64::try_from(format.sample.bytes()).ok()?;
|
||
let layer_channels = color_channels.checked_add(1)?;
|
||
|
||
for (_, _, layer) in doc.walk() {
|
||
structural_bytes = structural_bytes.checked_add(PSD_ESTIMATE_LAYER_OVERHEAD)?.checked_add(u64::try_from(layer.name.len()).ok()?)?;
|
||
for (_, data) in &layer.psd_blocks {
|
||
structural_bytes = structural_bytes.checked_add(u64::try_from(data.len()).ok()?)?;
|
||
}
|
||
|
||
let surface = match &layer.content {
|
||
LayerContent::Fill(fill) => {
|
||
let mut fill_width = width;
|
||
let mut fill_height = height;
|
||
if let Some(cache) = &layer.fill_cache
|
||
&& cache.fill == *fill
|
||
{
|
||
let (cache_width, cache_height) = surface_dimensions(&cache.surface)?;
|
||
fill_width = fill_width.max(cache_width);
|
||
fill_height = fill_height.max(cache_height);
|
||
}
|
||
add_plane_estimate(&mut raw_bytes, &mut rows, fill_width, fill_height, layer_channels, bytes_per_sample)?;
|
||
None
|
||
}
|
||
_ => layer.surface(),
|
||
};
|
||
if let Some(surface) = surface {
|
||
let (surface_width, surface_height) = surface_dimensions(surface)?;
|
||
add_plane_estimate(&mut raw_bytes, &mut rows, surface_width, surface_height, layer_channels, bytes_per_sample)?;
|
||
}
|
||
if let Some(mask) = &layer.mask {
|
||
let (mask_width, mask_height) = surface_dimensions(&mask.surface)?;
|
||
add_plane_estimate(&mut raw_bytes, &mut rows, mask_width, mask_height, 1, bytes_per_sample)?;
|
||
}
|
||
if let LayerContent::Smart(smart) = &layer.content
|
||
&& let photocraft_doc::SmartSource::Embedded { bytes, .. } = &smart.source
|
||
{
|
||
structural_bytes = structural_bytes.checked_add(u64::try_from(bytes.len()).ok()?)?;
|
||
}
|
||
}
|
||
|
||
let max_extra_channels = 56usize.saturating_sub(format.mode.color_channels() + 1);
|
||
let mut extra_channels = doc.channels.len().min(max_extra_channels);
|
||
if doc.quick_mask.is_some() && extra_channels < max_extra_channels {
|
||
extra_channels += 1;
|
||
}
|
||
let merged_channels = color_channels.checked_add(1)?.checked_add(u64::try_from(extra_channels).ok()?)?;
|
||
add_plane_estimate(&mut raw_bytes, &mut rows, width, height, merged_channels, bytes_per_sample)?;
|
||
for channel in doc.channels.iter().take(extra_channels) {
|
||
structural_bytes = structural_bytes.checked_add(u64::try_from(channel.name.len()).ok()?)?;
|
||
}
|
||
}
|
||
|
||
structural_bytes = structural_bytes
|
||
.checked_add(u64::try_from(doc.icc_profile.as_ref().map_or(0, |profile| profile.len())).ok()?)?
|
||
.checked_add(u64::try_from(doc.metadata.xmp.as_ref().map_or(0, String::len)).ok()?)?
|
||
.checked_add(u64::try_from(doc.metadata.exif.as_ref().map_or(0, |data| data.len())).ok()?)?;
|
||
for (_, name, data) in &doc.metadata.psd_resources {
|
||
structural_bytes = structural_bytes.checked_add(u64::try_from(name.len()).ok()?)?.checked_add(u64::try_from(data.len()).ok()?)?;
|
||
}
|
||
for (_, _, data) in &doc.metadata.psd_global_blocks {
|
||
structural_bytes = structural_bytes.checked_add(u64::try_from(data.len()).ok()?)?;
|
||
}
|
||
|
||
estimate_encoded_size(raw_bytes, rows, structural_bytes)
|
||
}
|
||
|
||
pub(crate) fn psd_version(doc: &Document, force_psb: bool, size_exceeds_limit: bool) -> Version {
|
||
let exceeds_dimensions = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||
if force_psb || exceeds_dimensions || size_exceeds_limit { Version::Psb } else { Version::Psd }
|
||
}
|
||
|
||
pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool {
|
||
exceeds_psd_size_limit(estimate_psd_size(doc))
|
||
}
|
||
|
||
/// [`document_to_psd_with`] for a document embedded `depth` smart objects deep.
|
||
fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec<String>) {
|
||
if doc.mode == ColorMode::Multichannel {
|
||
return crate::multichannel_map::document_to_psd(doc, opts.force_psb);
|
||
}
|
||
let fmt = doc.pixel_format();
|
||
let sample = fmt.sample;
|
||
let cc = fmt.mode.color_channels();
|
||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||
let size_exceeds_limit = !opts.force_psb && !big && psd_size_exceeds_limit(doc);
|
||
let version = psd_version(doc, opts.force_psb, size_exceeds_limit);
|
||
let mut ex = Ex {
|
||
fmt,
|
||
dpi: doc.resolution_dpi,
|
||
text_styles: doc.text_styles.clone(),
|
||
text_index: Default::default(),
|
||
mask_fmt: PixelFormat::new(ColorMode::Grayscale, sample, false),
|
||
cc,
|
||
cmyk: fmt.mode == ColorMode::Cmyk,
|
||
version,
|
||
next_id: 0,
|
||
layer_ids: Default::default(),
|
||
canvas: doc.bounds(),
|
||
records: Vec::new(),
|
||
warnings: Vec::new(),
|
||
guides: doc.guides.clone(),
|
||
comps: (!crate::comps_map::comps_unchanged(doc)).then(|| (doc.layer_comps.clone(), doc.last_document_state.clone())),
|
||
smart: SmartOut::new(doc, depth),
|
||
patterns: doc.patterns.clone(),
|
||
};
|
||
if big && !opts.force_psb {
|
||
ex.warnings.push("document exceeds 30000 px; written as PSB".into());
|
||
} else if size_exceeds_limit && !opts.force_psb {
|
||
ex.warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into());
|
||
}
|
||
if fmt.mode != doc.mode {
|
||
ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode));
|
||
}
|
||
// Assign layer ids up front (stable across round trips): keep unique
|
||
// `psd_id`s, number the rest after them; dividers come after all.
|
||
{
|
||
let walk = doc.walk();
|
||
let mut used = std::collections::HashSet::new();
|
||
for (_, _, l) in &walk {
|
||
if let Some(id) = l.psd_id
|
||
&& used.insert(id)
|
||
{
|
||
ex.layer_ids.insert(l.id, id);
|
||
}
|
||
}
|
||
let mut next = 1u32;
|
||
for (_, _, l) in &walk {
|
||
if let std::collections::hash_map::Entry::Vacant(e) = ex.layer_ids.entry(l.id) {
|
||
while used.contains(&next) {
|
||
next += 1;
|
||
}
|
||
used.insert(next);
|
||
e.insert(next);
|
||
}
|
||
}
|
||
ex.next_id = used.iter().copied().max().unwrap_or(0);
|
||
// Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the
|
||
// number it already names — its `Txt2` object must sit at that slot for `apply_txt2` —
|
||
// and new layers get the next free number.
|
||
let mut used_txt = std::collections::HashSet::new();
|
||
let mut next_txt = 0i32;
|
||
ex.text_index = Default::default();
|
||
for (_, _, l) in &walk {
|
||
let LayerContent::Text(t) = &l.content else {
|
||
continue;
|
||
};
|
||
let kept = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d));
|
||
let index = match kept {
|
||
Some(i) if used_txt.insert(i) => i,
|
||
_ => {
|
||
while used_txt.contains(&next_txt) {
|
||
next_txt += 1;
|
||
}
|
||
used_txt.insert(next_txt);
|
||
next_txt
|
||
}
|
||
};
|
||
ex.text_index.insert(l.id, index);
|
||
}
|
||
}
|
||
// Progress: one unit per layer record, then the merged composite (#3389).
|
||
let records = doc.walk().len() as f32;
|
||
let units = records + MERGED_WEIGHT;
|
||
let layers_share = records / units;
|
||
let mut done = 0.0f32;
|
||
let mut tick = || {
|
||
done += 1.0;
|
||
ctl.progress(done / units);
|
||
!ctl.cancelled()
|
||
};
|
||
let space = photocraft_compose::cmyk_space(doc);
|
||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers, &mut tick));
|
||
|
||
// Merged composite, rendered and encoded in bands (no full-size float composite). Matting
|
||
// against white only changes pixels with alpha < 1; if some are slightly translucent but all
|
||
// round to opaque (so no alpha channel is written), encode once more without the matte.
|
||
let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED;
|
||
let (mut planes, has_alpha, translucent) =
|
||
crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl));
|
||
if !has_alpha && translucent && opts.merged_matte {
|
||
planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0;
|
||
}
|
||
let n = doc.size.area() as usize;
|
||
if !has_alpha {
|
||
planes.truncate(cc * n * sample.bytes());
|
||
}
|
||
let canvas = doc.bounds();
|
||
let max_extra = 56 - cc - usize::from(has_alpha);
|
||
if doc.channels.len() > max_extra {
|
||
ex.warnings.push(format!("only {max_extra} alpha channels fit in PSD; the rest were dropped"));
|
||
}
|
||
let mut extra: Vec<_> = doc.channels.iter().take(max_extra).collect();
|
||
// Saved in Quick Mask mode: the mask is written as the last extra channel and flagged by
|
||
// resource 1022 (quick mask info: channel id, initially-empty flag), as Photoshop does.
|
||
let quick_mask_id = match &doc.quick_mask {
|
||
Some(q) if extra.len() < max_extra => {
|
||
extra.push(q);
|
||
Some((cc + usize::from(has_alpha) + extra.len() - 1) as u16)
|
||
}
|
||
Some(_) => {
|
||
ex.warnings.push("Quick Mask was dropped because the PSD channel limit was reached".into());
|
||
None
|
||
}
|
||
None => None,
|
||
};
|
||
for a in &extra {
|
||
let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() };
|
||
let bytes = s.to_interleaved(canvas);
|
||
planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0));
|
||
}
|
||
let channels = (cc + usize::from(has_alpha) + extra.len()) as u16;
|
||
let header = Header::new(version, doc.size.width, doc.size.height, channels, psd_depth(sample), psd_mode(fmt.mode));
|
||
let mcomp = if sample == SampleType::F32 { Compression::Raw } else { opts.merged_compression };
|
||
let image_data = ImageData::encode(mcomp, &planes, &header)
|
||
.or_else(|_| ImageData::encode(Compression::Raw, &planes, &header))
|
||
.unwrap_or(ImageData { compression: Compression::Raw, data: planes });
|
||
ctl.progress(1.0);
|
||
|
||
// Resources.
|
||
let mut resources = vec![
|
||
ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()),
|
||
ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()),
|
||
ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()),
|
||
];
|
||
if let Some(icc) = &doc.icc_profile {
|
||
resources.push(ImageResource::new(ids::ICC_PROFILE, icc.to_vec()));
|
||
}
|
||
if !doc.guides.horizontal.is_empty() || !doc.guides.vertical.is_empty() {
|
||
resources.push(ImageResource::new(1032, guides_resource(doc)));
|
||
}
|
||
if !extra.is_empty() {
|
||
let names: Vec<&str> = extra.iter().map(|a| a.name.as_str()).collect();
|
||
resources.push(ImageResource::new(1006, pascal_names_resource(&names)));
|
||
resources.push(ImageResource::new(1045, unicode_names_resource(&names)));
|
||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra)));
|
||
}
|
||
if let Some(id) = quick_mask_id {
|
||
let mut data = id.to_be_bytes().to_vec();
|
||
data.push(0);
|
||
resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data));
|
||
}
|
||
// Paths: saved paths (2000+), the work path (1025), the clipping path name (2999).
|
||
{
|
||
use crate::vector_map::{CLIPPING_PATH, WORK_PATH, path_from_resource, path_to_records};
|
||
let (w, h) = (doc.size.width, doc.size.height);
|
||
let max = (*crate::vector_map::SAVED_PATHS.end() - *crate::vector_map::SAVED_PATHS.start() + 1) as usize;
|
||
if doc.paths.len() > max {
|
||
ex.warnings.push(format!("only {max} saved paths fit in PSD; the rest were dropped"));
|
||
}
|
||
for (i, p) in doc.paths.iter().take(max).enumerate() {
|
||
let data = match &p.psd_raw {
|
||
Some(r) if path_from_resource(r, w, h).as_ref() == Some(&p.path) => r.to_vec(),
|
||
_ => path_to_records(&p.path, w, h).to_bytes(),
|
||
};
|
||
let mut r = ImageResource::new(crate::vector_map::SAVED_PATHS.start() + i as u16, data);
|
||
r.name = legacy_name(&p.name);
|
||
resources.push(r);
|
||
}
|
||
if let Some(wp) = &doc.work_path {
|
||
resources.push(ImageResource::new(WORK_PATH, path_to_records(wp, w, h).to_bytes()));
|
||
}
|
||
let raw_clip = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == CLIPPING_PATH);
|
||
let raw_name = raw_clip.map(|(_, _, d)| {
|
||
let n = usize::from(d.first().copied().unwrap_or(0));
|
||
d.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default()
|
||
});
|
||
if let Some(c) = &doc.clipping_path
|
||
&& raw_name.as_deref() != Some(c.name.as_str())
|
||
{
|
||
// Pascal name, then flatness as 16.16 fixed (best effort; see vector_map docs).
|
||
let mut data = vec![0u8];
|
||
let name = legacy_name(&c.name);
|
||
data[0] = name.len() as u8;
|
||
data.extend_from_slice(&name);
|
||
data.extend_from_slice(&((c.flatness.max(0.0) * 65536.0) as u32).to_be_bytes());
|
||
resources.push(ImageResource::new(CLIPPING_PATH, data));
|
||
}
|
||
}
|
||
if let Some(groups) = link_group_resource(&doc.layers) {
|
||
resources.push(ImageResource::new(ids::LAYER_GROUP_INFO, groups));
|
||
}
|
||
// The pixels are saved as they are shown: never let a reader rotate them again. The XMP and
|
||
// EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it (#1691).
|
||
let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0);
|
||
if let Some(x) = &doc.metadata.xmp {
|
||
resources.push(ImageResource::new(ids::XMP, photocraft_codecs::export_xmp(x, ppi).as_bytes().to_vec()));
|
||
}
|
||
if let Some(e) = &doc.metadata.exif {
|
||
resources.push(ImageResource::new(ids::EXIF, photocraft_codecs::export_exif(e, ppi).into_owned()));
|
||
}
|
||
let mut global_blocks = Vec::new();
|
||
let preserved = ex.smart.finish(crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc)));
|
||
// The type layers' `Txt2` is regenerated (the auto-kern modes live there, and a preserved
|
||
// block goes stale as text is edited): one object per `TextIndex`, at the slot each layer's
|
||
// `TySh` names (unused slots stay empty; see `photocraft_text::psd::build_txt2`). The block
|
||
// it replaces is handed over so a kept object keeps everything the file held beyond its text
|
||
// and style runs (Photoshop's `/21 /1` glyph pen positions, …).
|
||
let text_layers: Vec<(i32, &photocraft_doc::TextLayer)> = doc
|
||
.walk()
|
||
.into_iter()
|
||
.filter_map(|(_, _, l)| match &l.content {
|
||
LayerContent::Text(t) => Some((ex.text_index.get(&l.id).copied().unwrap_or(0), t)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let previous = (!text_layers.is_empty()).then(|| preserved.iter().find(|(_, key, _)| key == b"Txt2").map(|(_, _, data)| data.to_vec())).flatten();
|
||
// A fresh document of ordinary (Metrics) text has nothing to put in `Txt2`, and Photoshop never
|
||
// writes its empty `<< /0 << >> >>` stub — readers such as Photopea reject it (#3514). Keep the
|
||
// block when the file had one (round-trip its pen positions/extras) or a layer is Optical (the
|
||
// one mode EngineData can't carry; #1348).
|
||
let txt2 =
|
||
(previous.is_some() || photocraft_text::psd::needs_txt2(&text_layers)).then(|| photocraft_text::psd::build_txt2(&text_layers, previous.as_deref()));
|
||
for (sig, key, data) in preserved {
|
||
if txt2.is_some() && key == *b"Txt2" {
|
||
continue;
|
||
}
|
||
// A kept layer block (one the import could not read, #2700) describes layers the
|
||
// document doesn't have. The document's own layers are written below, and a file holds
|
||
// one layer list: a second `Lr16`/`Lr32` would contradict it.
|
||
if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
|
||
continue;
|
||
}
|
||
let mut tb = TaggedBlock::new(key, data.to_vec());
|
||
tb.signature = sig;
|
||
// Photoshop pads document-level (global) blocks to a multiple of 4, and readers such as
|
||
// psd-tools step to the next block that way: an even pad after `CAI ` (77 bytes)
|
||
// misaligned every block after it (#200).
|
||
tb.padding = Some(vec![0; (4 - data.len() % 4) % 4]);
|
||
global_blocks.push(tb);
|
||
}
|
||
if let Some(txt2) = txt2 {
|
||
let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone());
|
||
tb.signature = *b"8BIM";
|
||
tb.padding = Some(vec![0; (4 - txt2.len() % 4) % 4]);
|
||
global_blocks.push(tb);
|
||
}
|
||
let comps_resource = ex.comps.is_some().then(|| crate::comps_map::write_comps_resource(doc));
|
||
let (slices_resource, slices_warning) = crate::slices_map::export_resource(doc, &ex.layer_ids);
|
||
if let Some(warning) = slices_warning {
|
||
ex.warnings.push(warning);
|
||
}
|
||
for (id, name, data) in &doc.metadata.psd_resources {
|
||
// Layer comps: the preserved list while unchanged, else regenerated (or dropped) below.
|
||
if *id == crate::comps_map::LAYER_COMPS && comps_resource.is_some() {
|
||
continue;
|
||
}
|
||
// Slices: the preserved resource while unchanged, else regenerated (or dropped) below.
|
||
if *id == crate::slices_map::SLICES && slices_resource.is_some() {
|
||
continue;
|
||
}
|
||
// Measurement scale / count resources: only while they still describe the document.
|
||
if !crate::annotations_map::keep_resource(doc, *id) {
|
||
continue;
|
||
}
|
||
// A preserved clipping-path resource is only valid while it names the current one.
|
||
if *id == crate::vector_map::CLIPPING_PATH {
|
||
let n = usize::from(data.first().copied().unwrap_or(0));
|
||
let raw_name = data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
|
||
if doc.clipping_path.as_ref().is_none_or(|c| c.name != raw_name) {
|
||
continue;
|
||
}
|
||
}
|
||
let mut r = ImageResource::new(*id, data.to_vec());
|
||
r.name = legacy_name(name);
|
||
resources.push(r);
|
||
}
|
||
if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) {
|
||
resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data));
|
||
}
|
||
if let Some(Some(data)) = comps_resource {
|
||
resources.push(ImageResource::new(crate::comps_map::LAYER_COMPS, data));
|
||
}
|
||
if let Some(Some(data)) = slices_resource {
|
||
resources.push(ImageResource::new(crate::slices_map::SLICES, data));
|
||
}
|
||
resources.push(version_info_resource(true));
|
||
|
||
let has_layers = !ex.records.is_empty();
|
||
let placement = match (has_layers, sample) {
|
||
(true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr16", padding: None },
|
||
(true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index: 0, signature: *b"8BIM", key: *b"Lr32", padding: None },
|
||
_ => LayerInfoPlacement::Section,
|
||
};
|
||
let records = std::mem::take(&mut ex.records);
|
||
let mut layer_info = has_layers.then_some(LayerInfo { merged_alpha: has_alpha, layers: records, padding: None });
|
||
if let Some(info) = &mut layer_info {
|
||
// Photoshop pads the layer info to 4 bytes (an even pad misaligns readers after a global
|
||
// `Lr16`/`Lr32` block, #200). A length that cannot be computed keeps the even default.
|
||
if info.pad_to(version, 4).is_err() {
|
||
info.padding = None;
|
||
}
|
||
}
|
||
// 32-bit files carry Photoshop's HDR toning records as Color Mode Data; Photoshop will not
|
||
// open a 32-bit file without them (#291).
|
||
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
|
||
let file = PsdFile {
|
||
header,
|
||
color_mode_data,
|
||
resources,
|
||
layer_info,
|
||
layer_info_placement: placement,
|
||
global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default),
|
||
global_blocks,
|
||
layer_mask_trailing: Vec::new(),
|
||
image_data,
|
||
};
|
||
(file, ex.warnings)
|
||
}
|
||
|
||
/// PSD mask parameters for a user mask's and a vector mask's (density, feather); `None` when
|
||
/// every value is the default (density 1, feather 0).
|
||
fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option<MaskParameters> {
|
||
let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d)));
|
||
let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f)));
|
||
let p =
|
||
MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) };
|
||
let flags = u8::from(p.user_density.is_some())
|
||
| u8::from(p.user_feather.is_some()) << 1
|
||
| u8::from(p.vector_density.is_some()) << 2
|
||
| u8::from(p.vector_feather.is_some()) << 3;
|
||
(flags != 0).then_some(MaskParameters { flags, ..p })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn document(width: u32, height: u32) -> Document {
|
||
Document::new("size estimate", photocraft_geom::Size::new(width, height), ColorMode::Rgb, SampleType::U8)
|
||
}
|
||
|
||
#[test]
|
||
fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() {
|
||
// #2700: a 16-bit file whose Lr16 block claims five layers but holds none.
|
||
let mut file = photocraft_psd::testgen::merged_only(Version::Psd, PsdMode::Rgb, 16, Compression::Raw, 8, 6);
|
||
file.global_layer_mask = Some(GlobalLayerMask::default());
|
||
file.global_blocks.push(TaggedBlock::new(*b"Lr16", vec![0, 5, 0, 0]));
|
||
let imported = crate::import("broken.psd", &file.to_bytes().unwrap()).unwrap();
|
||
assert!(imported.warnings.iter().any(|w| w.contains("Lr16") && w.contains("could not be read")), "{:?}", imported.warnings);
|
||
|
||
let saved = PsdFile::from_bytes(&document_to_psd(&imported.document).to_bytes().unwrap()).unwrap();
|
||
let ours = matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { key, .. } if key == *b"Lr16");
|
||
let kept = saved.global_blocks.iter().filter(|b| b.key == *b"Lr16").count();
|
||
assert_eq!((ours, kept), (true, 0), "exactly one Lr16 block: the document's own layers");
|
||
assert_eq!(saved.layers().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn photo_filter_psd_is_four_byte_aligned_and_round_trips() {
|
||
let color = [60000u16, 30000, 0].map(|x| x as f32 / 65535.0);
|
||
let adjustment = photocraft_doc::Adjustment::PhotoFilter { color, density: 0.14, preserve_luminosity: true };
|
||
let mut doc =
|
||
Document::with_background("Photo Filter PSD", photocraft_geom::Size::new(32, 24), ColorMode::Rgb, SampleType::U8, photocraft_doc::Color::WHITE);
|
||
let mut layer = Layer::new("Warming Filter", LayerContent::Adjustment(adjustment.clone()));
|
||
doc.layers.push(layer.clone());
|
||
|
||
let assert_phfl = |file: &PsdFile| {
|
||
let records = &file.layer_info.as_ref().unwrap().layers;
|
||
let data =
|
||
records.iter().flat_map(|r| r.blocks.iter()).find(|b| b.key == *b"phfl").map(|b| b.data.as_slice()).expect("Photo Filter adjustment data");
|
||
assert_eq!(data.len(), 20, "PSD layer block length includes four-byte padding");
|
||
assert_eq!(&data[0..2], &[0, 2], "version 2 RGB Photo Filter");
|
||
assert_eq!(&data[17..], &[0, 0, 0], "three padding bytes after the 17-byte payload");
|
||
};
|
||
|
||
let exported = document_to_psd(&doc);
|
||
assert_phfl(&exported);
|
||
let encoded = exported.to_bytes().unwrap();
|
||
let parsed = PsdFile::from_bytes(&encoded).unwrap();
|
||
assert_phfl(&parsed);
|
||
let (restored, _) = crate::psd_import::psd_to_document(&parsed);
|
||
assert!(restored.layers.iter().any(|l| l.content == LayerContent::Adjustment(adjustment.clone())));
|
||
|
||
// Importing a legacy 18-byte block must still work; saving it again should repair it
|
||
// even when the adjustment values have not changed (normally raw blocks are reused).
|
||
let mut legacy = crate::adjust_map::write(&adjustment)[0].1.clone();
|
||
legacy.truncate(18);
|
||
layer.psd_blocks = vec![(*b"phfl", std::sync::Arc::new(legacy))];
|
||
doc.layers.pop();
|
||
doc.layers.push(layer);
|
||
let repaired = document_to_psd(&doc);
|
||
assert_phfl(&repaired);
|
||
let repaired_bytes = repaired.to_bytes().unwrap();
|
||
assert_phfl(&PsdFile::from_bytes(&repaired_bytes).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn size_limit_boundary_keeps_limit_and_switches_above_it() {
|
||
assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE)));
|
||
assert!(exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE + 1)));
|
||
assert!(exceeds_psd_size_limit(None));
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_size_estimator_rejects_arithmetic_overflow() {
|
||
assert_eq!(estimate_encoded_size(u64::MAX, 0, 0), None);
|
||
assert_eq!(estimate_encoded_size(0, u64::MAX, 0), None);
|
||
assert_eq!(estimate_encoded_size(0, 0, u64::MAX), Some(u64::MAX));
|
||
assert!(exceeds_psd_size_limit(estimate_psd_size(&document(u32::MAX, u32::MAX))));
|
||
}
|
||
|
||
#[test]
|
||
fn large_encoded_size_selects_psb_without_changing_explicit_choice() {
|
||
let large = document(30_000, 30_000);
|
||
assert!(estimate_psd_size(&large).is_some_and(|size| size > PSD_MAX_FILE_SIZE));
|
||
let large_size_exceeds_limit = psd_size_exceeds_limit(&large);
|
||
assert_eq!(psd_version(&large, false, large_size_exceeds_limit), Version::Psb);
|
||
|
||
let small = document(1, 1);
|
||
let small_size_exceeds_limit = psd_size_exceeds_limit(&small);
|
||
assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd);
|
||
assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb);
|
||
}
|
||
}
|