* Format: take the .pcraft envelope from craft-pcraft-format
The manifest envelope, TileRef, Hash, FORMAT_VERSION, EXTENSION, the
entry names, tile_path and blob_path, is_valid_hash, the manifest
nesting and item checks and the migration runner now come from
craft-libs' craft-pcraft-format. photocraft-format re-exports them, so
callers do not change. Manifest is craft_pcraft_format::Manifest<DocM>.
Saved bundles keep the same bytes. A save still checks only the nesting
and the item count of its manifest, not its size, and the manifest
nesting limit is still computed from photocraft_doc::MAX_GROUP_DEPTH.
Tests: a save, load and save again gives the same bytes in every color
mode and depth, with and without previews; MAX_GROUP_DEPTH equals
craft_pcraft_format::MAX_GROUP_DEPTH.
* Format: pin craft-pcraft-format from storytold/craft-libs
* Format: read manifests with craft-pcraft-format's read_manifest_with
Pins craft-pcraft-format to dca870a0, the head of craft-libs #407.
Loading reads the manifest with read_manifest_with, which runs the
limit check, the version check, the migration steps and the envelope
read in one path; the layers are still decoded one at a time after the
rest of the document. A save builds the manifest with Manifest::new and
tile references with TileRef::new, and the bundle's directory names and
the .zst suffix come from the crate's constants. Saved bytes and error
messages are unchanged.
* Format: pin craft-pcraft-format to craft-libs #407's head 04dd1bd
04dd1bd changes only the crate's docs and a test, so photocraft-format
builds the same code.
* Format: pin craft-pcraft-format to craft-libs main at b8f7d93
craft-libs #407 merged as b8f7d93. Its read_manifest also refuses a
thumbnail other than thumb.png and a composite other than
composite/preview.png, the only names photocraft-format writes.
* perf: reuse large buffers so a pinned glibc trim threshold costs no speed (#10479)
With MALLOC_TRIM_THRESHOLD_ set (#8743), glibc maps every allocation over
128 KiB fresh, so tiles, compositor bands and filter scratch were page-faulted
in on every call: viewport compose 3.6x slower, Gaussian Blur up to 1.8x,
.pcraft save 2.4x.
- photocraft_raster::scratch: a process-wide, capped pool of large Vec
buffers (u8/u16/f32/[f32; 4]). A miss drops the oldest buffers worth its
size down to a 256 MiB floor, so peak memory stays near glibc's defaults.
The engine releases the pool after any command that closes a document,
Headless::close does too, and an idle thread releases it after 2 s unused.
- Tiles, compose Buffer/masks/bands, algo Image and the blur paths, engine
adjustment/filter scratch, GPU tile upload and io import buffers go through
the pool.
- .pcraft save reuses one zstd FrameCompressor per worker (byte-identical).
- interactive_bench reports minor page faults per run.
36 MP, trim threshold set vs glibc defaults: viewport 5.6 vs 6.1 ms (was
22.7), Blur r4 445 vs 437 ms (was 852), Curves CPU proxy 35.0 vs 35.7 ms
(was 50), flatten 146 vs 147 ms (was 237), save .pcraft 251 vs 230 ms (was
545). RSS after closing a 5400x7200 document: 21 MB with trim set.
* fix: corpus tests build again; return remaining pooled buffers (#10479 review)
- Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a
`Buffer`, which now implements `Drop` (E0509 with `--features corpus`):
use `into_px()`.
- io: masks, the merged image, extra channels, smart-filter masks,
smart-object pixels and patterns give their `interleave` buffers back to
the scratch pool after writing them.
- compose: recycle the mask values of a pass-through group, `effect_shape`
and both `split_parts` callers.
- Styled Free Transform preview copies each band out instead of moving it,
so the band returns to the pool.
- interactive_bench: the fault count covers each whole run, untimed setup
included; say so.
* fix: recycle the masks of adjustment-atop and shape layers (#10479 review)
* fix: recycle effect hiding masks (#10479 review)
* fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review)
* fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review)
* fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review)
* fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review)
* test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review)
* fix: pool the final GPU effect shape buffer and return it after use (#10479 review)
* docs: describe scratch pool lifecycle regression invariants
* test: verify polling releases cancelled document scratch buffers
* perf: recycle remaining transform selection and PSD scratch buffers
* fix: free PSD gather bands before allocating channel planes
* test: assert exact scratch take reuses pooled storage
- Channel targeting routes an allow-list of pixel-editing paint commands, not every
`paint.*` id: the Mixer Brush's Alt-click load and Symmetry no longer follow a colour
set or get refused with "Rasterize the layer" on a fill layer. Patch and Content-Aware
Move stay routed (#9274).
- Native vector save-back checks the preview SVG as strictly as stored previews are
checked on load and PSD export, so a preview with an unreadable embedded image is
refused instead of stored and later dropped (#8571).
- .pcraft blobs read their zstd frame with framing headroom, so an incompressible
preview right at its 16 MiB limit still loads; the limit still bounds the data.
- Levels/Curves recall (Alt+shortcut) decides the combined Lab record from the target
the dialog captured, as remembering does, so a viewed layer mask keeps its settings
beside a Lab colour set (#9274).
Each comes with a test that fails without it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts resolved as unions of both sides: codecs re-exports (PngRows + TiffBands), smart_cmds source imports
(main's source_surface_from_buffer and cache API with #8571's svg_source_image_in/convert_svg_source; main's
duplicate rendering import dropped), io flat.rs test modules (#8536's banded TIFF tests and main's PNG ones),
menus/invoke.rs (#8571's smart SVG intercept and main's layer.selectUnderCursor), state.rs (#8311's
type_box_overflow and main's type_insertion_style), the canvas_geom test keeps the is_finite guard, docs
status lines keep both change lists, i18n catalogs keep both sides' rows (hi.tsv: main's wording where both
added the same string).
Address CodeRabbit review: dropping the Autosaver joins its worker, which waits
for the write in progress, so a timed-out receive could still hold the test
until nextest kills it. The saver is now ManuallyDrop and only dropped on the
passing path. With a worker that sleeps 60 s per write and a 2 s bound, the
test fails after 2.01 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
background_save_outcomes_report_failure_then_recovery failed on main's Windows
integration job: the real (second) autosave write flushes every tile to disk and
took over 30 s on a busy runner (normally 2-3 s; a concurrent autosave test ran
past 60 s too). The bound only exists to turn a worker that never reports into a
failure instead of a hang, so it is now 5 minutes, still under the 10 minutes
after which nextest's ci profile kills a test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: svg.rs (main's bufread inflate and shared render helper, kept
with the PR's bounded raster path), psd_import.rs (the PR's vector
preview moved into main's smart_content), menus/invoke.rs (the SVG
intercept moved into main's split invoke_unguarded), rendering.rs and
the ui-egui import lists (main's explicit imports), Cargo.lock, docs
status lines (unions).
Conflicts with main's batch #9880:
- psd file.rs / layer.rs: #5287 (PSB by encoded size, RLE tables widened on
promotion) against #6583's lazy reader. Main's new widening and length code
read `.data`, which #6583 leaves empty while the bytes stay in the source;
it now goes through `encoded()` / `encoded_len()` (also the auto-merged
lines in `rle_widening`, `encoded_len` and `LayerRecord::write`), and the
lazy reader sets `write_as_psb: false`. New test
`a_lazy_psd_promotes_to_the_same_psb_as_an_eager_one`: a lazily read PSD
writes the same PSB bytes as an eager one; it fails with a `.data` read.
- direct_select.rs / vector_ui.rs: #9321's Type Path and #6353's hidden
Work Path both kept.
- io tests main.rs: both modules. scorecard.md regenerated (12 of 181);
ui-parity.md and gaps.md follow it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A ZIP save encoded the thumbnail and the 1024 px composite preview as PNG
on the calling thread before any tile was compressed. The composite alone
is a ~200 ms single-threaded deflate on a 36 MP document, about 40 % of a
full save. The previews are now encoded on their own threads while the
document is collected and its new objects are compressed on all cores;
the bundle is assembled afterwards in the same order, so the bytes are
unchanged. Directory saves encode them up front as before. wasm stays
sequential.
bench_export, 36 MP, 3 layers, 16 interleaved rounds of 7 reps (median
of medians / min), Ryzen 16 threads, Linux:
.pcraft 517.9 / 471.1 ms -> 319.3 / 287.8 ms (-38 %)
.pcraft with cache 360.1 / 334.1 ms -> 277.5 / 268.3 ms (-23 %)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- External vector editing on macOS: discover VectorCraft.app in /Applications and
~/Applications, offer Illustrator when an Adobe Illustrator*.app is found, and accept a
chosen .app under Other application. CFBundleExecutable is read from a bounded Info.plist
(XML or binary, streamed with a nesting cap) and must resolve inside Contents/MacOS;
chosen bundles open with /usr/bin/open -a. vectorcraft-cli is looked for in the same
bundle, then in known installs. Logic is compiled and unit-tested on every Unix.
- Windows "not found" errors name the dialog's "Other application" option.
- Native preview hash verification is remembered per source allocation in a 32-entry
Weak-keyed LRU (no strong references, no reusable stale addresses, lock not held
while hashing), so refreshes and warp frames hash a shared source once.
- source_box uses the new io::svg::bounded_canvas_bounds for SVG without Stack Mode, so
warp setup gets a frame for drawings whose editable layer caches exceed the budget.
Stack Mode and Edit Contents stay strict. Exact 24,000,000-pixel boundary tested.
- Document every non-test function this PR adds; describe macOS behaviour in
docs/embedded-vector-editing.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: resolve SonarCloud maintainability issues
* fix: format imports and fix effect_entry argument type
* fix: restore test closure and wildcard import that broke compilation
* refactor: split composite_to_surface to reduce cognitive complexity
CodeRabbit (discussion_r4370498176, two findings):
- copy_metadata copied the ACL before the creation time; an ACL that
denies its owner write access, copied onto the temporary file first,
made the creation-time step fail to open it. The time goes first now.
- AuthorizedWorkspace::write (the automation save path) copied only the
extended attributes, so an automation save reset Finder's Created date.
It now also copies the creation time through the same capability-checked
handles, with the new descriptor-based meta::copy_created_file.
Tests: keeps_the_creation_time_under_a_deny_write_acl,
write_keeps_the_replaced_files_creation_time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving writes a temporary file and renames it over the original, so on
macOS the saved file was born at the time of the save and Finder's
"Created" date moved forward. Photoshop keeps it.
`copy_metadata` now copies the replaced file's creation time onto the
temporary file before the rename, with std's `FileTimesExt::set_created`
(stable since Rust 1.75). Best effort, like the other metadata steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Autosave: a recovery listing judges each key once, so an autosave in progress isn't listed twice
* Autosave: the recovery listing builds for the browser too
The writer thread now asserts each take_completed() result, so an earlier failed write
can't hide behind flush()'s last result, and a drop guard sets the done flag however the
thread ends, so a writer panic is reported by join instead of the listing loop spinning.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Windows the recovery sidecar was written through store::write_atomic, which replaces the
old file with ReplaceFileW: it moves the old sidecar to a backup name first and only then moves
the new one in. A recovery listing in between saw the bundle without its sidecar and listed the
entry twice, or not at all (so a recovery prompt could miss a document). The flaky
autosave::recovered_entries_survive_a_second_crash_until_saved_or_closed on Windows CI was this.
The sidecar now goes through one AtomicFs on every platform whose replace is a plain rename
(one step); Unix keeps its 0600 temp file, Windows still refuses a read-only sidecar up front.
Recovery data is private to the app, so ReplaceFileW's ACL/stream preservation isn't needed.
Test: a_listing_during_an_autosave_sees_the_entry_exactly_once lists the recovery folder while
an autosaver rewrites the entry 200 times. With the old code it failed 3 of 3 runs on Windows
(two entries, once none); with the fix it passes 3 of 3.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main now links each crate's integration tests into one tests/it binary;
the two new test files of this PR join it as modules.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(format): report a failed directory flush after a save instead of ignoring it
atomic_write and the bundle writer discarded the directory fsync result once the rename had published the file, so a save whose rename might not survive a power loss reported success and its recovery data could be discarded. A real flush failure is now returned (the file is in place, the message says the save may not last), so callers keep the document unsaved and its recovery data, and a bundle save stops before removing objects the old manifest needs. A file system that can't flush directories at all (EINVAL, ENOTSUP, Unsupported) still counts as saved. Fixes#4968.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): a folder that can't be opened for a flush is not a failed save
A write-and-search-only folder (no read permission) can't be opened to fsync it, so no flush can be attempted there. Treating that open failure as a failed flush made saves into such folders fail (the directory_symlinks permission tests on Linux). Only a flush that is attempted and fails is reported now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): flush bundle folders like atomic_write, and test a failed manifest flush
The bundle writer flushed the folder with sync_all, which on Apple
platforms is F_FULLFSYNC and fails on SMB shares; it now goes through the
same RealFs::sync_dir as atomic_write (sync_file's plain-fsync fallback,
#1336). open_dir_for_sync and dir_synced are crate-private. A test-only
hook fails the manifest's folder flush: the save fails, and the objects
the previous manifest references stay until the next save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): keep stale previews until the manifest commits; errno 45 only where it is ENOTSUP
A directory save removed a thumbnail or composite the new document no
longer has before writing the new manifest, so a failed manifest flush
left the previous manifest naming a preview that was gone. They are now
removed after the manifest commits, best-effort like unreferenced
objects; the failed-flush test checks the thumbnail survives.
errno 45 counted as "can't flush directories" on every Unix, but it is
ENOTSUP only on Apple platforms and the BSDs (illumos: EDEADLK). Other
Unix targets now accept only EINVAL and ErrorKind::Unsupported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): flush the object folders before every manifest commit
When an object's folder flush failed, a retried save found the object
already written, reused it without flushing its folder, and then
committed a manifest naming it: success was reported while the object's
directory entry was still unconfirmed. Each directory save now flushes
tiles/ and blobs/ once before writing the manifest, so a reused object is
confirmed too. The flush error is shared with write_bundle_file
(confirm_flushed), and a test retries a one-tile save whose tile folder
flush failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): keep stale-preview removal before the manifest commit, as main requires
f77ffa3e moved stale-preview removal after the manifest commit. That
broke the hardening in 2d5a7497 (#4957), whose test
denied_stale_preview_cleanup_preserves_the_previous_manifest requires a
committed manifest never to sit beside a stale preview, so a failed
removal must fail the save. Restore main's ordering; the object-folder
flush and the failed-flush tests stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(format): flush the composite folder after removing a stale composite
The stale composite/preview.png was removed before the manifest commit,
but nothing flushed composite/, so a crash could bring it back beside
the new manifest. The folder is now flushed when a stale composite was
removed (the thumbnail lives in the bundle root, flushed with the
manifest).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Review pointed out that READ_CHUNK bounds each read but not the total
that photocraft_format::read_file returns, so a regular linked file that
keeps growing could keep a PSD export from finishing, and growth past
the stat length grew the buffer with infallible allocations. #8601 asks
for a MAX_OPEN_BYTES limit on the shared reader, not only on the PSD
call site.
read_all_with, which backs both read_file and read_all, now:
- fails with FileTooLarge once a reader has given more than
MAX_OPEN_BYTES (256 GiB), or up front when the stat length is already
over it, so a file over the limit is never embedded truncated;
- grows the buffer past the stat length only after a probe read finds
more bytes, and with try_reserve, so a file that outgrows memory is an
OutOfMemory error and a file read exactly to its length is not
over-allocated.
The limit sits far above any document memory can hold, so opening large
PSB files is unchanged. The non-regular-file rejection is unchanged.
Tests: read::tests::reads_stop_at_the_total_limit (a file over the
limit, readers over it with any hint, a hint over it, an endless reader)
and read::tests::an_exact_hint_is_not_over_allocated.
- crates/ui-egui/src/layer_style.rs: values_of keeps the PR's method/smoothness keys and source-stop ends, and main's (#7072) unconditional From/To and geometry fields.
- crates/compose/src/cmyk_tests.rs (no textual conflict): add the PR's new `interpolation: None` field to main's CMYK test GradientOverlay literal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- crates/engine/src/tests.rs: kept both tests (main's restore_layer_selection test and the PR's history_states_share_large_document_payloads).
- crates/io/src/psd_import.rs: kept main's parsed Duotone inks (duotone_map) with the PR's Arc<[u8]> psd_raw; also main's unknown-adjustment-key import (#6575) now builds Arc<[u8]> raw.
- crates/io/tests/adjust_warnings.rs: moved to tests/it/adjust_warnings.rs on main; PR's .into() edits applied there, plus #6322's Color Lookup test (raw.as_slice().into()).
- docs/architecture.md: union; status line names both changes, both revision-history rows kept.
- Adapted main-new sites to the PR's types: psd_export.rs (Duotone write-back via as_deref/to_vec, Unsupported block compare by slice), inspect.rs and ui-egui unsupported_marker_tests.rs (raw .into()), io tests/it/psd_unknown_adjustment.rs (slice compare).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main merged #5940, #6472, #7838 and #7922 directly. Kept from the batch:
the snapshot/History-follow identity at the history limit (#5940,
c7ef3676a; main still had the 3-tuple HistorySpot) and the strokeless
shape at a 0 px width step (#6472, 962539607; main still added the
stroke). Main already carries the other two fix-ups (34c9d0ea4, dff8bfd0e).
Overlaps with main resolved:
- #5842 vs main's #5568: one path, apply_color, tool-gated as #5842
has it, with #5568's float fill, kept fill opacity and lock skip;
shape_fill_ui's duplicate foreground_changed/gesture dropped; #5568's
tests run with a shape tool.
- #6230 vs main's #5180: one Type tool path click (path_target), no
second type.attachToPath; vertical type does not start type on a path,
as main has it; main's path_at/path_cursor and canvas arm dropped.
- #5452 moved the shape swatch to shape_paint_ui.rs: main's hex field
(#6201) ported there.
- type_extra_cmds::with_text takes main's dpi form, pub(crate) for
type_area_cmds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>