Files
photocraft/crates/io/tests/large_psb.rs
8948511eeb Large PSBs open with their pixels: decode planes past 2 GiB, plus 4-5 GB PSB fixtures (#375) (#782)
* PSD: decode planes up to 8 GiB, so large PSBs keep their pixels (#375)

MAX_DECODED_BYTES capped a single decode at 2 GiB. Real PSBs go past
that in one plane set: the merged image of a 30000x30000 RGB file is
2.7 GB, a 33000x33000 16-bit layer channel 2.2 GB. Opening such a file
gave a document with no layers (flattened files) or layers whose
channels were "treated as empty" (layered files). Saving hit the same
cap on the encode side and silently fell back to uncompressed data.

The cap is now 8 GiB on 64-bit targets and 2 GiB elsewhere, the budget
photocraft-codecs already uses for Limits::max_alloc. It stays a
backstop: the decoders bound their output by their input on their own
(RLE expands at most 64x, Raw needs every byte present). The Raw and
RLE output buffers are reserved with try_reserve_exact, so a machine
without that much memory gets an error instead of an abort.

generated with Claude Code

* PSD import: a channel that can't be decoded leaves its layer empty (#375)

The warning says the channel is "treated as empty", but the importer
filled a missing transparency channel with opaque alpha, so a layer
whose channels all failed became an opaque black rectangle over the
document. A channel that is present but undecodable now leaves the
whole layer empty (transparent), as the warning says.

tests/large_psb.rs: the damaged-channel case (PSD and PSB), and an
opt-in release test that opens a flattened 29000x29000 PSB whose
merged image decodes to 2.52 GB (16 s, 11.6 GB peak here).

generated with Claude Code

* Large-file fixtures: generate and check 4-5 GB PSBs (#375, #216)

photocraft-psd example big_psb writes five PSBs straight from the spec,
independent of this crate's writer and its limits: flattened and
layered, raw (4.4 and 4.8 GB files, data past 4 GiB) and RLE (small
files whose planes decode past 2 GiB, up to a 33000x33000 16-bit
layer). Every sample follows one formula, so photocraft-io example
big_psb_check opens a file through photocraft_io::import and checks
pixels without a reference; with SAVE set it saves as PSB and checks
the re-opened file.

Release build, this machine: all five open with correct pixels and
survive a PSB save and re-open (5-14 s per import). Before the 8 GiB
cap, three of the five opened empty or black.

Scorecard FILE-216-2 missing -> partial: no 2 GB PSD, 150-layer or
20 MP fixture yet, and nothing runs in CI.

generated with Claude Code

* psd: ZIP channels reserve fallibly and read in bounded chunks

A small ZIP channel declaring a huge decoded size could make
zip_decompress try a multi-GiB with_capacity/read_to_end and abort on
allocation failure. Reserve with try_reserve_exact/try_reserve, read
through take(expected) in 1 MiB chunks, and report LimitExceeded
instead. Tests: absurd declared sizes, past MAX_DECODED_BYTES, garbage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:59:39 -04:00

51 lines
2.6 KiB
Rust

//! Large PSB files (#375): planes past 2 GiB decode, and a channel that can't be decoded leaves
//! its layer empty instead of painting it opaque black.
use photocraft_io::psd_to_document;
use photocraft_psd::testgen;
use photocraft_psd::{ColorMode, Compression, LayerSpec, PixelData, PsdBuilder, Version};
/// A layer whose red channel is damaged: the warning says "treated as empty", and the layer must
/// be empty, not opaque black (the transparency channel is still fine).
#[test]
fn undecodable_channel_leaves_the_layer_empty() {
for version in [Version::Psd, Version::Psb] {
let mut b = PsdBuilder::new(4, 4).version(version).compression(Compression::Rle);
b.push_layer(LayerSpec::new("Damaged", 0, 0, 4, 4, PixelData::Rgba8(vec![200; 64])));
let mut file = b.build().unwrap();
let red = file.layers_mut()[0].channels.iter_mut().find(|c| c.id == 0).unwrap();
red.data.truncate(1);
let (doc, warnings) = psd_to_document(&file);
assert!(warnings.iter().any(|w| w.contains("Damaged") && w.contains("could not be decoded")), "{warnings:?}");
let s = doc.layers[0].surface().unwrap();
assert_eq!(s.pixel(1, 1)[3], 0.0, "{version:?}: the damaged layer must be transparent");
}
}
/// A flattened 29000² RGB PSB: its merged image decodes to 2.52 GB, past the old 2 GiB cap, which
/// opened it as a document with no layers. Needs about 12 GB of memory, so it is opt-in:
/// `cargo test --release -p photocraft-io --test large_psb -- --ignored`.
#[test]
#[ignore = "allocates ~12 GB; run in release"]
fn flattened_psb_with_a_merged_image_over_2_gib_opens() {
const SIDE: u32 = 29_000;
let file = testgen::merged_only(Version::Psb, ColorMode::Rgb, 8, Compression::Rle, SIDE, SIDE);
assert!(!file.image_data.data.is_empty(), "the generator could not encode the merged image");
let (doc, warnings) = psd_to_document(&file);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(doc.layers.len(), 1);
let s = doc.layers[0].surface().unwrap();
// testgen::pattern_plane at 8 bits, channel c seeded with c.
let want = |x: usize, y: usize, c: usize| if (x / 4 + y).is_multiple_of(3) { (c * 17) as u8 } else { (x * 3 + y * 7 + c) as u8 };
let side = SIDE as usize;
for (x, y) in [(0, 0), (side - 1, 0), (side / 2, side / 2), (side - 2, side - 1)] {
let got = s.pixel(x as i32, y as i32);
for (c, v) in got.iter().take(3).enumerate() {
assert_eq!((v * 255.0).round() as u8, want(x, y, c), "({x}, {y}) channel {c}");
}
assert_eq!(got[3], 1.0);
}
}