* Raster: ask the allocator whether a big operation fits; PSD save reserves its whole output
New photocraft_raster::headroom: ensure(need) tries to reserve the need plus a margin and frees it
at once (web only; native always passes), below(bytes) tells whether less than that can be
allocated, and simulate_available fakes the answer for tests and diagnostics.
PsdFile::to_bytes reserved only the pixel data, so embedded smart-object data and other tagged
blocks grew the output by doubling, which on the web is one unrecoverable huge realloc. It now
reserves the exact encoded length.
* Big operations that won't fit in memory fail with an error instead of trapping
Place, Image Size, a smart-object re-render and Save now ask the allocator whether their estimated
need can be allocated before they start (photocraft_engine::memory_gate, estimates per operation,
photocraft_io::estimate_export_bytes for saves). A refusal changes nothing and says what the
operation needed and what to do (save, Edit > Purge > All, close documents).
A standing notice warns when less than 512 MiB can be allocated, with hysteresis, so the user can
save before anything traps. Notices can now be sticky, which keeps them past the auto-hide delay.
* Web build: a trap says so instead of freezing the page, and ?free-mb simulates low memory
index.html holds a hidden, static 'PhotoCraft stopped' message with a Reload link. web.rs reveals
it when the module traps (the window error event, or an unhandled rejection from a spawned
future), tells running out of memory from an internal error by the panic message a chained panic
hook kept, and skips the Leave site prompt once stopped. ?free-mb=N makes the memory gates and the
low-memory warning see only N MiB, for checking them in a browser.
* Docs: web build memory handling in the gaps list and the parity assessment
* Engine: use is_multiple_of in the megabyte formatter
* Cargo.lock: the web shell depends on photocraft-raster
* Web boot page: carry the stopped message too, as the test that keeps the two pages equal requires
* Save check counts one copy of the document, and runs once per save
The encoded file's buffer is reserved fallibly, so a save it doesn't fit
in fails with an error instead of trapping; counting it as well refused
saves that would have fitted. On the background-save path the check ran
in write_document and again in start_save, which other callers reach
directly; it now runs only in start_save there.
* Review fixes: warning check allocates nothing, PSD merged image counted, automation saves checked
The low-memory warning's periodic check reads the size of linear memory
instead of reserving a block: wasm memory never shrinks, so a trial every
few seconds raised the high-water mark by up to 768 MiB for nothing. The
operation checks keep their trial reservation.
PSD and PSB saves always build a canvas-sized merged image (a placeholder
without maximum compatibility) and hold it raw and encoded; the save
estimate now counts it. The synchronous automation save checks memory
before encoding, as File > Save does, and its test asserts the memory
error.
The trap listeners go in before the app is built, so a trap while
building it shows the stopped message too, and a memory access out of
bounds reads as unexpected rather than as running out of memory.
* Cover layered PSD preflight limitation
* Fix layered PSD test iteration
* Docs: layered PSD preflight limitation in the gaps list
* Web OOM gate: gate direct exports, truthful advice for unbounded PSD sources (#10024)
* Web OOM gate: estimate layered PSDs per layer instead of refusing every real PSD
The PSD check admitted a file only when its color-mode data, image
resources and layer section were all empty. Every Photoshop file has
image resources (at least the resolution), so on the web nearly every
real PSD was refused for Place, PSD smart objects could not render,
and Image Size was refused for documents holding one, whatever the
free memory (u64::MAX never fits).
The color-mode data and image resources hold no pixels, so they are
now only skipped. A layered file reads its layer count from the layer
info (signed, absolute value) and adds a canvas-sized surface and mask
per layer to the flat estimate. Files that are not 8-bit RGB or
grayscale, keep their layers in a Layr tagged block, or whose sections
can't be read stay unbounded and refused, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Web stopped page: match trap messages ignoring case
Safari reports the allocation-failure abort as "Unreachable code
should not be executed" and Firefox says "out of memory", which the
case-sensitive matching missed: the trap was not recognised, or was
shown as an unexpected stop instead of running out of memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Export estimate: count the merged image OpenRaster writes
The ORA writer keeps the layers but also flattens the document for
mergedimage.png, a 16 B/px composite of the canvas, which the
estimate left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Engine: droplet dependency capture skips disabled steps; a nested Batch
that was cancelled or could not write its error log fails the caller;
runDroplet forwards overrideOpen; actions.rename follows named Batch
steps; Batch steps inside a script count against MAX_SCRIPT_STEPS;
output claims compare destination identity; Save and Close never
replaces a read-only source. ATN: Md must be a BlnM enumeration,
adjustment-layer names must round-trip, Calculations exports a missing
blending as Multiply, Apply Image refuses unnamed-layer transparency.
Filters: Shape Blur records its resolved geometry, reserves its kernel
memory first, and reports a preparation failure as an error, not a
cancel; Crop keeps an artboard preset when the size is unchanged and
fits the board on a turned crop.
Codecs/cms/algo: zero-axis FITS image HDUs are empty; a GIF walk that
breaks off before the trailer fails closed; two-value .3dl shaper rows
are applied; fragmented custom kernels are refused and their work polls
cancellation.
UI: Conditional branches play through panel playback; paused actions
refuse to resume after being replaced; Playback Options OK validates
its fields; transform double-click ignores a hidden pivot; path boxes
snap to a stationary selection; modal blocking uses the effective tool;
a picker-consumed release clears snap lines and ends a Move drag; the
Crop preset control shows Selection and checks only it; Crop box undo
restores frame_from_selection; Bosnian fill labels.
Tests: timing asserts moved to an ignored test, plus the review's
coverage nitpicks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With our fix-up 1f524c378b on top of the author's head 713aa92ba5. Resolved with main: the version check sits in main's parse_layout (it merged cleanly); doc comments combined; atn_contract no longer expects version 12 to be refused and pins that an empty version 12 set parses; one ATTRIBUTION row for all ATN fixtures; the fixture README states which files were encoded how; development.md no longer counts the fixtures.
The three version 12 fixtures were tracked inside crates/psd/fuzz/corpus,
which crates/psd/fuzz/.gitignore ignores and cargo-fuzz rewrites (cmin) or
deletes; the psd and engine test binaries include_bytes! them, so cleaning
the corpus broke the build. They move to crates/psd/tests/fixtures/atn with
a README (sizes, purpose, sha256; the seeds note folded in) and an
ATTRIBUTION.md row; the fuzzer takes the folder as an extra corpus dir.
photocraft-cli recognised an extensionless Actions file only by version
word 16, so a version 12 set without .atn (classic Mac OS Photoshop 5)
went down the JSON path and failed with a UTF-8 error. It now accepts 12
and 16, the versions photocraft-psd reads. Input: v12-commands.atn copied
to "Legacy Actions" and passed to load_actions_file.
Co-authored-by: yoriswind <164435053+yoriswind@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the ten backend, automation and test paths from #5493 by
@jonathanwxh-cell, pinned at 43920ab780.
Keep its original CSH codec, bounded preset reader and protection tests.
The prerequisite's UI, translation and parity-document changes are excluded.
Adapt ShapePreset construction to retain the exact source identifier. Empty
identifiers remain anonymous presets; repeated identifiers resolve when their
geometry is identical and refuse resolution when it conflicts. Add one
import-to-ATN Shape Blur regression and document the source attribution.
Original contribution: https://github.com/storytold/photocraft/pull/5493
Source-only integration; compilation and combined runtime validation remain.
The contract tests no longer compiled on main (AtnItem::dictionary is now
Vec<u8>, so it is compared with b"Future event"), sat outside main's
tests/it layout, and the new atn.yml added six legs (two macOS) to every
PR against ci.yml's policy. Move the tests into tests/it so the existing
CI runs them, drop atn.yml and docs/atn-testing.md, keep main's ci.yml and
fuzz target, and note the committed fixtures in ATTRIBUTION.md.
Co-authored-by: denstu473 <63150090+denstu473@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings split/tiff-extra-samples-codecs at e6fca054, which merged main
1695f323. Conflicts:
- crates/io/src/lib.rs: main moved PSD import into import_psd(); it now
passes PsdImportOptions::default().
- PsdExportOptions (psd_export.rs, tiff_layers.rs): main's new
read_linked_files next to this branch's photoshop_spot_polarity.
- docs: main's newer status lines and rows, with this branch's TIFF
statements re-applied (dated 2026-10-23). docs/scorecard.md is main's
and is regenerated after PR 2 is merged too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs; helper modules under tests/common move with them.
These keep their own binary (own process):
- engine panic_hunt: a process-wide panic hook counts every panic.
- engine text_gamma: changes the process-wide text gamma.
- engine smart_filter_stages, mask_cache_purge: assert on global caches.
- codecs pnm_peak_memory: reads the process's peak RSS.
- photocraft mac_menu_appkit: harness = false.
Flat and layered TIFFs write and read alpha channels, spot channels and a
Quick Mask as extra samples, with Photoshop's channel resources. This is
the io part of #7415 as it stands, on current main; the next commit keeps
PSD output as it is on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com>
- swatches.import with bytes only (no path or format): an .act whose first
colour is 0, 1, x or 0, 2, x still went to the .aco reader, which ignores
trailing bytes and returned a handful of junk swatches. At the two .act
lengths (768/772) an .aco header now counts only when its sections span
the whole file (new `aco::sections_len`); a Photoshop .aco of exactly
768 bytes is still an .aco. Test: act_that_starts_like_an_aco_is_still_an_act.
- The .atn corpus tests (psd and engine) skipped unreadable folders and
entries silently; they now fail with the path.
- act::write doc: the trailer is written when `count` or `transparent` is given.
Co-authored-by: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com>
Co-authored-by: Fabio Lissi <fabio.lissi@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fill_to (Paint Bucket export): keep main's `source` alias and explicit
colour (`Usng` = `Clr ` plus `Clr `, #7199) together with the PR's
optional `Tlrn`/`AntA`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
photocraft_psd::ase::parse counted colors toward MAX_SWATCHES but not
group starts, so the number of groups it read grew with the file's
size. An empty group start is six bytes, and parse held about 64 bytes
for each: in a test build, parsing a 60 MB file of 10,000,000 empty
group starts raised the process's peak memory from 63 MB to 705 MB.
swatches.import reads files of up to 512 MiB, and read_file built a
group for each group start before the import refused more than 10,000
groups.
parse now counts group starts and returns LimitExceeded past
MAX_GROUPS (10,000), the most groups the Swatches panel holds. Files
with at most 10,000 groups parse as before, so every file the import
accepted still loads. Files with more were refused before as well; now
parse refuses them before it holds more than 10,000 groups. write also
refuses more than MAX_GROUPS groups, as it refuses more than
MAX_SWATCHES colors.
ase_group_cap_is_enforced_on_hostile_input parses 10,001 empty group
starts and expects LimitExceeded, then parses the same file with one
block fewer and expects all 10,000 groups. too_many_groups checks that
write refuses 10,001 groups and that 10,000 groups are written and
read back.
Frames past the cap collected nothing, so the layers under dividers that
are never closed were lost once those dividers nested past max_depth,
and nothing was left to hoist at the end. A first pass now matches
dividers to folders and only closed dividers open a frame, so every
frame's depth is the final depth of its nodes and the capped tree equals
layer_tree cut at max_depth, still at most max_depth + 1 levels deep.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- corpus/atn: 16 sets from seven public repositories (MIT, BSD, Unlicense), fetched at pinned
commits by cargo xtask corpus --atn, verified against xtask/atn-corpus.sha256, licences next to
the files, rows in ATTRIBUTION.md and docs/development.md. photocraft-corpus takes only our own
generated files, so third-party sets are pins here, like psd-tools and ag-psd.
- photocraft-psd corpus_atn: every set parses and rewrites byte for byte (floor 16 files).
photocraft-engine corpus_atn: every set loads, saves and reloads to the same actions; the
steps still kept as Photoshop steps are capped (517 of 5 296 measured; generative AI and
scripts), and the first differing step is named on a mismatch.
- What the corpus found: localised panel names are platform legacy bytes (Mac Roman), now kept
verbatim with AtnItem::label from the descriptor's Unicode class name; exporters emitted
Photoshop defaults the importer had not read (now symmetric); setAdjustment carries its kind;
and 300 more steps map: transform without a reference, selection nudges, moves to the bottom,
View menu items, toolbox colours, clipping masks, ungroup, copy/paste styles, collapse groups,
layer comps. New example atn_roundtrip shows the first differing byte.
* psd: harden the standalone binary parsers (gradients, swatches, patterns, filter effects, metadata, resources, tagged blocks)
Correctness and robustness work on the psd crate's auxiliary parsers, with
fuzz targets and property tests so the invariants are enforced, not just asserted.
grd (gradients):
- Split num()/finite() into a single guard with a saturating f64->f32 cast: only
NaN has no value, everything else (including +/-inf and 1e300) saturates to the
representable range so the mapping stays monotonic instead of snapping to a field
default and inverting a hostile value.
- Hue now wraps with rem_euclid(360) instead of clamping (370 -> 10, not 370 -> 360,
which would be a different colour at the boundary).
- Every colour model is bounded: RGB/CMYK/Gray to 0..=1, L* to 0..=100 and a*/b* to
the CIELAB gamut (-128..=127), so a downstream converter never sees out-of-gamut input.
aco/ase (swatches):
- Share one io::utf16_units() helper that caps a name by UTF-16 code units without
ever splitting a surrogate pair; keeping it in one place stops the fix from drifting
between the two formats.
Coverage:
- New fuzz targets for grd, aco, ase, patterns, filter_effects, metadata, tagged and
resources (wired into the fuzz CI matrix).
- Property tests mirroring each fuzz invariant (round-trip stability, no panic on
arbitrary bytes, documented colour/number ranges) so normal CI covers them; the
fuzz jobs run on demand.
369 psd tests pass; clippy -D warnings and rustfmt clean; layering and wasm check pass;
all psd fuzz targets build and run clean.
* psd: address Gemini/Claude review of the parser hardening
Follow-up to the previous commit, applying the substantive points from two
independent code reviews (Gemini 3.8: ship; Claude: ship after small fixes).
Production:
- grd hue: an in-range value (0..=360, including exactly 360) is now kept verbatim;
only an out-of-range hue wraps with rem_euclid. Wrapping unconditionally altered a
legal 360 to 0 (and f32 round-off on a tiny negative could itself land on 360),
changing the colour at the boundary for anything that interpolates in HSB.
- ase: compile-time assert that MAX_NAME_UNITS < u16::MAX, since put_name writes the
length as `units.len() as u16 + 1`; guards the invariant if the cap is ever raised.
Stronger test/fuzz invariants (byte stability alone would pass even if the writer
silently dropped a field; these assert semantic equality):
- metadata fuzz: assert the parsed-then-written block re-parses to the same items
(the weakened byte-only check was the fix the first fuzz run surfaced).
- patterns fuzz: assert parse(write(parse(x))) == parse(x); channel offsets/padding
are wire artifacts, not fields, so the round trip is lossless (confirmed, 2M execs).
- filter_effects fuzz: assert back == fx as well as byte stability; also decode every
cached slot, not just the mask.
- resources fuzz: round-trip ResolutionInfo through to_bytes/from_bytes.
Coverage:
- tagged fuzz: select the block key from a table of every typed/strict key via the
first byte, so the fuzzer reaches each branch instead of guessing a 4-byte key.
- new property tests: damage-a-valid-block for metadata/patterns/filter_effects (pure
random bytes rarely pass a header), and check_structure over every structural key.
- grd_every_colour_model_is_clamped_and_finite now pins exact saturation values
(a*/b* to +/-128, L* to 0/100, units to 0/1), so a weaker clamp is caught, not just
an out-of-range value; grd_hue_wraps now pins the exact-360 and 0 boundaries.
Verified: 371 psd tests pass; clippy -D warnings, rustfmt, layers and wasm clean; all
13 fuzz targets build and run clean (metadata 11.5M, patterns 2M, filter_effects 5.9M,
resources 3.5M, tagged 5.4M execs); the a*/b* clamp-weakening and hue-guard-removal
mutants are both killed by the strengthened tests.
---------
Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
The file-open path rejects PSDs nested past MAX_GROUP_DEPTH before
conversion, but the public psd_to_document built file.layer_tree() to the
file's full depth (~16000 levels) before Ctx::build capped it, and layered
TIFF reached it with no preflight at all.
- photocraft-psd: PsdFile::layer_tree_max_depth(n) builds the tree
iteratively and drops the children of groups nested n deep, so walking
or dropping it recursively is bounded. layer_tree() is the uncapped case.
- psd_to_document uses it with MAX_GROUP_DEPTH, the same cap Ctx::build
and apply_link_groups apply, so imports are unchanged.
- Layered TIFF rejects nesting past the cap with the same error as a PSD
(shared psd_import::check_group_depth).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
validate_planes bounded its zlib loop by the uncapped declared size, so a
PSD/PSB declaring more than MAX_DECODED_BYTES of merged image could make
PsdFile::from_bytes decompress far past the decode budget before any
error. decode_planes refuses such a layout with LimitExceeded anyway, so
the stream is now kept verbatim without inflating it, like an unknown
compression method. A layered file whose merged composite is too large
still opens (the importer only warns when the composite can't be decoded).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(psd): Align global tagged blocks to four bytes
Use four-byte defaults for global blocks and streamed layer information.
Read global payloads without full copies while preserving block order,
explicit padding and the first layer candidate. Keep two-byte layer
alignment; test canonical padding and lossless PSD/PSB parsing.
* fix(psd): Preserve transparent composite colours and precision
* fix(psd): Check ZIP integrity and bound layer previews
* fix(io): Use document CMYK profiles for PSD fill pixels
Generated fill pixels used the default CMYK separation even when the PSD
embedded a different profile. Scope layer export to the document colour
space so cached pixels and the composite use the same profile.
Check native cached samples against an explicit ICC transform at 8 and
16 bits.
* io: refuse an empty RGB composite before splitting it into planes
chunks_exact(0) panics; an empty decoded composite now returns an error.
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>
Saving a 16- or 32-bit PSD built the Lr16/Lr32 layer payload in a temporary buffer and cloned
every global tagged block before copying them into the file. The payload is now written
straight into the output with its length patched afterwards, and the neighbouring blocks are
borrowed. Output is byte-identical: all 735 corpus PSD/PSBs re-save to the same bytes. A 24 MP
layer saves in 66 ms instead of 75 ms at 16-bit and 132 ms instead of 164 ms at 32-bit, and the
temporary 144-288 MB copy is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
real_before_parameters picked Photoshop's order (real mask first) whenever
the record length fit the parameter-flags byte at offset 36, even when that
byte was just part of the real rectangle. A density-only record in the
spec's order then read its density as the real mask's background and lost
the parameter. When both orders fit, Photoshop's reading now has to be
plausible (background 0 or 255, known parameter flag bits) or the spec's
reading with a real mask wins.
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* PSD: reject a header with zero width or height (#519)
A 0xN header opened as an empty document and saved back as another
zero-sized PSD or .pcraft. Header::validate now requires at least 1x1
(the spec range starts at 1), and PSD export validates the header so a
zero-sized canvas is never written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* .pcraft: documents with deeply nested groups load again (#517)
serde_json's 128-level recursion limit made bundles with 40 or more
nested groups unreadable after a successful save. The manifest is now
parsed with that limit disabled behind a linear JSON depth check, layers
are decoded and converted one level at a time with small stack frames,
and groups may nest up to MAX_GROUP_DEPTH (100) levels, which load and
save within a few hundred KiB of stack. Saving a deeper document fails
instead of writing a bundle that can't be opened.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Export: convert non-sRGB RGB to sRGB for formats without a profile (#522)
GIF, BMP, QOI, TGA, ICO and Netpbm can't embed ICC, so a linear-light
or Display P3 document was written with values that only meant something
under the dropped profile (a linear EXR came out dark). export_flat now
converts such RGB to sRGB through photocraft-cms (perceptual, as Quick
Export does) for every photocraft_io::export route. The EXR/HDR linear
conversion shares the same banded helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Export: composite transparency over white for formats without alpha (#521)
The CLI, MCP, Save As, Layers to Files and Layer > Export As dropped the
alpha channel when writing JPEG (or Radiance HDR), exposing the colours
stored under transparent pixels, while Export As and Quick Export
composite over white. export_flat now flattens over white too (no ink
for CMYK) and says so in its warnings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>