Budget PSD pattern expansion and bound multichannel conversion (#10541)

* Stream PSD user and real masks into tile rows

* Correct streamed-mask fixture metadata and test lint issues

* Budget PSD pattern expansion and bound multichannel conversion

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
Khawar Zamman Wani
2026-11-01 20:30:21 +05:30
committed by GitHub
parent 84732d09bc
commit 209bce8d46
9 changed files with 355 additions and 55 deletions
+1 -1
View File
@@ -349,7 +349,7 @@ pub struct ImportOptions {
/// The demosaic method camera raws are developed with.
pub demosaic: raw::Demosaic,
/// PSD layer/mask/merged pixel allowance, including conversion workspace. May lower the
/// default cap; does not cover metadata, patterns or smart-filter caches.
/// default cap; does not cover metadata or smart-filter caches.
pub psd_max_pixel_bytes: u64,
}
+63 -26
View File
@@ -26,50 +26,70 @@ const ALPHA_IDENTIFIERS: u16 = 1053;
const LAYER_ONLY_RESOURCES: [u16; 4] = [ids::LAYER_STATE, ids::LAYER_GROUP_INFO, 1069, 1072];
/// Value of sample `i` of a big-endian plane.
fn decode(plane: &[u8], i: usize, s: SampleType) -> f32 {
match s {
SampleType::U8 => f32::from(plane[i]) / 255.0,
SampleType::U16 => f32::from(u16::from_be_bytes([plane[2 * i], plane[2 * i + 1]])) / 65535.0,
SampleType::F32 => f32::from_be_bytes([plane[4 * i], plane[4 * i + 1], plane[4 * i + 2], plane[4 * i + 3]]),
}
fn decode(plane: &[u8], i: usize, s: SampleType) -> Option<f32> {
let start = i.checked_mul(s.bytes())?;
let bytes = plane.get(start..start.checked_add(s.bytes())?)?;
Some(match s {
SampleType::U8 => f32::from(*bytes.first()?) / 255.0,
SampleType::U16 => f32::from(u16::from_be_bytes(bytes.try_into().ok()?)) / 65535.0,
SampleType::F32 => f32::from_be_bytes(bytes.try_into().ok()?),
})
}
/// Fills `doc` (already in Multichannel mode at the file depth) from the merged planes `all`.
pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[String], warnings: &mut Vec<String>) {
pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[String], warnings: &mut Vec<String>) -> Result<(), crate::IoError> {
let h = &file.header;
let depth = doc.depth;
let (w, hh) = (h.width as usize, h.height as usize);
let n = w * hh;
let plane = h.row_bytes() * hh;
let canvas = Rect::new(0, 0, h.width as i32, h.height as i32);
let invalid = || crate::IoError::Unsupported("multichannel pixel size overflow".into());
let plane = h.row_bytes().checked_mul(hh).ok_or_else(invalid)?;
let pixels = w.checked_mul(hh).ok_or_else(invalid)?;
if pixels.checked_mul(depth.bytes()).ok_or_else(invalid)? != plane {
return Err(crate::IoError::Unsupported("multichannel channel depth does not match its pixel layout".into()));
}
let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false);
for k in 0..usize::from(h.channels) {
let Some(p) = all.get(k * plane..(k + 1) * plane) else {
let start = k.checked_mul(plane).ok_or_else(invalid)?;
let end = start.checked_add(plane).ok_or_else(invalid)?;
if all.get(start..end).is_none() {
warnings.push(format!("multichannel: channel {} is missing", k + 1));
break;
};
let vals: Vec<f32> = (0..n).map(|i| 1.0 - decode(p, i, depth)).collect();
let mut s = Surface::new(fmt);
s.write_region(canvas, &vals);
s.prune();
}
let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Channel {}", k + 1));
// Without DisplayInfo the channels print in black, as Photoshop shows them. DisplayInfo
// turns alpha-type channels (selected / masked areas) back into alpha channels.
doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) });
doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, Surface::new(fmt)) });
}
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
crate::channel_map::apply_display_info(&r.data, true, 0, &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, 0, &mut doc.channels);
}
// Alpha-type planes are stored as-is, not as ink: undo the inversion for them.
for ch in doc.channels.iter_mut().filter(|c| c.spot.is_none()) {
let vals: Vec<f32> = ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()).map(|v| 1.0 - v[0]).collect();
let mut s = Surface::new(fmt);
s.write_region(canvas, &vals);
s.prune();
ch.surface = s;
for (k, ch) in doc.channels.iter_mut().enumerate() {
let start = k.checked_mul(plane).ok_or_else(invalid)?;
let p = all.get(start..start.checked_add(plane).ok_or_else(invalid)?).ok_or_else(invalid)?;
// DisplayInfo is known before conversion: alpha channels need no inversion or
// second full-plane readback. One tile row is the only conversion buffer.
for y in (0..hh).step_by(photocraft_geom::TILE_SIZE as usize) {
let bottom = (y + photocraft_geom::TILE_SIZE as usize).min(hh);
let samples = w.checked_mul(bottom - y).ok_or_else(invalid)?;
let len = samples.checked_mul(depth.bytes()).ok_or_else(invalid)?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(len).map_err(|_| crate::IoError::OutOfMemory("not enough memory for multichannel pixels".into()))?;
for i in y.checked_mul(w).ok_or_else(invalid)?..bottom.checked_mul(w).ok_or_else(invalid)? {
let v = decode(p, i, depth).ok_or_else(invalid)?;
let v = if ch.spot.is_some() { 1.0 - v } else { v };
match depth {
SampleType::U8 => bytes.push((v.clamp(0.0, 1.0) * 255.0).round() as u8),
SampleType::U16 => bytes.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()),
SampleType::F32 => bytes.extend_from_slice(&v.to_ne_bytes()),
}
}
ch.surface
.try_write_interleaved(Rect::new(0, y as i32, h.width as i32, bottom as i32), &bytes)
.map_err(|e| crate::IoError::OutOfMemory(format!("multichannel pixel allocation: {e}")))?;
}
ch.surface.prune();
}
Ok(())
}
/// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane).
@@ -204,3 +224,20 @@ pub(crate) fn layerless_file(
crate::psd_export::sign_global_blocks(&mut file);
file
}
#[cfg(test)]
mod import_allocation_tests {
use super::*;
#[test]
fn missing_planes_remain_recoverable_without_reading_past_input() {
let file = photocraft_psd::testgen::merged_only(photocraft_psd::Version::Psd, PsdMode::Multichannel, 8, Compression::Raw, 3, 2);
let mut doc = Document::new("Partial", photocraft_geom::Size::new(3, 2), ColorMode::Multichannel, SampleType::U8);
let mut warnings = Vec::new();
import(&file, &[0; 6], &mut doc, &[], &mut warnings).unwrap();
assert_eq!(doc.channels.len(), 1);
assert_eq!(doc.channels[0].surface.read_region(doc.bounds()), vec![1.0; 6]);
assert_eq!(warnings.len(), 1);
assert!(decode(&[], usize::MAX, SampleType::F32).is_none());
}
}
+107 -18
View File
@@ -12,7 +12,7 @@ use photocraft_geom::Rect;
use photocraft_psd::patterns::{PatternBudget, PsdPattern, block_has_user_mask, block_key, mode_channels, parse_pattern_block_with, write_pattern_block};
use photocraft_raster::Surface;
use crate::pixels::{deinterleave, interleave, max_sample, psd_depth, sample_for_depth, zero_sample};
use crate::pixels::{deinterleave, max_sample, psd_depth, sample_for_depth, try_interleave, zero_sample};
/// Global block keys holding patterns.
pub const PATTERN_KEYS: [[u8; 4]; 3] = [*b"Patt", *b"Pat2", *b"Pat3"];
@@ -26,28 +26,67 @@ fn psd_mode(m: ColorMode) -> u32 {
}
}
/// A PSD pattern as a document pattern (indexed patterns become RGB through their palette).
/// A PSD pattern as a document pattern (indexed patterns become RGB through their palette). A
/// malformed pattern, or one that can't be allocated, is `None`; [`try_from_psd`] tells them apart.
pub fn from_psd(p: &PsdPattern) -> Option<Pattern> {
try_from_psd(p).ok().flatten()
}
/// [`from_psd`], with `Ok(None)` for a malformed pattern and `Err` for a failed allocation.
pub fn try_from_psd(p: &PsdPattern) -> Result<Option<Pattern>, String> {
let mut oom = None;
let pattern = pattern_from_psd(p, &mut oom);
match oom {
Some(error) => Err(error),
None => Ok(pattern),
}
}
fn pattern_from_psd(p: &PsdPattern, oom: &mut Option<String>) -> Option<Pattern> {
let (w, h) = (p.width as usize, p.height as usize);
if w == 0 || h == 0 {
if w == 0 || h == 0 || w > 30_000 || h > 30_000 || !matches!(p.depth, 1 | 8 | 16 | 32) {
return None;
}
let n = w.checked_mul(h)?;
let plane_len = w.checked_mul(usize::from(p.depth))?.div_ceil(8).checked_mul(h)?;
if p.channels.iter().chain(p.alpha.iter()).any(|plane| plane.len() != plane_len) {
return None;
}
fn copy_plane(plane: &[u8], oom: &mut Option<String>) -> Option<Vec<u8>> {
let mut out = Vec::new();
reserve(&mut out, plane.len(), oom)?;
out.extend_from_slice(plane);
Some(out)
}
fn reserve(out: &mut Vec<u8>, n: usize, oom: &mut Option<String>) -> Option<()> {
out.try_reserve_exact(n).map_err(|e| *oom = Some(e.to_string())).ok()
}
// 1-bit patterns: expand to 8-bit grey.
let (channels, depth): (Vec<Vec<u8>>, u16) = if p.depth == 1 {
let row = w.div_ceil(8);
let expand = |plane: &Vec<u8>| -> Vec<u8> {
(0..w * h).map(|i| if plane.get((i / w) * row + (i % w) / 8).is_some_and(|b| b & (0x80 >> (i % w % 8)) != 0) { 0 } else { 255 }).collect()
let expand = |plane: &Vec<u8>, oom: &mut Option<String>| -> Option<Vec<u8>> {
let mut out = Vec::new();
reserve(&mut out, n, oom)?;
out.extend((0..n).map(|i| if plane.get((i / w) * row + (i % w) / 8).is_some_and(|b| b & (0x80 >> (i % w % 8)) != 0) { 0 } else { 255 }));
Some(out)
};
(p.channels.iter().map(expand).collect(), 8)
(p.channels.iter().map(|plane| expand(plane, oom)).collect::<Option<_>>()?, 8)
} else {
(p.channels.clone(), p.depth)
(p.channels.iter().map(|plane| copy_plane(plane, oom)).collect::<Option<_>>()?, p.depth)
};
let (mode, channels) = match p.mode {
1 | 8 => (ColorMode::Grayscale, channels),
2 => {
let pal = p.palette.as_deref()?;
let idx = channels.first()?;
let ch: Vec<Vec<u8>> = (0..3).map(|c| idx.iter().map(|i| pal.get(usize::from(*i) * 3 + c).copied().unwrap_or(0)).collect()).collect();
let ch: Vec<Vec<u8>> = (0..3)
.map(|c| {
let mut out = Vec::new();
reserve(&mut out, idx.len(), oom)?;
out.extend(idx.iter().map(|i| pal.get(usize::from(*i) * 3 + c).copied().unwrap_or(0)));
Some(out)
})
.collect::<Option<_>>()?;
(ColorMode::Rgb, ch)
}
4 => (ColorMode::Cmyk, channels),
@@ -69,11 +108,22 @@ pub fn from_psd(p: &PsdPattern) -> Option<Pattern> {
fill.push(max_sample(s));
invert.push(false);
}
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
let mut bytes = match try_interleave(&refs, &fill, n, s, &invert) {
Ok(bytes) => bytes,
Err(photocraft_psd::PsdError::OutOfMemory(error)) => {
*oom = Some(error.to_string());
return None;
}
Err(_) => return None,
};
if mode == ColorMode::Lab && s == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, refs.len(), true);
}
let surface = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), &bytes);
let mut surface = Surface::new(fmt);
if let Err(error) = surface.try_write_interleaved(Rect::new(0, 0, w as i32, h as i32), &bytes) {
*oom = Some(error.to_string());
return None;
}
Some(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface })
}
@@ -119,15 +169,25 @@ pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec<String> {
/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All
/// blocks share one memory budget, so repeating a block can't multiply it.
pub fn from_global_blocks(doc: &Document) -> Vec<Pattern> {
try_from_global_blocks(doc).unwrap_or_default()
}
/// [`from_global_blocks`], failing with the allocation error instead of dropping the pattern it
/// hit, so an import reports that the file could not be opened in full.
pub fn try_from_global_blocks(doc: &Document) -> Result<Vec<Pattern>, String> {
let mut budget = PatternBudget::default();
doc.metadata
.psd_global_blocks
.iter()
.filter(|(_, k, _)| PATTERN_KEYS.contains(k))
.filter_map(|(_, _, d)| parse_pattern_block_with(d, &mut budget).ok())
.flatten()
.filter_map(|p| from_psd(&p))
.collect()
let mut out = Vec::new();
for (_, _, d) in doc.metadata.psd_global_blocks.iter().filter(|(_, k, _)| PATTERN_KEYS.contains(k)) {
let patterns = match parse_pattern_block_with(d, &mut budget) {
Ok(patterns) => patterns,
Err(photocraft_psd::PsdError::OutOfMemory(error)) => return Err(error.to_string()),
Err(_) => continue,
};
for p in patterns {
out.extend(try_from_psd(&p)?);
}
}
Ok(out)
}
/// Global blocks to write: the raw ones while they still match [`Document::patterns`] (see
@@ -307,3 +367,32 @@ mod unpaintable_tests {
assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}");
}
}
#[cfg(test)]
mod allocation_tests {
use super::*;
#[test]
fn malformed_dimensions_and_planes_fail_before_conversion() {
let mut p = PsdPattern {
mode: 1,
width: 9,
height: 2,
name: "Bits".into(),
id: "bits".into(),
palette: None,
depth: 1,
channels: vec![vec![0x80, 0, 0, 0x80]],
alpha: None,
};
let converted = from_psd(&p).unwrap();
assert_eq!(converted.surface.to_interleaved(converted.rect()), [0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0]);
p.channels[0].pop();
assert!(from_psd(&p).is_none());
p.width = u32::MAX;
assert!(from_psd(&p).is_none());
p.width = 9;
p.depth = 7;
assert!(from_psd(&p).is_none());
}
}
+20 -1
View File
@@ -1,5 +1,5 @@
//! Conservative preflight for the main PSD pixel paths. This bounds decoded layer/mask/
//! merged pixels, not parser metadata, patterns or smart-filter caches (separate limits).
//! merged and pattern pixels, not parser metadata or smart-filter caches (separate limits).
//!
//! Reserve the sum of all possible retained tiles and the largest workspace, without credit
//! for pruning or tile sharing. Reserving before any worker starts makes scheduling irrelevant.
@@ -126,6 +126,25 @@ pub(crate) fn check(file: &PsdFile, limit: u64) -> Result<(), IoError> {
let converted = mul(pixels, mul(channels.max(cc + 1), sample * 3 + 12)?)?;
budget.scratch(converted.checked_add(inflate_scratch(merged, 1)?).ok_or_else(overflow)?);
}
let mut patterns = photocraft_psd::patterns::PatternBudget::default();
for block in file.global_blocks.iter().filter(|b| crate::pattern_map::PATTERN_KEYS.contains(&b.key)) {
// Earlier records can decode before a malformed later record discards the block.
let items = photocraft_psd::patterns::pattern_block_memory(&block.data, &mut patterns);
for p in items {
let pixels = mul(u64::from(p.width), u64::from(p.height))?;
let sample = u64::from(p.depth.div_ceil(8));
let color = if p.mode == 2 { 3 } else { photocraft_psd::patterns::mode_channels(p.mode) as u64 };
let alpha = u64::from(p.planes > photocraft_psd::patterns::mode_channels(p.mode));
budget.surface(Rect::from_xywh(0, 0, p.width, p.height), color + alpha, sample)?;
// The block parser returns all planes together. Count them as retained so
// previous converted patterns and later blocks cannot reuse this allowance.
budget.retain(p.decoded_bytes)?;
// Channel clones/bitmap expansion, indexed RGB expansion and interleave.
// Include a source plane during padded channel placement as well.
let converted = mul(pixels, mul((p.planes as u64).max(color + alpha) + color + alpha + 3, sample)?)?;
budget.scratch(converted.checked_add(p.decoded_bytes).ok_or_else(overflow)?);
}
}
budget.check(limit)
}
+50
View File
@@ -124,6 +124,56 @@ fn legacy_conversion_reports_a_rejected_import_instead_of_decoding_it() {
assert!(warnings.iter().any(|warning| warning.contains("pixel memory")));
}
fn pattern_block(mode: u32, depth: u16, width: u32, height: u32) -> photocraft_psd::TaggedBlock {
use photocraft_psd::patterns::{PsdPattern, mode_channels, write_pattern_block};
let len = (width as usize * depth as usize).div_ceil(8) * height as usize;
let p = PsdPattern {
mode,
depth,
width,
height,
name: "Budget pattern".into(),
id: "budget".into(),
palette: (mode == 2).then(|| vec![0; 768]),
channels: vec![vec![0; len]; mode_channels(mode)],
alpha: None,
};
photocraft_psd::TaggedBlock::new(*b"Patt", write_pattern_block(&[p]).unwrap())
}
#[test]
fn pattern_tiles_and_expansion_share_the_import_allowance() {
for (mode, depth) in [(1, 1), (2, 8), (3, 8), (4, 16), (9, 32)] {
let mut file = testgen::small(Version::Psd, Compression::Raw);
let baseline = estimate(&file);
let block = pattern_block(mode, depth, 9, 3);
file.global_blocks.push(block.clone());
let one = estimate(&file);
assert!(one > baseline);
assert!(check(&file, one).is_ok());
assert!(matches!(check(&file, one - 1), Err(IoError::PsdImportBudget { .. })));
let bytes = file.to_bytes().unwrap();
let opts = crate::ImportOptions { psd_max_pixel_bytes: one - 1, ..Default::default() };
assert!(matches!(crate::import_with_options("pattern.psd", &bytes, &opts), Err(IoError::PsdImportBudget { .. })));
assert_eq!(crate::import("pattern.psd", &bytes).unwrap().document.patterns.len(), 1);
file.global_blocks.push(block);
assert!(estimate(&file) > one, "blocks cannot each reuse the allowance");
}
}
#[test]
fn malformed_later_pattern_still_counts_earlier_decodes() {
let mut file = testgen::small(Version::Psd, Compression::Raw);
let baseline = estimate(&file);
let mut block = pattern_block(1, 1, 9, 3);
// An invalid second pattern makes the real parser discard the block, after decoding
// its first record. The preflight must still account for that transient allocation.
block.data.extend_from_slice(&4u32.to_be_bytes());
block.data.extend_from_slice(&99u32.to_be_bytes());
file.global_blocks.push(block);
assert!(estimate(&file) > baseline);
}
/// An unknown colour mode imports as RGB (`import_format`), layers included, so it must pay
/// for them: a layered file can't dodge the allowance by carrying mode 5.
#[test]
+23 -5
View File
@@ -1116,7 +1116,16 @@ pub(crate) fn can_recover_layers(file: &PsdFile) -> bool {
import_format(file).1 && !file.layers().is_empty()
}
fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Document, layered: bool, multichannel: bool, warnings: &mut Vec<String>) -> bool {
/// Reads the document-level metadata; the second value is the allocation failure that stopped a
/// pattern from being read, which fails the open.
fn read_initial_metadata(
file: &PsdFile,
global: &[PsdGlobalBlock],
doc: &mut Document,
layered: bool,
multichannel: bool,
warnings: &mut Vec<String>,
) -> (bool, Option<String>) {
let header = &file.header;
for resource in &file.resources {
match resource.id {
@@ -1174,7 +1183,11 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do
block.key_str()
));
}
doc.patterns = crate::pattern_map::from_global_blocks(doc);
let mut alloc_error = None;
match crate::pattern_map::try_from_global_blocks(doc) {
Ok(patterns) => doc.patterns = patterns,
Err(error) => alloc_error = Some(format!("patterns: {error}")),
}
warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns));
doc.notes = crate::annotations_map::notes_from_blocks(doc);
if crate::annotations_map::anno_unreadable(doc) {
@@ -1184,7 +1197,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do
doc.measurement.scale = scale;
}
let _ = layered;
damaged
(damaged, alloc_error)
}
/// [`psd_to_document_with`] with the merged image already decoded (by
@@ -1231,7 +1244,7 @@ fn psd_to_document_global(
warnings.push(format!("unknown color mode {}; importing as RGB", h.color_mode.as_u16()));
}
let mut doc = Document::new("Untitled", Size::new(h.width, h.height), mode, depth);
let damaged = read_initial_metadata(file, &global, &mut doc, layered, multichannel, &mut warnings);
let (damaged, pattern_error) = read_initial_metadata(file, &global, &mut doc, layered, multichannel, &mut warnings);
let fmt = doc.pixel_format();
let cc = fmt.mode.color_channels();
@@ -1259,6 +1272,9 @@ fn psd_to_document_global(
uniform: Default::default(),
total: file.layers().len(),
};
if let Some(error) = pattern_error {
cx.allocation_failed(error);
}
for (_, key, data) in cx.global.clone().iter().filter(|(_, key, _)| matches!(key, b"FEid" | b"FXid")) {
// Smart-filter caches can be large: a cancelled open stops between blocks.
if ctl.cancelled() {
@@ -1348,7 +1364,9 @@ fn import_decoded_content(import: DecodedImport<'_>, doc: &mut Document, cx: &mu
fn import_multichannel(file: &PsdFile, merged: &Option<photocraft_psd::Result<Vec<u8>>>, doc: &mut Document, cx: &mut Ctx<'_>) {
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, doc, &names, &mut cx.warnings);
if let Err(error) = crate::multichannel_map::import(file, all, doc, &names, &mut cx.warnings) {
cx.fatal_error.get_or_insert(error);
}
}
if !file.layers().is_empty() {
cx.warn(format!("Multichannel documents have no layers: {} layer records were not imported", file.layers().len()));
+28
View File
@@ -223,3 +223,31 @@ fn corpus_multichannel_round_trip_keeps_channels_and_resources() {
let keys = |f: &PsdFile| f.global_blocks.iter().map(|b| b.key).collect::<Vec<_>>();
assert_eq!(keys(&back), keys(&f));
}
#[test]
fn tile_row_conversion_preserves_spot_and_alpha_planes() {
for depth in [8, 16, 32] {
let mut f = testgen::merged_only(Version::Psb, ColorMode::Multichannel, depth, Compression::Rle, 5, 600);
let mut info = 1u32.to_be_bytes().to_vec();
display_record(&mut info, [0, 0, 0], 0, 2);
display_record(&mut info, [65535, 0, 0], 50, 1);
f.resources.push(ImageResource::new(1077, info));
let raw = f.decode_merged().unwrap();
let (doc, _) = psd_to_document(&f);
let sample = usize::from(depth / 8);
let plane = 5 * 600 * sample;
for (k, channel) in doc.channels.iter().enumerate() {
let values = channel.surface.read_region(doc.bounds());
for (i, value) in values.iter().enumerate() {
let bytes = &raw[k * plane + i * sample..k * plane + (i + 1) * sample];
let original = match depth {
8 => f32::from(bytes[0]) / 255.0,
16 => f32::from(u16::from_be_bytes(bytes.try_into().unwrap())) / 65535.0,
_ => f32::from_be_bytes(bytes.try_into().unwrap()),
};
let expected = if channel.spot.is_some() { 1.0 - original } else { original };
assert!((value - expected).abs() < 0.00001, "depth {depth}, channel {k}, pixel {i}");
}
}
}
}
+54 -1
View File
@@ -192,7 +192,28 @@ fn read_vma_body<'a>(r: &mut Rd<'a>) -> Result<&'a [u8]> {
r.take(len)
}
/// Pixel storage needed by a pattern, inspected without decoding channel data.
#[derive(Debug, Clone)]
pub struct PatternMemory {
/// Pattern width in pixels.
pub width: u32,
/// Pattern height in pixels.
pub height: u32,
/// Bits per sample, including packed bitmap samples.
pub depth: u16,
/// PSD colour mode number.
pub mode: u32,
/// All written planes, including any ignored extra arrays.
pub planes: usize,
/// Sum of full padded channel plane sizes.
pub decoded_bytes: u64,
}
fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result<PsdPattern> {
read_pattern_with(r, budget, true, &mut |_| {})
}
fn read_pattern_with(r: &mut Rd, budget: &mut PatternBudget, decode: bool, visit: &mut dyn FnMut(PatternMemory)) -> Result<PsdPattern> {
let PatternHeader { mode, name, id, palette } = read_header(r)?;
let body = read_vma_body(r)?;
let mut v = Rd { b: body, p: 0 };
@@ -289,8 +310,18 @@ fn read_pattern(r: &mut Rd, budget: &mut PatternBudget) -> Result<PsdPattern> {
if channels.len() < nc {
return Err(PsdError::invalid("pattern has fewer channels than its colour mode"));
}
visit(PatternMemory {
width: w,
height: h,
depth: first_depth.unwrap_or(8),
mode,
planes: channels.len(),
decoded_bytes: channels
.iter()
.try_fold(0u64, |sum, c| sum.checked_add(c.destination.total_bytes()?).ok_or(PsdError::LimitExceeded("pattern memory size overflow")))?,
});
let mut planes = Vec::new();
for channel in channels {
for channel in channels.into_iter().filter(|_| decode) {
let plane = decode_planes(channel.compression, channel.data, &channel.source)?;
planes.push(place_channel_plane(plane, &channel.source, channel.x, channel.y, &channel.destination)?);
}
@@ -472,6 +503,28 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu
Ok(out)
}
/// Inspect a pattern block's dimensions and decoded sizes without allocating pixel planes.
/// Uses the pixel parser’s structural validation and shared decode limits. Returns
/// earlier records even if a later record is malformed, since those can decode first.
pub fn pattern_block_memory(data: &[u8], budget: &mut PatternBudget) -> Vec<PatternMemory> {
let mut r = Rd { b: data, p: 0 };
let mut out = Vec::new();
while r.p + 4 <= data.len() {
let Ok(len) = r.u32() else { break };
let len = len as usize;
if len == 0 {
break;
}
let Ok(body) = r.take(len) else { break };
// Earlier patterns may already have decoded when a later record is malformed.
if read_pattern_with(&mut Rd { b: body, p: 0 }, budget, false, &mut |m| out.push(m)).is_err() {
break;
}
r.p = (r.p + (4 - len % 4) % 4).min(data.len());
}
out
}
/// Whether `Patt` / `Pat2` / `Pat3` block data keeps a pattern's transparency in the user mask
/// array, as PhotoCraft wrote it before #8054. Photoshop refuses such a file, so a save must
/// rewrite the block rather than keep it. Unreadable data counts as `false`.
+9 -3
View File
@@ -654,7 +654,7 @@ command: `cargo run --release -p photocraft-engine --example remove_bench`.
## PSD import pixel memory limits
PSD/PSB imports preflight layer, user-mask, merged-image and extra-channel pixel storage
PSD/PSB imports preflight layer, user-mask, merged-image, extra-channel and pattern pixel storage
before merged-image validation or decoding. The conservative estimate sums retained tile
storage (including padding and negative offsets) and the largest decode/conversion workspace.
It does not credit uniform-tile sharing or pruning, so some files that could fit after decoding
@@ -674,8 +674,14 @@ in place. Raw and RLE source-backed masks read only the current band (plus the R
ZIP still inflates one whole plane before handing out bands. No full-mask interleave copy is
needed. A damaged later band discards all previously written bands of that mask.
The #8593 allowance covers the main pixel paths, not total process
RSS: encoded input, parser metadata, patterns, smart-filter caches, embedded smart-object
Pattern block headers are inspected without decoding pixels. The same allowance includes
all decoded pattern planes, padded tiles, bitmap/indexed expansion and conversion copies,
including earlier records in a block that later proves malformed. Pattern conversion reserves
pixel buffers fallibly. Multichannel conversion uses one tile-row buffer and applies alpha/ink
polarity before writing tiles, without a full-plane float buffer or alpha readback.
The #8593 allowance covers these pixel paths, not total process
RSS: encoded input, parser metadata, smart-filter caches, embedded smart-object
contents, generated vector caches, and text preparation are outside it and retain their
existing limits. Joining these paths to a shared budget and streaming ZIP inflation are follow-up work. An accepted
estimate does not guarantee allocation success on a memory-constrained host.