From 209bce8d4671708364842ff0ff5cda76d3153fc3 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sun, 1 Nov 2026 20:30:21 +0530 Subject: [PATCH] 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 --- crates/io/src/lib.rs | 2 +- crates/io/src/multichannel_map.rs | 89 ++++++++++++++------ crates/io/src/pattern_map.rs | 125 ++++++++++++++++++++++++----- crates/io/src/psd_budget.rs | 21 ++++- crates/io/src/psd_budget/tests.rs | 50 ++++++++++++ crates/io/src/psd_import.rs | 28 +++++-- crates/io/tests/it/multichannel.rs | 28 +++++++ crates/psd/src/patterns.rs | 55 ++++++++++++- docs/development.md | 12 ++- 9 files changed, 355 insertions(+), 55 deletions(-) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 006d9ac020..b3135df1ad 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -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, } diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 2b6f4b9b54..64fb6e2562 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -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 { + 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) { +pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[String], warnings: &mut Vec) -> 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 = (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 = 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()); + } +} diff --git a/crates/io/src/pattern_map.rs b/crates/io/src/pattern_map.rs index 4f0b5fe54d..97417b7cc7 100644 --- a/crates/io/src/pattern_map.rs +++ b/crates/io/src/pattern_map.rs @@ -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 { + 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, 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) -> Option { 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) -> Option> { + let mut out = Vec::new(); + reserve(&mut out, plane.len(), oom)?; + out.extend_from_slice(plane); + Some(out) + } + fn reserve(out: &mut Vec, n: usize, oom: &mut Option) -> 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>, u16) = if p.depth == 1 { let row = w.div_ceil(8); - let expand = |plane: &Vec| -> Vec { - (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, oom: &mut Option| -> Option> { + 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::>()?, 8) } else { - (p.channels.clone(), p.depth) + (p.channels.iter().map(|plane| copy_plane(plane, oom)).collect::>()?, 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> = (0..3).map(|c| idx.iter().map(|i| pal.get(usize::from(*i) * 3 + c).copied().unwrap_or(0)).collect()).collect(); + let ch: Vec> = (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::>()?; (ColorMode::Rgb, ch) } 4 => (ColorMode::Cmyk, channels), @@ -69,11 +108,22 @@ pub fn from_psd(p: &PsdPattern) -> Option { 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 { /// 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 { + 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, 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()); + } +} diff --git a/crates/io/src/psd_budget.rs b/crates/io/src/psd_budget.rs index 9988ff5fa0..b3c00ed4ef 100644 --- a/crates/io/src/psd_budget.rs +++ b/crates/io/src/psd_budget.rs @@ -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) } diff --git a/crates/io/src/psd_budget/tests.rs b/crates/io/src/psd_budget/tests.rs index 0f72d5c56b..3c1a195486 100644 --- a/crates/io/src/psd_budget/tests.rs +++ b/crates/io/src/psd_budget/tests.rs @@ -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] diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 717fd02f6f..6989941d31 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -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) -> 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, +) -> (bool, Option) { 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>>, 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())); diff --git a/crates/io/tests/it/multichannel.rs b/crates/io/tests/it/multichannel.rs index fd8f8448c3..4e7ffeeb2e 100644 --- a/crates/io/tests/it/multichannel.rs +++ b/crates/io/tests/it/multichannel.rs @@ -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::>(); 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}"); + } + } + } +} diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 21e6de55b5..fb604f0c02 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -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 { + 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 { 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 { 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 { + 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`. diff --git a/docs/development.md b/docs/development.md index 1eecf91042..3ecefb9f59 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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.