mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
committed by
GitHub
parent
84732d09bc
commit
209bce8d46
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user