The importer bounded and inflated the top-level .svgz once, then handed
the result to usvg::Tree::from_data, which re-checks the gzip magic and
decompresses again with no byte limit of its own if the content happens
to start with a nested gzip stream. The same default resolver decompresses
embedded image/svg+xml data URLs (SVGZ-in-data-URL) the same unbounded way.
Parse the already-bounded top-level text with Tree::from_str, which never
decompresses. Wrap the image-href resolver so a gzip-compressed embedded
SVG is inflated through our bounded inflate() first; the already-plain
bytes handed to the default resolver no longer start with the gzip magic,
so it parses them directly instead of decompressing a second time.
Finding: a6444163-e510-4925-ae63-727658b6582e (CWE-409)
Embedded PNG and WebP images reached resvg using only their displayed
destination area, while resvg/tiny-skia allocate and decode the full
intrinsic source image first. A highly compressed large image could
therefore trigger gigabyte-scale allocation and decompression outside
the importer's destination-memory accounting.
Wrap usvg's default image-href data resolver with a check that reads
PNG dimensions directly from the IHDR chunk and WebP dimensions via
photocraft_codecs::probe_size, rejecting images whose decoded RGBA size
exceeds the raster budget before resvg ever sees them.
Findings:
- f4fc2a9e-c26f-442d-9d05-4e793d6b0f13 (CWE-789, embedded WebP allocation)
- d765fad5-678e-4f15-a8f8-3e6ee086056a (CWE-789, embedded PNG allocation)
Keep scan coordinates and tile boundaries in i64 so valid reads at the final tile and large strides do not overflow. Bound each run by the remaining output and return the surface default beyond the i32 coordinate range. Add differential regressions across all sample depths and missing/present tiles.
Co-authored-by: Lucenx9 <[email protected]>
As in Photoshop, the Move tool's Show Transform Controls box and handles are an Extra: hiding
Extras hides them, and their handles stop responding (no hover cursor, a press doesn't start Free
Transform). Cmd+H again brings them back. Show Transform Controls and the layer are unchanged.
Fixes#6699
Co-authored-by: Claude Opus 5.5 <[email protected]>
Restore suspended recording when the Displace map picker is canceled or fails. Reject control requests that replace a dialog continuation token. Cover failure and successful continuation paths with regression tests.
PR #8680
abandoned() drops the pause's record as stopped() does, so the recording a
paused call suspended comes back once and the record doesn't keep refusing
actions.record, actions.delete and actions.move afterwards.
The pause is recorded in the app (`PausedPlaybacks`): the action, the next
step and the gate it was playing under, keyed by a token that is all the
step dialog's field holds. `resume` plays from that record under its gate
(or the one already installed), and refuses a token it doesn't know.
Cancelling drops the record. A playback a person started stays ungated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`Headless::export_layers` calls `export_layers_with` outside `Session::execute`, so no session gate was installed when `encode` chose whether a PSD/PSB embeds a linked smart object's file. `export_layers_with` enters the session's gate itself, as `Session::execute` does, and a workspace session's layer export writes such a layer as pixels like `doc.save`.
The Replace prompt (`__replaceSaved`) routes `confirm` and `cancel` like the other dialog markers, so `ROUTES` lists it and `ui.dialog.set` / `ui.dialog.open` refuse to add it.
`dialogs::confirm` picks a dialog's handler by marker fields (`__diskChanged`,
`__export`, `__web`, `__print`, ...). Only the app sets them now:
- `ui.dialog.set` and `ui.dialog.open`'s `fields` refuse to add a key any of
confirm's or cancel's `owns()` predicates route on
(`dialogs::is_route_marker`, which probes the predicates themselves). A
dialog's own fields, private ones included, stay settable.
- `dialogs::confirm`, when driven by automation (a control request's gate or
synthetic input), judges a handler that writes files itself as the file
command it stands for: file.save, file.export.exportAs,
file.export.saveForWebLegacy or file.print.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A smart object's Linked source path is document data (a PSD placed layer's
Idnt, or a path a .pcraft stores). `read_file`, the single disk read point
for linked files, now rechecks the path with the gate the engine already
carries through dispatch, `Session::edit` and background jobs, as the
contents commands already did. A refused path is handled like a missing
file: the layer keeps its last rendering. Trusted sessions install no gate
and are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
PSD/PSB and layered TIFF export embed the file a Linked smart object names
(lnk2). Add `read_linked_files` to `photocraft_io::ExportOptions` and
`PsdExportOptions` (default on, so trusted saves are unchanged), threaded
through nested PSB and layered TIFF export. When it is off, such a layer is
written as pixels with the existing warning. Automation turns it off in
`Headless::save` (workspace), the desktop's `save_automation_with`, data-set
export for a gated session, and `file_cmds::encode` when a session gate is
installed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`image.applyDataSet` judges the Pixel Replacement image paths a data set
names with the session's automation gate. `export_data_sets_with`
(doc.exportDataSets / doc_export_data_sets) now does the same: the check is
factored out of `apply_data_set` and runs for every planned data set before
anything is written. Test in automation rpc tests.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the civil_from_days copies behind the app's log timestamp, the
Measurement Log's date/time, and the perf report date with chrono as a
workspace dependency (reusing the version already in the tree). Output formats
are unchanged and pinned by the existing tests. AGENTS.md now says time goes
through chrono.
Follow-up to storytold/cadcraft#1785
* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed
chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked. logging::timestamp and analysis_cmds::now_iso now read the duration
since the epoch and go through the fallible DateTime::from_timestamp, reading
an out-of-range or pre-epoch clock as the epoch as the old saturating code
did. All three ISO helpers (timestamp, now_iso, perf::iso_utc) write the year
with {:04} rather than %Y, which prefixes a '+' past year 9999.
Tests cover the far-future and year-10000 cases for timestamp and iso_utc.
* Don't build an unrepresentable SystemTime in the far-future test
UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.
* time: preserve old out-of-range date conversion (no epoch fallback)
`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any
instant past chrono's year range to 1970-01-01, changing output the old
hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s
was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain
with a cycle-based helper that routes the calendar through chrono (the
calendar repeats every 400 years = 146_097 days, so month/day/time come from
chrono and only the year is carried in i128, keeping every i64 exact).
- apps/photocraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/engine/src/analysis_cmds.rs: extract pure `iso_from_unix` (cycle
based) from `now_iso` so the conversion is tested directly.
- xtask/src/perf.rs: `iso_utc` cycle-based, accepts the full u64 range the
old code did.
Pinned old outputs in tests:
logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
iso_from_unix 10_000_000_000_000 -> 318857-05-20T17:46:40Z,
-10_000_000_000_000 -> -314918-08-13T06:13:20Z, i64 extremes
iso_utc u64::MAX -> 584554051223-11-09T07:00:15Z (was epoch),
10_000_000_000_000 -> 318857-05-20T17:46:40Z
* Don't build a year-318857 SystemTime unchecked in the logging test
Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.
* Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
* Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
- gpu: the test modules share one GPU_LOCK, and timing and vector_mask
take it too, so one process drives one wgpu device at a time.
- ui-egui: the wgpu tests in pattern_preview_canvas, adjust_preview_gpu,
marquee_autoscroll and layout_perf take gpu_lock().
- affinity compound_limits: the child process selects its test by the
module path (compound_limits::<name>); with --exact, the bare name
matches no test and the child passes without running the check.
- xtask scorecard reads crates/io/tests/it/corpus.rs.
- Docs and comments name the new test paths and the --test it filters.
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.
Merges main (8f09d122d). Conflicts: the replay_save doc comment (main added
file.export.exportAs replay) and the status lines and history rows of
docs/file-format-parity.md and docs/gaps.md; both sides kept.
The docs called FILE-215-19 done and said only Save As and Save a Copy ask.
Photoshop's JPEG Options also has Matte, the progressive scan count
(Scans 3-5) and the size preview, none of which is offered, and Baseline
Optimized writes the same file as Baseline for RGB (4:2:0). The row is now
partial with those gaps named, and the docs say File > Save asks too.
docs/scorecard.md is regenerated.
Adds jpeg_options_ui::tests::the_answer_reaches_the_encoder. Every UI test
used a fake exporter, so dropping `opts.encode.jpeg_format` from
ExportSettings::io_options (the desktop and web save path) left them all
green; this test writes a real JPEG through io_options and fails without it.
Co-authored-by: Michael Higgins <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The 20 i18n catalogs: main and the PR both appended a block at the end;
both kept, main's first. docs/gaps.md: main's text with the PR's
TYPE-218-2 change applied (G8 Missing, Evidence 5 done / 4 partial /
0 missing, revision row), dated 2026-10-22. docs/scorecard.md
regenerated with cargo xtask scorecard.
The TYPE-218-2 note now also says PSD doesn't store the paragraph
direction (crates/text psd.rs maps only the vertical WritingDirection),
so a direction set with the new buttons reopens from a PSD as Auto.
docs/localization-parity.md: the Arabic row said paragraph direction has
no UI (TYPE-218-2); it now has the buttons, the RTL caret is still missing.
Co-authored-by: tareqziad1998 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Zoom tool click or wheel zoom keeps the clicked spot under the pointer, so
the view centre moves off the image's middle. The window then grew to the
image's size around that off-centre view: a 400x300 image zoomed to 200% about
(50, 50) showed grey on the left and top and lost its right and bottom edges.
The size also left out the 12 pt scrollbars, which are drawn over the canvas
and hid the image's right and bottom edges.
The window now makes room for the scrollbars, and a window that grows around
the whole image centres it in the area the scrollbars leave free (any flip,
rotation, pixel aspect and display scale). A window the screen caps keeps the
zoomed spot where it is. Scrubby zoom and the Animated Zoom hold re-anchor the
view only when the zoom changes, so their release frame and the frames at the
zoom limit keep that centred view. A maximized or full-screen window keeps the
size the user gave it.
Co-authored-by: Nguyễn Minh Long <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Each arrow step in the open font menu applies its family, the options bar,
Character and Glyphs panels record it as the most recent font, and the next
frame hoisted it to row 0: with Recent Fonts on (default 10), Down then
alternated between two families and Up stayed on the first row (CodeRabbit's
open thread on #3559). The menu now keeps the recent group it opened with.
As in Photoshop, the recent families now head the menu in their own group
above a divider and keep their alphabetical place below it, instead of
moving out of it. The arrows start from the current family's alphabetical
copy (or the row the last arrow chose), and only that row is marked.
Tests: open_menu_arrows_walk_on_through_recent_fonts,
options_bar_menu_arrows_walk_on_while_recording_recent_fonts and
open_menu_on_a_recent_font_steps_through_the_alphabet fail without the
fix; recent_fonts_appear_first_in_the_menu pins the group, divider and
alphabetical copies.
Co-authored-by: Reynold Hu <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The resolution preference reached File › Open, but the other ways a raw
becomes a document of its own still opened it at 72 ppi: File › Revert
(a raw opened at 240 dropped to 72), Batch / Image Processor / Lens
Correction batch (a raw saved as PSD came out at 72 while Open + Save
gave 240), Load Files into Stack, the headless MCP `doc.open`, and the
CLI (`convert` at 72 while `run`/`info`, which open headless, now take
the default 300). They now apply the same `raw_defaults` rule; Batch
passes the user's value into its scratch sessions.
Tests that fail without this change (72 vs 240/300):
revert_batch_and_stack_open_a_raw_at_the_raw_defaults_resolution
(engine, one assertion per path), doc_open_gives_a_raw_the_raw_defaults_resolution
(automation), convert_opens_a_raw_at_the_raw_defaults_resolution (CLI).
Co-authored-by: Korg1ch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The 32-bit branch cut the slider to floor(|s| * 255) / 255, which only matches
Photoshop near 25 and 50 %: measured at every whole percent from -100 to +100
on a 32-bit document, Photoshop lowers by 1 - floor(|s| * 256) / 256 and raises
by 255 / (255 - floor(s * 254)) taken to 1/256 and capped at 32767/256 (201 of
201 exact). +100 gave 512x where Photoshop gives 128x, +90 9.8x for 9.44x.
It also kept the HSL saturation unclipped, which is inf at HSL lightness 1 and
0 and negative above 1: (2, 0, 0), red at +1 stop, turned NaN with any edit,
Hue +40 alone included, where Photoshop gives (2, 1.333, 0). The 32-bit branch
now scales the chroma around the lightness instead (same result in gamut),
on the CPU and in the shader, in whole numbers on both. Colorize clamps its
saturation before the 32-bit scaling, so a PSD's out-of-range value renders
the same on the GPU.
Tests (fail without this): thirty_two_bit_saturation_steps_match_photoshop,
thirty_two_bit_hue_saturation_keeps_bright_colours, the GPU parity
hdr_hue_saturation_bright_colours. Photoshop-oracle floor 188 -> 190 of 258,
measured after merging main.
Co-authored-by: Timothy Daly <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The PR's test checks L at full opacity only, so it still passes when a and b are left unrounded or
the rounding moves before the opacity mix, or truncates. New test: Curves taking a 140 to 151.4 at
50% opacity in an 8-bit Lab document must show a 146 (145.7 unrounded, 145.5 rounded before the
mix, 145 truncated: all three fail).
Co-authored-by: timothydaly1 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop's Create Rectangle switches only the right-clicked field
(Width 35.28 mm, Height still 100 px), lists the six length units
without Percent, and opens in pixels again after OK. The PR shared one
unit between both fields, offered Percent, and saved the unit on every
OK, so after one use with the rulers in px the dialog stayed in px even
after the rulers changed to cm, though no unit was ever picked.
Now each field has its own widthUnit / heightUnit (set only by the
menu, never remembered), and the menu is rulers::unit_menu_of with
LENGTH_UNITS. The new test right-clicks the real field and clicks the
menu item through egui input.
Co-authored-by: Korg1ch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The panel scrolled with `scroll_to_rect(current, Some(Align::BOTTOM))` on every revision
change. In egui an explicit alignment always realigns, and the revision also changes when
nothing in the history does (selecting a layer, a channel eye). So clicking a visible older
state slid it to the bottom under the pointer, Undo hid the greyed redo states, a layer click
threw a user who had scrolled up back to the bottom, and selecting a snapshot scrolled it out
of view.
Now the panel follows the history position (document, past/redo counts, current name, and the
newest past state, which also changes at the state limit), skips it while a snapshot is
selected, and scrolls without alignment, so only a hidden current row moves into view. New
tests: a clicked visible state stays put, Undo keeps the redo state in view, a layer click keeps
the scroll, a selected snapshot stays in view, a new state at the limit is revealed; the first
three fail on the PR head, the others without the snapshot guard / the newest-state key.
Co-authored-by: nrq6993-a11y <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
MSVC's floor quiets a signalling NaN while branchless_floor keeps its payload, so the Windows job failed the bitwise compare; every other input still compares bit for bit.
crates/engine/src/file_cmds.rs: batch keeps the save-point closure (takes the output path) and also copies the session's authorize hook into each scratch session, as main now does.
crates/raw/src/sensor.rs: both sides cap the memory of retained tiles; kept the existing retained_segment_bytes check (checked multiplication, same limit) and dropped main's duplicate inline loop.
Merges main through #3283 (Smart Filters: distinct editors, shared
disclosure and stable alpha).
filter_dialog.rs: main's split of the preview into is_smart_edit,
smart_preview_factor and scale_preview_params is kept. The plug-in branch
from #7524 (plugin_cmds::scale_for_preview scales only declared pixel
lengths) moved into scale_preview_params, so it applies to a plug-in run
on its own and to a plug-in recorded as a smart filter.
smart_ui.rs open_editor: main's smart_filter_dialog::target flow and the
shared adjust/filter dialog opening are kept; the Liquify re-open with
recorded strokes (r4) stays ahead of it. The old per-adjustment
edit_filter call is dropped with main's rewrite.
Resolutions: CLI usage, flag lists and flag parsing carry both --tiff-compression (t15) and
--jpeg-format (t16); file.saveACopy, batch, imageProcessor and layersToFiles param specs carry
both tiffCompression and jpegFormat; file_cmds keeps tiff_compression_param and
jpeg_format_param; atn_cmds keeps flag_set/applies_at_canvas_bounds (t15) and UNHOLDABLE (t16),
edit.transform uses the pixel_target guard (t15) with layer_ref(p)? (t16); ui-egui open paths
build ImportOptions (t16) and apply raw_defaults (t15); docs headers/rows unioned; atn tests keep
both sides; tr.tsv "Keep Using CPU" kept once.
crates/ui-egui/src/panels.rs: main's start_os_move call (from #6158) under the PR's drag_started_by(Primary) condition.
crates/ui-egui/src/titlebar.rs: same, in the modal drag overlay.
crates/ui-egui/src/canvas.rs: kept main's curve_point_press and the hand condition that uses it, then the PR's held-zoom block with the existing pan_delta branch as its else-if.
crates/io/tests/corpus.rs: kept main's pass_floor 188 (the PR's 187 would lower it; not re-measured here)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
docs/gaps.md: header and change-log row once each (the PR's row on top).
docs/ui-parity.md: header and change-log row once each; the Selection row keeps main's #6549 sentence and adds the PR's #6548 sentence.
crates/engine/src/presets/tests.rs: tests appended by both sides, kept whole (the PR's before main's persistence sections).
crates/ui-egui/src/swatches_ui.rs: tests appended by both sides, kept whole.
crates/ui-egui/src/vector_ui.rs: kept both sides in paths_panel (main's trash-drag state, the PR's rename id) and both new tests; the PR's tests call path_context_actions with main's added layer argument (None).
crates/ui-egui/src/lib.rs: kept both cancel_stale calls (perspective crop from main, pen from the PR).
crates/ui-egui/src/vector_ui.rs pen_down: kept main's resume-at-endpoint block and the PR's pen_doc claim; the claim comes first so a resumed path is claimed by its document too.
crates/engine/src/layer_multi_cmds.rs: took the PR's call to shift_shape; main's added check that the moved path (plus stroke) stays inside the canvas moved into shift_shape (vector_cmds.rs) so both behaviours hold.
crates/algo/src/features.rs: comment-only conflict from a commit already on main; kept main's side.
crates/ui-egui/src/crop_ui.rs: fit_frame conflict; kept the PR's side (its 2-argument crop_size::target call does not compile on main; fixed in the next commit).
crates/ui-egui/src/puppet_ui.rs: tests added by both sides in the same place; kept both whole (#6754's pin-deletion tests, then #6786's drag-preview tests).
* fix(engine): Quick Export writes the format its path names (#8519)
Quick Export as PNG renamed to photo.jpeg in the save dialog wrote PNG
bytes under a JPEG name, which upload checks refuse and file managers
don't preview. The path's extension (.jpg/.jpeg/.png/.gif/.webp) now picks
the format; any other extension is refused with a pointer to Export As;
no extension keeps Export Preferences' format.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* contributors: add benandez's real name
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Conflict in crates/engine/src/file_cmds/tests.rs: both sides added tests at
the end of the file. Kept all three (sanitize device names, exposure text,
gzipped XCF listing). main's Save for Web slice rework still goes through
`slice_file` / `portable_generated_stem`, so the device-name fix applies.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The nightly FreeBSD run fails because the timing test binary crashes with
SIGSEGV in the VM's software Vulkan driver when its tests set up devices in
parallel. Run with --test-threads=1 it passed 3 of 3 times on GitHub's x86_64
and aarch64 VMs and on a native FreeBSD 15.1 aarch64 VM, where all 121
photocraft-gpu tests pass. Exclude photocraft-gpu from the workspace test run
and test it serially, as ui-egui's integration tests already are.
decode_comps matched each nonzero layer-setting comp ID against the comp
vector with iter_mut().find(), an O(n*m) scan over two file-controlled
collections. A crafted PSD could cause quadratic CPU work during import.
Build a comp-ID-to-first-index map once and use it for setting lookups.
or_insert preserves first-match behavior for duplicate comp IDs; comp 0
(Last Document State) is still handled separately.
Finding: 47567ac0-a75f-4557-b127-ffc855e5bd8d (CWE-407)
parasites() implemented last-duplicate-wins by scanning all previously
collected names with iter_mut().find() per record. Parasite records are
file-controlled, so many distinct names caused O(n^2) CPU work before
unrecognized metadata was discarded.
Maintain a name-to-index map so replacement is O(1) while preserving
insertion order and last-value-wins behavior.
Finding: 6107f41e-9b17-4632-a9df-f88a74a796a8 (CWE-407)
Header::get scanned every card linearly, and axis resolution looks up
NAXIS1..NAXISn while find_image/data_len re-resolve the axes. Both the
commentary-card count and the NAXIS count are file-controlled, so a
crafted header multiplied them into quadratic header-processing work
without a correspondingly large decoded image.
Build a first-occurrence keyword index once in parse_header (skipping
HIERARCH cards, matching the prior filter) and use it in get().
Finding: eb2121d8-9dee-4422-bd57-f78dce60a61b (CWE-407)
actions.load gave each imported action a unique name with a helper that
restarted suffix numbering at 2 and linearly scanned all accepted names
for every candidate. A file repeating one base name many times caused
cubic comparison work during load.
Replace it with a UniqueNames helper backed by a HashSet for membership
and a per-base-name next-suffix cache, preserving the name, name 2,
name 3 ... sequence and first-match behavior.
Finding: 9c7a5fdb-131e-4687-8175-58d72007b0c5 (CWE-407)
Cryptomatte metadata enumeration had two file-driven amplifiers:
- cryptomatte_keys() tested key membership with iter().any(), O(n^2) in
the number of distinct cryptomatte/<key>/<field> attribute groups;
- layers() called layer_streams() for every key, rescanning all channels
and allocating a fresh owned stream list even when many keys name the
same layer.
Use a HashSet for key membership (keeping file order) and cache
layer_streams results by resolved layer name within each header, so
repeated keys reuse the discovered streams.
Findings:
- 120f4d51-b981-4fcc-abec-26f45f3efd29 (CWE-407, quadratic key membership)
- dae6e76b-a491-4089-892f-4cf34b911f36 (CWE-400, repeated per-key stream scans)
xmp_values advanced its cursor only past the matched key, so the
value-closing search (find(close)) rescanned the remaining packet for
every property-like substring. Repeating text such as
' tiff:ResolutionUnit>' inside the packet produced quadratic CPU work
even when no real resolution property existed.
Advance the cursor past each located value; a missing close delimiter
means the value is unterminated, so no later value can parse and the
scan stops. Valid multi-value extraction is unchanged.
Finding: 6ce55e33-27f0-4fb9-a22f-737382e1d328 (CWE-407)
The spelling list converted each misspelling's start and end byte offset
to a character offset with char_at, which recounts the text prefix from
the start every call. A layer with many short misspellings therefore
caused quadratic rescanning on the synchronous UI thread.
Collect the ascending offsets and map them to character offsets in a
single forward walk over the text. char_at is removed (byte_at still
handles the reverse direction in change()).
Finding: 392eb136-b836-4400-8ed7-f1e4549ff0ae (CWE-407)
fps_from_delays counted each frame's delay by scanning a growing vector
with iter_mut().find(), O(n^2) for a GIF with many distinct delays. A
crafted animation within the frame limit could stall the synchronous
import path.
Count delays in a HashMap and keep a first-seen order vector so the
most-common delay still wins, breaking ties toward the earliest-seen
delay exactly as before.
Finding: 23d54421-8e26-41b9-9690-ae1125ee2f85 (CWE-407)
Row 16 (9f6a3c79): preserve platform filename semantics in corpus scans
(no backslash-to-separator rewrite on Unix) and enforce destination
containment before copying files into the corpus directory.
Row 20 (6e2a985b): skip directory symlinks, dedupe visited directories by
canonical identity, and cap total directories/files so symlink cycles can
no longer drive exponential traversal.
Fixes findings 9f6a3c79-8bdf-4751-8eb4-87d3154074c9 and
6e2a985b-6b14-4b97-af07-7ca8333b9b75.
Object::get scanned the field vector linearly. An Affinity file can
define one large shared object and reference it repeatedly as sibling
layers, so repeated property lookups multiplied into quadratic CPU work
without the fields appearing more than once in the file.
Build a first-occurrence tag index when an object's fields are set
(via Object::new and set_fields, the latter covering the root object
that is filled after it is pushed). Positional fields (tag 0) stay out
of the index, and first-match semantics are preserved.
Finding: c315c4b0-dceb-459e-b39a-fa9c68ae6f40 (CWE-407)
subpath() looked up each polyline anchor's corner radius by scanning the
entire radius list, O(anchors * radii). A file supplying large anchor and
radius arrays could cause quadratic CPU work before geometry limits
inspected the path.
Build a record-to-maximum-radius map once and look each anchor up in it,
preserving the previous fold(0.0, f64::max) behavior for duplicate record
indices.
Finding: 89f65d65-f637-44fc-9a81-a9039a413a4b (CWE-407)
Reader::children re-collected a group's child objects by scanning every
raw Chld array slot, discarding nulls before the node budget counted
anything. A shared group holding a large null-filled child array, linked
many times, multiplied into excessive CPU work the MAX_NODES limit never
observed.
Charge every raw child slot (nulls included) against a document-wide
MAX_CHILD_SLOTS budget before filtering, via a new Stream::array_len
helper, and return a limit error when it is exhausted.
Finding: 3973fcd0-acda-4312-b538-d986dcffeb1c (CWE-400)
FilterEffects::parse tested the trailing bytes with
rest.iter().all(|b| b == 0) before checking rest.len() <= 3, so every
loop iteration scanned the whole remaining buffer. A block with many
empty sections produced quadratic CPU work.
Reorder the short-circuiting && so the three-byte length check gates the
zero scan, making each padding check constant work. Behavior is
otherwise identical.
Finding: 4887ae49-cfda-43b0-9296-a1816fc1ddba (CWE-407)
auto_slices tested every grid cell against the full slice-rectangle list
(covered(x, y) = taken.iter().any(...)). With slice edges forming the
grid, that is cubic in the slice count, so a crafted document's slice
arrangement could stall ordinary canvas drawing on the UI thread.
For each row band, mark covered x-cells in a single pass over the rects
that span the band, then scan the precomputed cells. This drops the inner
work from O(rects) per cell to O(1), while producing the same tiling.
Finding: 9fbea43b-3096-4cdc-b005-86d44a8fe57e (CWE-400)
Two per-pixel/per-sample amplifiers driven by a crafted ICC profile:
- table_inverse recomputed a sampled curve's flat-run edge indices by
scanning the whole table on every call, and transform construction
inverts each destination curve OUT_TABLE times. Compute the edges once
per curve via a new Curve::inverse_table batch method.
- eval_multilinear enumerated 2^inputs cube corners over every declared
input, including single-grid-point (degenerate) dimensions that
contribute nothing. Interpolate over the effective dimensions only, so
a 15-channel CLUT of one-point dims no longer does 2^15 iterations per
pixel.
Findings:
- acfbe3a3-ee58-4b7e-b72a-5a4877dd15c9 (CWE-407, repeated flat-run scans)
- 9fcc72e7-46d6-4f75-9333-cb14a65eeafd (CWE-407, degenerate-dim corner blowup)
The decode admission counted each pixel's channel samples and vector
header but not the heap capacity of the per-pixel (id, coverage) pair
lists. A compressed Cryptomatte document could therefore return pixel
storage exceeding the caller's max_alloc budget.
Include the maximum pair capacity (one pair per rank, 8 bytes each) in
the per-pixel byte count before check_bytes.
Finding: 5482ed4a-bc95-43fc-84d1-d5bfb6373761 (CWE-400)
Two deep-EXR allocation gaps:
- flatten() allocated the flattened output and the full display-window
stage (nc f32 samples per pixel) without a limit check; the deep decode
only admitted the data window at four bytes per pixel, before the
channel layout was known. Pass Limits into flatten and re-check both
buffers at the real f32 layout size.
- decode_deep built a per-pixel usize block index before validating the
chunk's declared packed sizes. Compute the block bounds first, validate
the payload is present, then materialize the index, so a truncated or
lying chunk is rejected before the index is allocated.
Findings:
- feb6fcd8-58df-47e2-a04a-d4d81d5565aa (CWE-789, display-stage allocation)
- 9c53025d-65f8-4bcc-9161-143a4c5d7f46 (CWE-789, index before payload check)
When transforms (orientation/clean-aperture crop) are applied, admission
checked only the transformed output size. The delegated HEVC decoder
allocates the full coded image before cropping, so a HEIF with a small
visible aperture over a large coded image bypassed the limit policy.
Always check the coded dimensions against the full limits before
delegating, in addition to the transformed size.
Note: the related SPS-driven and grid-tile allocations (findings
75048229 and 2db4c59e) occur inside the pinned heic-rs dependency from
parameter sets the container does not expose; fully bounding those needs
a decoder-side change and is tracked separately.
Finding: ec5cec31-8a62-4963-8045-e3ec9d9e4931 (CWE-400)
Tiff::dump appended each entry's full ASCII value with no per-value or
total-output limit. Thousands of entries can reference one large aliased
ASCII value, expanding a small diagnostic input into a multi-gigabyte
String.
Cap each ASCII preview to 256 bytes (truncated at a char boundary) and
stop the listing once the accumulated output reaches 1 MiB, marking it
truncated.
Finding: d994ec7f-25dd-4ae7-9673-04adc0aa8a3c (CWE-400)
read_craw treats every segment as a run of full-width image rows and
grew the row-reference vector before checking coverage. A tiled layout
(which does not describe full-width rows) with repeated tile offsets
could push far more than height rows, allocating gigabytes before the
post-loop coverage check ran.
Reject tiled cRAW layouts up front (cRAW is only stored as strips) and
stop collecting rows once the image height is reached.
Finding: 3c2601a1-ad92-45b6-96f7-be5da87d97eb (CWE-400)
compressed_row positioned the bit cursor by replaying every preceding
byte within its page (skip % PAGE calls of get(8)). For narrow images
this replay runs per row, so a small file could consume disproportionate
CPU just setting up cursors.
Add Bits::new_at, which sets the page position and remaining-bit count
directly from the row offset in O(1), and use it in compressed_row. A
unit test proves it matches the old byte-by-byte replay.
Finding: 2a7d3d4b-90ab-4887-9dba-c3884e224990 (CWE-407)
GainMap::row materialized the entire horizontal gain grid (points_h
floats) once per applicable image row, even though each output pixel only
needs two neighboring points. File-controlled image height and horizontal
map size multiplied into processing and allocation churn far beyond the
input size.
Add GainMap::at_rv, a direct four-corner bilinear lookup, and use it per
output column in gain_row so no points_h-wide row is built per image row.
A test proves at_rv matches the former row() + at() path.
Finding: 7ebec8df-1a7a-4d33-915f-097f8e9be0e5 (CWE-400)
upright_preview decoded a raw file's embedded JPEG with default codec
options, discarding the caller's DevelopOptions.limits. A compressed
embedded preview could therefore allocate and process far more pixel
data than the caller authorized during the fallback and color-fit paths.
Pass the caller's limits into upright_preview and translate them into
the JPEG DecodeOptions, so both the Unsupported fallback and the
preview look-fit respect the same policy.
Finding: d17bc286-6765-41ce-bd41-579ee5b75cfa (CWE-409)
Each DHT definition for table id 0..3 calls Huffman::new, which
allocates and fills a ~48 KiB lookup pair. A stream repeating DHT
definitions (and DNG tiles referencing such a stream) could rebuild
tables thousands of times, multiplying header-processing cost and
memory churn while producing little output.
A real lossless JPEG uses a handful of Huffman tables; cap cumulative
table builds per stream at 64 and reject streams that exceed it.
Finding: 7a31b581-d1b7-4f9c-91e5-ce42b47db87f (CWE-400)
read_plane decodes every tile in parallel and retains all decoded tile
buffers in before assembly copies only the useful pixels. Each
tile is charged individually, but file-controlled padding and repeated
source ranges can make the cumulative retained samples far exceed both
the input and the useful image.
Sum the padded tile samples (two bytes each) and reject the plane before
decoding when that total exceeds max_alloc.
Finding: e66981b1-0d7a-4738-a64c-37b5e8bf28d1 (CWE-770)
PDN conversion copied a referenced name into an owned String for every
output layer, while the NRBF budget charged the shared string only once.
Repeated references to one large name could multiply a small document
into gigabytes of retained metadata.
Cap each layer name at 64 KiB and the aggregate copied name bytes at
4 MiB, checked before each copy.
Finding: fd77f4b2-39a2-48b5-b9d8-4ad69cf69469 (CWE-770)
The XCF importer reparses every layer offset and allocates a new owned
name and item path per reference. Repeated offsets referencing one
record with a large name or path could retain gigabytes of metadata,
which the per-field and per-layer limits did not bound in aggregate.
Add a document-wide metadata budget (32 MiB) to the Image reader and
charge each copied layer name and item path against it, erroring when
exhausted.
Finding: 1631d2df-fe8f-4734-b14e-380db11afb26 (CWE-789)
build_mask derives its working area from sparse mask geometry grown by
feather, so two distant changed pixels could span a canvas-sized
rectangle. It then allocated f32 planes (coverage, pixel-mask values,
blur scratch) over that area, gigabytes during even a one-pixel render.
Check the estimated working bytes before the dense allocation and fall
back to the constant default mask above 512 MiB, so pathological
geometry degrades feather detail instead of exhausting memory.
Finding: 8c04c5ef-a5bd-48f4-bfcf-83615bcf11c0 (CWE-770)
When a pattern exceeded the preparation cache budget, every lookup still
called Tile::new_in, expanding the whole sparse pattern into RGBA floats
on each output tile (and possibly concurrently). The cache budget only
controlled retained entries, not this conversion work.
Reject patterns whose full RGBA conversion would exceed an absolute
256 MiB cap on the uncached path, so oversized patterns are skipped
instead of re-expanded per tile. The cap is independent of the cache
budget, so small patterns still convert on demand when nothing is cached.
Finding: cc0b2f77-5578-48ed-aba0-1f3beccd21cd (CWE-400)
The README's clean-room section names every maker-note tag the crate reads and
where it is documented; 0x001E is added, and the code comment now names
ExifTool's Nikon tag documentation as its source.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
embedded_preview_color_space only read the EXIF ColorSpace, but Nikon bodies
write none: they say Adobe RGB only in the maker note (tag 0x001E: 1 sRGB,
2 Adobe RGB). A Nikon NEF with Adobe RGB selected had its JPEG preview read as
sRGB by the colour fit.
When the EXIF does not state a colour space, the Nikon maker note's is used
(the EXIF still wins, a DNG still states nothing, other makers' maker notes
are not read). The maker-note locator in tiffep.rs is shared. A packed Nikon
NEF with the tag joins the hostile-input seeds, and rawinfo prints the stated
colour space.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
The D4 profile's saturation level of 15520 was measured on 14-bit files but
applied to the 12-bit files the body also writes, whose samples are the
14-bit values shifted right by two. 12-bit D4 NEFs developed about 2.6 stops
darker than the same scene at 14 bits, with no warning.
The profile now records the depth its level was measured at, and
cameras::apply takes the stored sample depth: 14-bit files keep 15520, 12-bit
files get 3880. A synthetic D4-sized NEF at both depths must develop within
0.2 stop (it was 2.5 stops apart before); on the public 12-bit D4 files the
difference to the 14-bit twin is now within 0.02 stop.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
fx_region_bytes counted the shape plus one map per enabled effect. Strokes
also build an inside distance and one or two outside fields, and overlays
and strokes build no map of their own, so a stroke-heavy document could
stay under the estimate while thrashing, and an overlay-only one could be
clipped though it fits. effects::map_count, next to build_maps_prepared,
counts what it builds; the test checks it equals FxMaps::bytes per layer.
The Hand tool's held Zoom goes through `zoom_tool::drag`, so with Scrubby
Zoom off a ⌘-drag draws a zoom rectangle rather than scrubbing. Say so in
the parity row, and test that case.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
crates/format/src/store.rs: the Windows tiles/blobs directory flush goes before main's manifest write (main publishes the manifest before removing stale previews), and that write takes the PR's fs argument; the PR's second manifest write is dropped.
crates/ui-egui/src/lib.rs: replay_save doc comment names both Export As and JPEG; replay_save uses the PR's FormatChoices::from_step, after main's export_dialog::replay branch.
docs/file-format-parity.md: header keeps main's text plus the JPEG Options item (last updated 2026-10-19); TIFF row is main's, JPEG row is the PR's; change-log rows once each, newest first.
docs/gaps.md: header keeps main's text plus the G10 item (last updated 2026-10-19).
Conflicts:
docs/gaps.md: header line is ours plus the PR's missing change item (later Last updated date); change-log rows kept both, newest first
docs/file-format-parity.md: same
crates/engine/src/prefs.rs: neither general.zoomResizesWindows (this PR implements it) nor general.animatedZoom (PR #3698 in this track implements it) stays in HIDDEN_UNTIL_IMPLEMENTED.
crates/ui-egui/src/prefs_ui.rs: kept both is_hidden asserts.
crates/ui-egui/src/i18n/*.tsv (20 catalogs): union of both sides' rows; no duplicate (context, source) keys.
docs/scorecard.md: ours (regenerated at combine).
crates/compose/src/effects.rs: kept main's par_units row loop in bevel_maps and ported the PR's change into it: k maps shade and the flat-face level through the gloss contour (lut_at), and the closure returns (hi, sh) without the apply_contour pair.
crates/engine/src/float_cmds.rs: kept main's cut_piece (parallel, shares full tiles) and try_lift_coverage, and the PR's CutParts::outline; the outline is read from the piece's alpha, which holds min(alpha, selection) as the PR's own lift loop computed, instead of replacing cut_piece with that loop.
crates/engine/src/float_cmds/tests.rs: git interleaved the two sides' new tests; took main's file and appended the PR's added tests whole.
docs/ui-parity.md: header line keeps main's text plus the PR's #7538 item.
Conflicts:
docs/gaps.md: header line is ours plus the PR's missing change item (later Last updated date); change-log rows kept both, newest first
docs/file-format-parity.md: same
crates/ui-egui/src/i18n/*.tsv (20 catalogs): both sides appended rows at the same place; union, no duplicate keys.
crates/ui-egui/src/state.rs: both new ToolOptions fields (type_style_defaults, type_direction) and defaults.
crates/ui-egui/src/tool_reset.rs: Type tools reset both type_direction and type_style_defaults.
crates/ui-egui/src/type_tool.rs: new type is created from default_style_params (main); direction added to it, and apply_defaults stores a direction edit in type_direction.
docs/gaps.md: header and G8 evidence from main, G8 missing text reworded for direction buttons, change-log lines of both.
docs/scorecard.md: ours (regenerated at combine).
crates/engine/src/jobs.rs: kept both Running fields (recorded_by, pre_selected) and both initialisers; main's new_fx_target block and foreign-recording flush kept; after_command takes the PR's pre_selected argument at both call sites.
crates/ui-egui/src/lib.rs: union in the PhotocraftApp fields, the initialiser and set_wgpu (main's window_device/window_lost/window_recovery_retry and set_available, the PR's gpu_render_state/gpu_fault).
docs/control-protocol.md: ui.gpu.simulateLoss line takes the PR's text and keeps main's destroy: true sentences.
crates/ui-egui/src/canvas.rs: test modules kept both (blend_viewport_regression and lasso_preview_tests); the non-test freehand Lasso code merged without conflict.
crates/engine/src/atn_cmds.rs: no `uncarried` macro arm; the four Select > Modify events (Feather, Expand, Contract, Smooth) use the existing `unless` arm with a new predicate `applies_at_canvas_bounds` (wraps the PR's `flag_set`). Helper fns kept both (`preserves_roundness`, `flag_set`).
crates/engine/src/atn_cmds/tests.rs: both sides' appended tests kept whole.
crates/ui-egui/src/i18n/ru.tsv: both sides added rows at two places; kept both (union).
docs/gaps.md: header from main, change-log lines of both.
docs/localization-parity.md: change-log lines of both.
crates/format/src/store.rs: kept both field sets in PcraftWriter::default (main's blob_hash_cache/compressed_budget and the PR's lock_wait) and both setters (set_compressed_cache_budget, set_lock_wait).
crates/engine/src/layer_style.rs: union of main's glossAntiAlias and the PR's six shading params in the bevel defaults and overlay_effect; both sides' tests kept.
crates/ui-egui/src/layer_style.rs: union: P::Contour(Some("glossAntiAlias")) plus the six new rows and values_of lines.
crates/ui-egui/src/i18n/*.tsv (16 catalogs): union of both sides' rows; no duplicate (context, source) keys.
crates/ui-egui/src/canvas.rs: crop-camera block and paint_canvas call take the PR's display_view easing; the overlay-while-panning guard (`panning`, `if primary`) is main's, replacing the PR's hand_pan && dragged condition.
apps/photocraft/src/services.rs: SAVE_FILTERS keeps the DDS entry from main and the PR's Cineon entry.
crates/codecs/README.md: Cineon table row from the PR (read and write), then main's DICOM and Amiga IFF rows.
crates/codecs/src/format.rs: caps keep the PR's Cineon (read and write) and main's Dicom and Iff rows.
crates/codecs/src/lib.rs: encode keeps the PR's Cineon arm and main's Dicom and Iff arms.
docs/file-format-parity.md: header kept from main with the Cineon write item added; Cineon / DPX row from the PR, DICOM row from main; the PR's change-log line added once at the top.
docs/gaps.md: header kept from main with the Cineon write item added; Missing list without 'Cineon write'; the PR's Cineon progress line replaces main's read-only one; the PR's change-log line added once at the top.
crates/engine/src/atn_cmds.rs (EVENTS): main builds adjustment events with adjustment_event!; kept main's Black & White macro line and used the PR's by-name Photo Filter (empty code) through the same macro, with the PR's comment.
The tooltip goes through tl!("Preview size"). All 20 catalogs get the row with the English text (no translations yet). The BrushesPanelState.scale doc comment now names the Brushes tab slider.
crates/engine/src/atn_cmds.rs: kept main's helper block (SMART_BRUSH, Color Range, clipping events) whole and the PR's radial blur consts and fns after it.
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole.
docs/gaps.md: header line kept from main with the PR's change entry prepended and date 2026-10-21; the PR's Done entry and change-log row placed above main's 2026-10-19 ones.
crates/ui-egui/src/menu_keys_tests.rs: both sides appended a test after the same helper; kept both whole (main's alt_held_through_a_shortcut test, then the PR's alt_held_during_a_drag_is_no_tap).
crates/ui-egui/src/tool_reset.rs: main's PaintBucket line (no sample_all_layers) with the PR's Type arm (adds type_style_defaults).
crates/ui-egui/src/type_tool.rs: the PR's styles_at fallback to default_styles, plus main's type_unit line.
docs/gaps.md: header and change-log lines of both (header from main), G9 progress entries of both.
docs/ui-parity.md: header from main, change-log lines of both.
crates/engine/src/atn_cmds.rs: kept main's smartBrushWorkspace/autoCutout/ClrR events and comment; refineSelectionEdge uses the PR's refine_edge_to, which also refuses a step with sampleAllLayers (main's earlier inline check).
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole.
docs/file-format-parity.md: header line kept from main with the animated WebP item added in front; BMP row from main; WebP row from main plus the PR's animation sentence; the PR's change-log line added once, above the first 2026-10-14 row.
docs/brush-parity.md: kept main's header and added the PR's change item; the PR's change-log row added.
docs/gaps.md: kept main's header and added the PR's change item; the PR's G4 progress note and change-log row added.
contributors/people.toml: union of both sides' entries.
crates/ui-egui/src/canvas.rs: main's Move cursor (arrow) plus the PR's Type path-cursor arm.
crates/ui-egui/src/type_tool.rs: main's has_transform in draw_overlay; the PR's earlier `shape` binding kept, the duplicate binding dropped.
crates/ui-egui/src/vector_ui.rs: main's draw_overlay (paths already show with any tool), the PR's vector_tool line dropped.
docs/gaps.md: header line from main, G8 text from main with the click-on-path clause, change-log lines of both.
apps/photocraft-web/src/web.rs: OPEN_EXTS kept main's list (with the DICOM extensions) and added iff, lbm, ilbm.
apps/photocraft/src/services.rs: OPEN_EXTS kept main's list (with the DICOM extensions) and added iff, lbm, ilbm.
crates/codecs/README.md: kept both the DICOM and the Amiga IFF table row and section.
crates/codecs/src/format.rs: kept both the Dicom and the Iff variants in every match, detect runs the DICOM check then the IFF check, Format::ALL length re-counted (22).
crates/codecs/src/lib.rs: one use line importing dicom and iff; both decode and encode arms kept.
docs/gaps.md: header line kept from main, with the PR's 'Cineon and Amiga IFF read' wording.
crates/paint/src/brush_tests.rs: both sides added tests at the same spot; kept main's four smoothing tests and added the PR's three pose_axes tests after them.
docs/brush-parity.md: kept main's header and added the PR's change item.
docs/gaps.md: kept main's header, added the PR's change item; G4 progress notes and the change-log row kept from both sides.
docs/control-protocol.md: kept main's eyedropper line (with the document.sampleColor wording) and added the PR's Brush Pose line before it.
crates/engine/src/atn_cmds.rs: the filter macro's to guard keeps both checks (pixel_target && !skip && holds_its_settings); kept main's preserves_roundness and the PR's UNHOLDABLE table and holds_its_settings.
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole.
crates/engine/src/atn_cmds.rs: three hunks, kept both sides (the Mn menu-item mapping and zoom ids from main, the Dcmn offset mapping and document.activate id from the PR); the document.activate arm precedes main's menu-item fallback in select_to.
docs/gaps.md: header line kept from main with the PR's change entry prepended and the date 2026-10-19.
crates/ui-egui/src/panels.rs: layer_row: kept main's thumbnail-size lines (base == 0 case) and applied the PR's one-step indent through its own steps value.
crates/engine/src/print_cmds.rs: kept main's result fields and the printServer/spooler/backend parameter text, added the bleed field and the bleed parameter text (two hunks).
crates/engine/src/print_cmds/tests.rs: both sides' appended tests kept whole.
docs/target-app-parity.md: header from main with the Bleed item added; change-log rows from both sides, newest first.
crates/engine/src/atn_cmds.rs: kept this PR's range-checked Kind::Long arm and main's Kind::ExactLong arm; dropped the PR's Group Layers explicit-layer refusal (#7236), as #7968 exports that layer by name.
crates/engine/src/atn_cmds/tests.rs: kept both sides' tests whole; dropped the PR's groupLayers layer:3 refusal case (#7236).
crates/engine/src/edit_menu_cmds.rs: fade(): kept main's checked_sub for the previous state and took the PR's FadeTarget-based surface_of lookups and messages.
crates/engine/src/file_cmds.rs: exposure_text kept the PR's fraction rule and main's guard against "1/1 s" (n > 1.0 for exposures from 0.1 s to 1 s); the main test for it still holds.
crates/engine/src/file_cmds/tests.rs: both sides' appended tests kept whole.
docs/file-format-parity.md: header line keeps ours plus the PR's change item, date 2026-10-18; TIFF row has both notes; change-log rows kept both, newest first.
crates/engine/src/presets/tests.rs: main's test already covered export_presets; kept main's setup and loop, added the PR's recent_documents key and its extra damaged/null assertions, dropped the PR's unconditional per-key export_presets assert (main's conditional one stays).
docs/control-protocol.md: kept main's table rows (document.inspect with snapshots, layer.list) and added the PR's four-floats wording to document.pixel.
apps/photocraft-web/src/web.rs: OPEN_EXTS kept main's list and added dcm, dicom, dc3 after cin.
apps/photocraft/src/services.rs: OPEN_EXTS kept main's list and added dcm, dicom, dc3 after cin.
dialogs.rs: main's Displace-map prompt runs before the fallible confirm_command. filter_dialog.rs: main's ask_displace_map uses the new confirm_command signature; SMART_FILTER_SET_PARAMS stays for main's uses; a smart-filter edit without a "layer" param previews the active layer, as the command runs it (main's smart_displace_preview_uses_the_map_document).
catch_unwind wrapped the app callback too, so a panic in setup_context,
PhotocraftApp::new or set_wgpu printed "no GPU adapter" and passed both
tests untested. A panic after the callback starts is now re-raised.
Addresses CodeRabbit's review on #7530.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The version-6 slices parser matched each trailing descriptor ID against
the slice list with iter_mut().find(), an O(n*m) scan over two
file-controlled collections. A crafted resource with many slices and
many unmatched descriptor IDs could therefore cause quadratic CPU work
during import.
Build an ID-to-first-index map once and use it for descriptor lookups.
or_insert preserves the existing first-match behavior for duplicate IDs.
Finding: cf3f9ceb-d6df-4087-a2d6-dbe0f12657c3 (CWE-407)
Create/Release Clipping Mask and Brightness/Contrast, Black & White and
Gradient Map were written out by hand with the same body as
adjustment_event! or each other, which SonarCloud counts as duplicated new
code (photocraft's quality gate fails on 9.8% duplication from the GrpL
entry). adjustment_event! takes an optional command id, and clipping_event!
builds the two clipping events. The mapping table is unchanged.
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
The merge of main kept `#[cfg(test)]` on `mod editor_tests;` only, so
`layer_thumbnail_scaling_tests` (which uses the dev-only egui_kittest)
was compiled into normal builds.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#3289, #3974 and #3975 landed in main, so the Smart Filters row code meets
#3974's thumbnail geometry.
Conflicts:
- smart_ui.rs: the rows keep our layout (separate name and Blending
Options targets, shared disclosure, real mask thumbnail) and take
#3974's geometry: the header height and the shared-mask side come from
smart_filters_header_geometry, sub-rows are SUB_ROW_H, the mask is
hidden at None and the header name follows it. #3974's own
drag_started and selection handling is dropped, ours already covers
both through the name target. #3974's scaling test module is kept
next to editor_tests.
- All 20 i18n catalogs: both sides appended rows at the end; kept both,
exact duplicates dropped, dupscan reports 0 problems.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
17f149e7 keys mask thumbnails on the document height as well as the
width; main's #2117 test still computed the old width-only key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Include the native format crate in Windows platform selection so publication barriers and retry regressions execute in the unit and integration CI legs. Document the durability helpers and regression tests flagged by review.
Validated 20 platform-selection scenarios and all 130 native Windows format tests, plus formatting, Clippy, layering and the format WebAssembly check.
- smart_ui.rs: confirm_command sends every Smart Filter edit through
smart_filter_dialog::edit_command, the builder the canvas preview
already uses, so preview and OK cannot build different payloads.
- lib.rs: the mask thumbnail cache revision includes the document height
as well as the width. Two documents that restore the same layer id and
width no longer share a thumbnail framed for the other's height.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Conflicts:
- filter_dialog.rs: preview_document_for_target keeps our proxy scaling
and `p` clone; main's Bitmap rule (the preview drops Output resolution,
#5559) is applied to the same `p`.
- hu/sr/tr/vi catalogs: both sides appended rows at the end; kept both, no
duplicate keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
One conflict, pattern_cmds.rs: insert_fill_layer keeps the default_mask
argument and takes main's placement(command, params)? signature.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- canvas.rs: the Content-Aware Move preview now comes before the
Layers-panel reorder preview. The reorder preview is built from the
session document and would drop a pending Content-Aware Move result.
- layer_multi_cmds.rs: preview_rejects_invalid_drop_unchanged also runs
layer.moveTo for each rejected drop: the missing target, `into` on a
non-group and the invalid position are errors, the self-drop is a
no-op, and the document stays unchanged each time.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Fuzz step interpolated the user-controlled workflow_dispatch input
${{ inputs.fuzz_seconds }} directly into a run: shell command, allowing
script injection by whoever triggers the workflow. Pass it via an env var
(FUZZ_SECONDS) and reference "$FUZZ_SECONDS" so the value is treated as
data, not shell. Resolves SonarCloud githubactions:S7630 (BLOCKER).
A layer keeps its own XMP packet (Layer::xmp), where scripts and embedding
apps store data about the layer under their own namespaces.
- PSD/PSB/layered TIFF: read from and written to shmd > cust > layerXMP
(UTF-16, NUL-terminated, after layerTime), laid out as Photoshop 2026
writes it; an unchanged Photoshop block round-trips byte for byte.
- .pcraft: optional `xmp` on each layer.
- layer.xmpMetadata {layer?, rawData?}: read, set (one undo step) or
remove with null; refuses malformed XML, DTDs, NUL and > 4 MiB.
- Duplicate Layer copies the packet; document.inspect reports hasXmp.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Conflicts:
- commands.rs: layer.duplicate keeps the "Add copy" option; main's #5880
dropped the second damage pass (`before` and note_insert) for single
duplicates, so the clone of the document goes away too.
- jobs.rs: the success arm runs main's relayout damage first, then our
Expand New Effects disclosure update.
- panels.rs: the Background no longer loads a transparency selection on a
thumbnail click (main), and a thumbnail size of None has no thumbnail to
click (ours); main's `pos` / `on_thumb` now come before layer.select, so
the size guard moved there.
- hu/sr/tr/vi catalogs: both sides appended rows at the end; kept both, no
duplicate keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A set built from the bindings dropped action keys from the main shortcuts but copied them from
the alternates, and applying a set replaced the alternates wholesale, dropping an action's
alternate key. Both now treat alternates like the main shortcuts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
In File > New, the field labels and headings (Width, Height, Resolution,
Color Mode, Background Contents, PRESET DETAILS, the Saved tab's text)
were selectable egui labels, so hovering them showed the I-beam as if
they were editable. In Photoshop they are static text.
- The dialog body turns off selectable labels, so every static label
keeps the arrow. The name field keeps its text cursor and the size
fields their scrub cursor.
- The preset-save error message stays selectable so it can be copied.
- Test: a kittest hovers the six labels and the empty Saved list
(arrow) and the name field (text cursor). It fails without the fix.
Save Actions wrote Photo Filter steps with the four-char event code PhtF.
Photoshop loads the file but stops on playback ("The command 'Photo
Filter' is not currently available"). The same descriptor runs under the
string event photoFilter (measured by the reporter in Photoshop 27.11 at
8 and 16 bits). PhtF is the adjustment's class, not an event.
- Photo Filter now exports by name (photoFilter), like Black & White,
Box Blur and Surface Blur since dfe94d6a. The descriptor is unchanged.
- Import still accepts PhtF through LEGACY_CODES, so actions PhotoCraft
saved before keep loading, and they re-save under the name.
- Tests: Photo Filter joins the by-name/legacy-code test (export id,
import by name and old code, near misses stay Photoshop steps). A new
test records the issue's exact step, saves it strictly with nothing
skipped, loads a Photoshop-authored step and an old PhtF step, re-saves
both as photoFilter, and plays both the same as a direct run at 8 and
16 bits. Both tests fail without the fix.
A Duotone PSD opens flattened from its merged image (its layer records are
not read back), but with Maximize Compatibility off a layered Duotone was
saved with the white placeholder, so reopening it showed white. Its merged
image is now always written, like a document without layer records.
Test: a_layered_duotone_keeps_its_image_without_maximize_compatibility.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Clouds, Difference Clouds, Fibers and Lens Flare opened as editable smart
filters even when their descriptor held an extra or repeated setting, a
value of another type (a long centre, a unit seed), an RGBC colour with
extra keys or out of 0-255, or a Lens Flare enum of another type. A
re-save rebuilt the item without it. They now need exactly the keys and
value types Photoshop writes; anything else stays a psd.unsupportedFilter
and round-trips byte for byte, as main's newer filters do with only_keys.
Test: render_filters_with_settings_we_dont_model_stay_verbatim (30 cases).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The raw-curve path kept a second whole-area f32 buffer next to the retoned
pixels while converting them back to the surface format; it is now one
band-sized buffer, read from the source per band to keep the stored alpha.
The develop doc says what retone documents: close to Open, differing only in
the deepest shadows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
openingRaw is {name, path, demosaic}; ui.demosaic is accepted only while a raw
opens; rawDefaults.demosaic is the method the app's raw opens develop with; Open
re-develops when the chosen method differs from the one the open used.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A render needs every layer's effect maps. On a large document whose
ordinary layers' whole-region maps take more than the effect cache budget
together, building one evicted the next one needed, so every render, of
any size, built them all again: 1.8-4.4 s per render on a 9000x6000
document with three full drop-shadowed layers. When they don't fit, local
maps are now built over the render rect padded by twice the effects'
reach, as a shapeless layer's already are (#1909), which gives the same
pixels there.
The open-time dialog's raw curve is shared (Arc) by the proxy worker from main
(#5964) and the full-resolution preview.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
docs/scorecard.md regenerated with cargo xtask scorecard (24 of 172); the
Preferences test keeps main's Enhanced Controls rows and the demosaic row;
catalog tails keep both sides.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Conflicts with main's smart-filter and layer-style work, both sides kept:
- smart_map: main's 27 more filters and this batch's Clouds, Difference
Clouds, Fibers and Lens Flare (45 known); the item colours are written for
Pointillize, Tiles and the three coloured render filters.
- layer_style: main's new Stroke default (1 px inside black) with this
batch's Overprint.
- psd_export: main's verbatim unknown adjustments with this batch's unpadded
Gradient Map check.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Hst2 adjustment wrote and read hue under 'Hue '; Photoshop's key is 'H ' (stringID hue, as PhotoCraft's PSD Hue/Saturation smart filter already uses). Exported actions failed in Photoshop with a program error, and Photoshop-recorded actions imported without their hue. Export writes 'H '; import reads it, and still reads 'Hue ' from PhotoCraft's own older files.
The overflow button sat right after the last tab that fit, leaving a gap of up to a tab's width at the end of the strip. Photoshop keeps it at the strip's right end; so do both the Pro and the Studio tab strips now.
Conflicts only in the parity docs: status lines take main's text with the
JPEG Options entry (FILE-215-19) on top; scorecard takes main's rows with
FILE-215-19 done and File compatibility 10 / 12 / 5.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Position a fixed-size marquee at the current pointer throughout creation, including when crossing the initial press. Preserve Alt centering and completed-selection movement. Add real-canvas regression coverage at normal and atlas zoom levels.
Builds on itsF4LCON’s exact-size and single-click correction from #6026, preserved as a separate preceding commit.
The previous head ba7e4e9e passed every check; the branch sync to cc479f60 changed
nothing this PR touches, and the failing engine test timed out at its 4 s deadline.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Conflicts in the ko, zh-Hans and zh-Hant catalogs: keep both this PR's
Make Selection row and main's Bicubic rows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keeps both the CJK access-key test (#5863) and main's new
a_command_shortcut_leaves_the_bar_and_runs test in menu_keys_tests.rs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A press between pixels snapped both edges outwards, so 10 x 10 selected
11 x 11 and 1 x 1 selected 2 x 2. The near edge now goes to the closest
pixel edge and the far one is the size away. A click without a drag
places the Fixed Size selection, as in Photoshop.
Mezzotint and Color Halftone use main's only_keys (now also rejecting a
repeated setting) and enum_from (the Mezzotint table becomes string
pairs) instead of the branch's own only_keys and typed_enum; the new
malformed cases use main's pixelate_item.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Offset and Custom use main's only_keys (now also rejecting a repeated
setting, for every filter that uses it) and enum_from instead of the
branch's own only_keys and typed_enum.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
run_pairs_params_with_the_preceding_cmd counted the named layers, which
a naive zip of params[i] to cmd[i] also passes. Assert which layer
carries each name, so only pairing each --params with the --cmd before
it passes.
Co-authored-by: Claude Opus 5.5 <[email protected]>
`commands --help` also exits 0 with output and empty stderr when it
runs, so the help test could not tell help from execution; assert the
command list (`file.new`) is absent from help output.
Pin that a `--cmd` without `--params` gets its own default: `First` and
`Third` must each appear exactly once, so a neighbour's params leaking
onto the middle command fails. Also assert exit 0 in the reversed-order
repeated-flag case and explain the layer count.
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Add in-process tests for the current hand-written parser so the module
split and the clap port can prove they keep behaviour: top-level
help/version/no-args dispatch, `<sub> --help`/`-h` for all eight
subcommands (returns on stdout, nothing runs), unknown flags on every
subcommand (exit 2, flag named on stderr), `--flag=value`, bare flag
given a value, value flag missing its value, last-wins for repeated
value flags, and `run` pairing each `--params` with its preceding
`--cmd`.
Assertions use exit codes, which stream got output and the offending
flag name, not full help or error wording, so they survive clap's
different messages.
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
A corner's arrow was aimed from the opposite corner, so a box wider than
about 2.4:1 (or taller) showed the horizontal (or vertical) arrow at its
corners. Corners now aim along their bisector as the view shows it, so an
upright box's corners always show the diagonal arrows, as in Photoshop.
The same fix applies to the type box and Content-Aware Move's box.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The blur tables are now (Photoshop value, ours) string pairs, so the typed
enum check goes through main's shared enum_from instead of a second reader.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Move cached keys and compressed buffers between saves, and compare weak
allocation pointers without temporary strong-reference updates. Keep
payload budgets and archive bytes unchanged.
Allow fallible tile edits after a cached save. Move pixel storage when
only weak references remain; use a fallible copy for shared storage and
restore the original tile if the copy fails.
Cover cache key reuse, edits after saves at every depth, and weak/shared
tile ownership. Name the fixed revisions behind the existing timings.
The Perspective Warp session started with no planes, so a plane drawn
before the guide passed the test. Drag out one plane across the guide
and check its outline draws over the guide, like the Puppet Warp mesh
and the Vanishing Point plane.
The dialog's Set dropdown replaced its bindings with the set's copy and
computed "(modified)" itself, so it still dropped the actions' F-keys and
marked a set modified when one was assigned. It now uses the engine's
ShortcutSet::apply (what useSet does), shortcut_sets::differs (what the
engine's `modified` uses) and ShortcutSet::from_bindings for Save and
New Set. Sets sent with `sets` (the dialog's OK) hold no action keys
either, and an action key a set saved earlier still holds doesn't count
as a change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The status bar's report of a failed worker develop was cleared only when the
dialog drew, so after a control-channel edit rendered a newer frame,
statusError still reported the old failure until the next frame. The check is
now a method that menu_update also calls after its synchronous render.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The guide pointed to rust-framework-options.md, which is no longer in the
repo, for version pinning and the UI toolkit choice. Say where versions are
pinned, and drop the two open decisions this document already records as
made (egui/eframe on wgpu; the WebAssembly plug-in API).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a pixel layer that doesn't fill the canvas (a placed photo, or the
canvas grown), Select Subject ran over the whole canvas, so the photo's
rectangle stood out from the empty canvas around it as the subject. The
mask then revealed the rectangle, which a Stroke layer style outlines.
Search and refine the edge within the layer's own pixels instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Capture Shift and Alt at the initial press so early key release does not turn Add or Subtract into Replace. Cover real canvas clicks and rectangle drags, including a subsequent unmodified click.
While drawing, the Pen shows both handles of the last anchor, but an
Alt-press only grabbed the outgoing one; on the incoming handle it added
a new anchor instead. Alt now grabs whichever shown handle is under the
pointer and turns it alone, as Photoshop's Alt (Convert Point) does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Batch dropped recorded Save / Save As / Save a Copy steps and saved the
active document after the whole action, so "..., Save As, Close" failed on
every file, and a Close that left another document active saved that one
instead. Treat a recorded save as marking the result (Photoshop's "Override
Action 'Save As' Commands"): write the document as the save found it when it
is closed or another document is active at the end, else save it after the
action as before (#3058). Droplets keep the save steps too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Without a usable GPU the canvas uploads one texture of at most 4096 px per
side (or the device's smaller limit), so a larger document was drawn from a
reduced picture, magnified and bilinear-filtered: blurred at 100%.
When that whole-document texture is coarser than the screen, the view now
also composites just the part in sight at the screen's resolution (one texel
per document pixel at 100% and above, crisp nearest sampling) into a
view-sized texture drawn over the reduced one on its own checkerboard. The
region snaps to a 256-texel grid so small pans reuse it, edits and stroke
steps in view update only their texels, and its cost depends on the window,
not the document. The size decision is the pure `detail_plan`.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The OS clipboard read (arboard, plus decoding a copied file with
photocraft-codecs) and write now run under crash_guard::guard, like Open
and Export. A panic there becomes the paste's error in the status bar and
the app keeps its documents, instead of closing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Unknown manifest fields were always ignored, so a plug-in may already
carry any "unit" value; failing its install would break it. Anything but
"px" (an unknown string, a number, null...) now reads as "none", as when
the field is absent. The plug-in loader has no warning channel, so this
stays silent.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- 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 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A filter plug-in's live preview runs on a reduced copy of a large
document, but its params only went through the built-in name list, so a
pixel length with another name (Chromatic Aberration's Amount) previewed
2-4 times too large, and a unitless `size` or `radius` was divided.
number and int params now take an optional "unit" ("px" or "none", the
default; anything else is a manifest error). The preview divides px
params by the reduction (defaults included, ints rounded, kept in range)
and limits the factor so a px param with a positive minimum never drops
below it. Unitless params are never scaled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Drag the visible image in the print preview using paper-scaled egui points.
Switch Center to manual without moving the grab point, allow signed Top/Left
offsets, and retain the shared preview/PDF layout and mirrored behavior.
Add regression coverage for negative PDF placement, native HiDPI dragging,
centering, clipping, scaling, fit, mirroring and invalid geometry.
Validation: 25 engine print tests, 19 file-dialog unit tests, four print-dialog
integration tests and the adversarial-command test pass. Formatting,
all-target clippy, desktop check/build and layer checks pass.
Three existing socket-binding tests are sandbox-blocked and excluded from
the successful print-suite rerun. GUI capture and wasm checks remain limited
by the environment.
PointerGone and WindowFocused(false) cleared the mapping mid-gesture. On
macOS a drag that leaves the window still delivers its release, which then
arrived as a primary release, so egui kept the secondary button down. The
mapping now lasts until the release; every press recomputes it, so a missed
release can't remap a later plain click.
Regression test: Control press, pointer exit, focus loss, re-entry and
release leave no button down (failed before this change).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Look in from each edge through strips that double in thickness, so a subtracted
or intersected subpath's empty margin costs a few renders instead of one per row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A hold left from an earlier press (the tool changed before its release)
is only reused by the press that started it (same origin and start time);
a new press starts a fresh one instead of zooming at once about the old
anchor or never becoming a hold.
- Only a press the canvas owns becomes a hold: not one on a dialog, a
scrollbar or the rotate compass over the canvas.
- Each canvas (tiled windows, secondary windows) keeps its own hold, so a
hold on one never zooms another.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On Windows the modifier state the wheel events carry could lack a held
Alt until another modifier was tapped, so Alt + wheel scrolled like a
plain wheel (or zoomed, with Zoom with Scroll Wheel on). The Alt key's
own press and release still arrive, and the menu bar already follows
them. The canvas wheel now counts Alt as held from its key press to its
release as well as from the event's modifiers.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
slice.list and slice.set report a stored slice with its own canvas-clipped
rectangle (what slice.set takes back) and its visible parts under "pieces";
a slice wholly covered by later ones is still listed, with number null.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review follow-up: the Color Lookup dropdown scans the LUT folder on a
worker thread and shows the last finished scan meanwhile; files over
64 MB (larger than any LUT the parser takes) are not listed; the UI test
uses a tempfile directory.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keeping the print size at a higher Output resolution could ask for more than
the 300,000 px document limit; the side was clamped, silently changing the
print size. Refuse it instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
toolBrushes.owner names the tool whose brush lastBrush holds. While the
session brush can't be remembered yet (a sampled tip too big to embed),
lastBrush keeps the previous owner's brush, so keep that owner saved too;
otherwise the next launch gave the old owner's brush to the tool selected
since. Exit flushes the brush before the tool memory, as a frame does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
macOS delivers Control-click as a primary press with Control held, so no
secondary_clicked()/context_menu() site saw it. ControlClick rewrites it to
the secondary button in raw_input_hook, before panels and tools route
clicks. Control-Option (brush resize, Liquify) and Control-Command keep the
primary button; Control pressed mid-drag still overrides snapping.
Tests: the normalizer's unit tests (now clearing the frame's texture delta),
and a canvas test that feeds a real Control-click through the normalizer
into the app: Marquee and Pen open the tool menu, Move the layer menu, Brush
the Brush Preset picker, and nothing is painted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The uncancelled speedup is for layers with locked transparency only, the
numbers are local runs and the perf baseline is not re-measured yet.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
BUG-207-5: only eight retouch tools are tested to follow the Channels panel target (Healing,
Spot Healing, Sponge and Remove are skipped by changes_gray). BUG-208-4: the Polygonal Lasso test
keeps the selection for Shift or Alt and commits a Shift polygon; no test commits an Alt one.
The notes this branch changes now cite file:line, as docs/development.md asks; ui-parity.md gets
a dated row for the marquee Shift+Alt test instead of rewriting the 2026-10-11 one.
Regenerated docs/scorecard.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
large_file_bench needs about 30 GB (its PSB, as its doc comment says), not 16 GB; the opt-in
tests need about 12 GB (2 GB PSD) and 25 GB (4.4 GB PSB), as their #[ignore] reasons say.
Regenerated docs/scorecard.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Preferences > File Handling > LUT Folder: its .cube, .3dl and .look
files appear in Color Lookup's 3D LUT File dropdown under the built-in
looks, and picking one embeds it like Load 3D LUT... does. Agents can't
set the folder, like the other folder preferences.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The coarse Content-Aware Move preview scaled the selection down in its own
8-bit format, so scattered 1/255 pixels averaged to 0 and the preview failed
with "the selection is empty" while the command moves them. Scale the
selection in 32-bit float instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A soft shape's transform frame, snap rectangle and Properties X/Y/W/H came from
the path plus the full stroke width on every side, so a centred or inside stroke
grew the frame when Density or Feather was set. The frame is now the pixels the
same shape renders at full Density without Feather (edge strips only, clipped to
the canvas), identical to the hard shape's for every stroke alignment.
feather_matches_photoshops_ramp now enforces the 1-level claim the scorecard
makes (measured max deviation 1 at 8, 16 and 32 bit).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A higher Output resolution passed the resample budget (document bytes only)
but then allocated ~40 B/px of f32 composite, gray and output buffers at the
new size. Budget the whole working set before resampling.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Opening: Smart Blur and Radial Blur enums must have their measured enum
type, Surface Blur's radius must be #Pxl and Smart Blur's radius and
threshold plain doubles; anything else stays verbatim instead of being
re-written in another shape.
Saving: a present value that is not a number or lies outside the range
the filter opens with (Surface Blur radius 0.5, a text Radial Blur
centre) is an error with a warning, not a filter that reopens as
unsupported or a centre silently taken as 0.5.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit's docstring-coverage check counted the two functions this PR
touches without a doc comment.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A recognized no-options filterID (or Shadows/Highlights) with a present
but unreadable Fltr opened as editable and lost the Fltr on save; it now
stays an unsupported filter, written back unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve vector_ui.rs: keep main's shows_anchors and the PR's
selected_saved_path; the selected saved path now shows its anchors with the
same tools as the work path (Path Selection and the Pen tools).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
main added the GPU adapter strings (#1885) at the end of every
translation table, where this PR adds the Color Table strings. Both
blocks are kept.
Co-authored-by: abdul-karim-mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- English stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Greek, Cyrillic and CJK names follow the Latin ones
- Lang::for_picker holds the order; the Preferences dropdown and the native menu's Language submenu used the registry order, which is the order the languages were added
A press inside a warp cell (no anchor or handle) pulls the surface; this
test drags a gradient layer that way, commits, and checks every committed
pixel sits where the preview's warp.map put it, including the grabbed
point at the drop position.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- The favourite badge overhangs the swatch by 4 px; placing it with ui.put allocated that overhang, so the row grew and the swatches after the hovered one re-centred, flickering when the shift moved the swatch out from under the pointer
- Click and hover through ui.interact, the star through icons::paint; neither allocates
Review follow-ups:
- Windows also reserves COM¹–COM³ and LPT¹–LPT³ (the ISO 8859-1
superscript digits), with any extension. `is_windows_device_name`
takes them too; `sanitize` keeps those characters, so `COM¹` became a
device file name before.
- Save for Web's `portable_generated_stem` added `_` at the end, so
`path: nul.v2.png` gave `nul.v2_.html`, still the NUL device, and the
export failed as an unsafe component. Both it and `sanitize` now share
`portable_device_name`, which puts `_` before the first dot.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
File › Package named its folder and document after the document with
`file_cmds::sanitize`, which let Windows device names through. On
Windows 11 a document named `nul` failed to package with "path not
found (os error 3)"; on Windows 10 `con`, `prn`, `aux`, `comN` and
`lptN` are devices too.
`sanitize` now adds `_` before the first dot of a device name
(`nul` -> `nul_`, `com1.v2` -> `com1_.v2`) on every platform, so a
package written anywhere can be copied to Windows. The device check
moves from web_cmds.rs to `file_cmds::is_windows_device_name`, and
web_cmds uses it unchanged.
The new package test fails on Windows without the fix.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Brushes tab only had the Size slider of the current brush, which Photoshop users expect to
resize the palette's thumbnails. A second slider under the search field now scales the grid
cells and list rows (0.5x-2x) and leaves the brush size alone.
With one top-level menu open, a pointer pushed against the top screen edge (maximized window)
lit the neighbouring titles (egui hovers widgets within interact_radius) but did not open them,
because the hit test required the pointer inside the title's rect. The title's column now
extends up to the window's top.
swatches.use converted a CMYK swatch (e.g. a Pantone .ase colour 0/0/100/0) to sRGB and
forgot the inks, so the Color Picker showed 5/1/92/0 and CMYK documents were painted with
the round-tripped inks. It now records them like a colour picked in CMYK (#4196).
The Info panel and Color Sampler readouts used a naive (1-r-k)/(1-k) formula while the
Color Picker converts through the CMYK profile, so the two never agreed (white-to-black
rows differed by tens of percent). device_cmyk now calls convert::rgb_to_cmyk, inside the
document's CMYK space for readouts.
Command dialogs with a `dir` field (Artboards to Files, Layers to Files, Layer Comps to
Files, Package…) get a Browse… button that opens the system folder picker, and Install
Plug-in… gets one that picks the .wasm file. The dialog body flags the clicked field and
dialogs::show asks the file dialog, so the filled value lands in the dialog fields.
The Select and Mask… buttons of the marquee, lasso and Magic Wand options bars were always
live, though Select > Select and Mask… is disabled until there is a selection or a layer mask.
They now follow the same enabled state.
Older Photoshop keeps each type layer's text object at
/DocumentObjects /TextObjects (named keys, run lengths under /Length) and
re-renders from it. type.edit refreshed TySh and the numbered /1 /1 list but
left the old text there, so the layer came back with its old text.
build_txt2_at now refits that entry like edited_object does for the numbered
ones: the model text, one ParagraphRun run per paragraph, every other run
array cut or extended to the new UTF-16 total. Entries are only edited in
place (never grown), a malformed run array leaves the entry as the file has
it, and an unchanged block is still written back byte for byte. A legacy
block holding both lists with a stale older copy is repaired on re-save.
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is a software routine there, called once per sample of the backdrop under every adjustment layer of an 8- or 16-bit document. branchless_floor rounds through 2^23 and steps down where that went up: bit-identical to floor for every f32 (a new test sweeps every 4099th bit pattern and the edges around whole numbers and 2^23), and with no branch the loop vectorizes.
An adjustment layer's result was blended back into the backdrop pixel by pixel through blend_rgb, which looks up the HDR flag and dispatches on the mode, three channel calls deep, for every pixel. Normal (nearly every adjustment layer) gives the source exactly, so blend_back skips the dispatch for it, and walks the backdrop, the result and the coverage in step instead of indexing by coordinates. Bit-identical.
Baseline x86-64 has no SSE4.1 roundss, so f32::floor is an out-of-line libm call there. lut, which every Levels, Curves, Hue/Saturation range and modern Brightness/Contrast sample goes through, floored its non-negative table position. Truncating is the same there (NaN casts to 0 either way) and inlines. Bit-identical.
color_balance_channel recomputed the folded black point, white point and midtone gamma, and four of the five powf calls of the gamma > 1 Hermite join, for every sample. Those depend only on the sliders, so BalanceCurve works them out once per channel and the per-sample step is the clamp and one powf (or the Hermite polynomial). The CPU compositor builds the three curves once per buffer and the GPU plan once per LUT (it sampled color_balance_channel 3 x 4096 times). Results are bit-identical: a new test compares the curve against the per-sample formula it replaced, to the bit, over 123 slider settings, both Preserve Luminosity modes and a 1/1024 ramp.
When document B was saved (Save As, Save a Copy, a replayed action) onto the
file document A in another tab was bound to, both tabs pointed at that file and
both read as saved, though A's content existed only in memory: closing A lost
it without a prompt, and A's own Save wrote over B's file.
Every completed save and Save a Copy now detaches any other open document bound
to the written file (compared with file_cmds::same_file): it loses the path and
reads as unsaved, and a notice names it. Detaching rather than refusing keeps
replaying a recorded fixed-path Save As working (actions::tests::recorded_save_*).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Make Selection named the committed path "work" or "layer" by itself, which
could differ from where `pen_commit` put it: a vector mask targeted through
the Channels target goes to the mask, and a path drawn with a saved path
selected is added to that path, not the Work Path. It now decides the layer
case with `targeted_vector_mask`, as `pen_commit` and `path_to_selection`
do, and otherwise uses the active path, which the commit selects.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A `make` of a `Chnl` whose `At` reference has further items (the mask of
a named layer) was mapped to a layer.layerMask command, which acts on the
target layer. Such a step now stays a kept Photoshop step, reported as
unsupported.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The canvas draws `display_doc` (a Move drag's moved layers, a transform or
fill preview, ...), but the pasteboard holes for loose layers came from
the committed document, so a loose layer dragged over the pasteboard was
covered at its new position until the move was committed. Cut the holes
from the displayed document instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
seam_labels gives a new image only the overlap cells strictly on its side
of the seam. A contained image (all overlap) one grid cell wide in a line
has no such cell, so the seam ran through its only cell and it got nothing:
the all-black mask of #5745 again, for a layer the canvas crops to a strip.
Where a contained image's side of a line is empty, it now takes the seam
cell. Both sides are handled; other images are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
main added a GPU timer argument to submit_batch and the batch runs to run_cell,
so the OOM submit-and-retry path no longer compiled after the merge. Also add
the admission-evicted bytes to the flush counter with saturating arithmetic.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
cargo fmt --check failed on the if/else added in bbcc8321; rustfmt
(max_width 160, use_small_heuristics Max) wants it on one line.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The merge with main took main's generated docs/scorecard.md. It is
brought back in line with scorecard/file_compat.toml (FILE-215-19 done):
File compatibility 8 done / 14 partial / 5 missing, and the FILE-215-19
row with its status and note. Edited by hand to the generator's row
format, since `cargo xtask scorecard` isn't run locally; CI's
`cargo xtask scorecard --check` verifies it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The merge with main left `mod jpeg_tests` above `mod dds_tests` in one
group, which `cargo fmt --check` reorders.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
selection_only_change used same_pixels, which turns down every video layer,
so any selection step marked such a document modified. It now compares video
frames (painted and original) by tile pointer as well; same_pixels itself is
unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Conflict in apps/photocraft/src/main.rs: main added
`app.perf.gpu_info.set_available(&rs.available_adapters)` where this branch
installs the texel guard; both are kept (set_available first, then
texel_guard::install).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The pixel-only cap (2^27) still let two RGBA layers past the canvas with
about 11 GiB of working buffers. Count what Auto-Blend holds per pixel
(each layer's pixels, luminance, alpha and weight, plus the merged picture
and depth map) against a 4 GiB budget; over it, keep to the canvas as
before. The per-layer region reads and luminance/alpha buffers now use the
fallible allocation helpers, so running out of memory is an error.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve conflicts with #4's "Auto on Windows leaves Vulkan out when an
Intel adapter is present" (3c9c93ed) and #7813's adapter choice
(`configure` gained `wanted`):
- gpu_startup.rs: `intel_dx12_applied(.., vulkan_left_out)` from main and
the patch's `dx12_default_applied` become one
`dx12_default_applied(adapters, chosen, os, vulkan_left_out)` that
returns the maker's name (Intel or Qualcomm). `vulkan_left_out` counts
only for Intel, since only an Intel DXGI adapter makes
`windows_auto_backends` drop Vulkan. `configure` keeps main's `wanted`
parameter and Vulkan-free instance; the System Info note names the maker.
- Tests: keep both main's Intel instance test and the Qualcomm test,
updated to the merged signature, plus one assertion that Vulkan being
left out doesn't make a Qualcomm DX12 pick "the default".
- Module docs and docs/development.md: main's wording, with Qualcomm
(Windows on ARM) added to the DX12 default.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A continued redraw reused the cap from when it began, and the finished composite
was stored with it; after Memory Usage dropped (or the document grew) it could be
kept over the current allowance. The cap is taken again when a redraw continues
and when it finishes (once, not per frame).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A PQ or HLG image is decoded to linear light and its ICC profile dropped.
When its nclx primaries have no built-in profile (e.g. DCI-P3, code 11), the
document was left untagged, so the linear samples were read as sRGB-encoded.
It is now tagged linear sRGB, which keeps the tones right, and the import
warns that the primaries were assumed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Move the empty/whitespace/non-string Color Profile cases into the bad-input
table, which asserts the preferences (presets included) and their revision
are untouched.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`default` and the `working-*` aliases make file.new resolve Color Settings'
working profile, which on the desktop can be a user-configured .icc path.
Treat them like edit.assignProfile does; exact `working` and `none` skip
resolution and stay allowed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pages dropped to make room can still be held by the work recorded since the
last submit, so the immediate retry may fail although the memory is about to
be freed. render now submits, waits for the GPU and runs the cell once more
before sending the refresh to the CPU (encode, which can't submit, still goes
to the CPU). Bytes dropped to admit pages also count towards the
intermediate-submit threshold.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Channels panel menu, Blending Options, canvas right-click menus, Color Picker
titles, rasterize prompts, GPU and monitor status, status messages and Liquify
tool tooltips. Each string is passed through tl! at runtime and was already
translated in other catalogs.
Labels with letters of their own were assigned in row order, so one before a
suffixed label could take the key the suffix shows, leaving that row without it.
Suffixed labels now take their keys first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
At the lowest zoom of a huge document a drag can exceed the ±10,000,000 px the
ΔX/ΔY readout and the live preview show; layer.translate got the raw distance.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a machine with Iris Xe and a discrete GPU the default request can pick
the discrete one, so the probe would not test the affected adapter.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- type.setStyle/edit/create: an unknown `baseline` is a BadParams error and no longer resets
superscript or subscript to Normal (#5807).
- Indexed/Bitmap PSD save warns whenever flattening loses something, also for a single hidden,
masked, non-Normal, effect, type, shape or smart object layer, not only for two or more layers
(#4816).
- brush.presets.importAbr: the apply step keeps only `replace` and `select`, not a second copy of
a base64 `data` string of up to ~1.3 GiB (#7180).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Main moved on (integration batch #8095 and others). Two conflicts:
- docs/gaps.md revision history: both rows kept, newest first.
- docs/scorecard.md (generated): main's version taken. It needs
`cargo xtask scorecard` again: with FILE-215-19 done, File
compatibility reads 8 done / 5 missing rather than main's 7 / 6.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main added DDS saving (#5993): DDS Options, and FormatChoices carrying the
Targa and DDS answers through every save path (this branch's SaveChoices
carried Targa and JPEG). Resolved by keeping main's name and making it
the one struct for all three answers.
- lib.rs: FormatChoices { tga_bits, dds, jpeg } with from_params (strict),
from_step (lenient JPEG read of a recorded step), apply and record;
main's separate block is folded in. Both prompts (dds_options and
jpeg_options) are kept everywhere: save_to asks Targa, then DDS, then
JPEG Options; save_options_open covers all four. save_in_place takes
main's (path, choices) signature and records the plain-Save step with
plain_save_step for JPEG plus the Targa/DDS answers (save_in_place_with
folded into it). journal_as_plain_save relabels a parked Targa, DDS or
JPEG prompt and keeps tgaBits/dds*/quality/jpegFormat when dropping the
path. replay_save reads choices with from_step.
- SAVE_OPTIONS_OPEN names all four prompts; DDS Options uses it too.
- targa_options_ui: main's plain-Save step (no path) through this
branch's write_answered_save. JPEG Options gets main's Enter-on-a-
focused-Cancel fix.
- view_cmds Save a Copy asks Targa, DDS, then JPEG Options; write_copy
takes FormatChoices.
- control.rs: both app.save tests (JPEG and DDS) kept.
- engine file_cmds: SaveOpts keeps jpeg_format and dds; param docs list
both jpegFormat and ddsCompression/ddsMipmaps. CLI: both --jpeg-format
and --dds-compression/--dds-mipmaps. codecs re-exports JpegFormat and
DdsCompression/DdsOptions.
- docs/book: both entries kept (control-protocol app.save lists both).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
canvas_tool_menu.rs tests: keep both main's #1056 test
(path_selection_tools_open_the_path_menu) and this branch's
pen_options_bar_makes_a_selection_from_the_path. make_selection still
mirrors choose_pen's commit-then-dialog flow, which #1056 left unchanged.
ko/zh-hans/zh-hant.tsv: keep this branch's "Make Selection" row and
main's four clipboard-unavailable rows, with no duplicate keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The PR stopped writing PhotoCraft's defaults for params a step leaves
out. Checked against Photoshop, that is fine for Fill opacity and mode,
Crop delete, Selective Color method, Channel Mixer monochrome, Paint
Bucket contiguous and Gradient Map reverse/dither, but Photoshop plays
other missing keys differently: Canvas Size anchors top left (PhotoCraft:
centre), Apply Image blends Normal (Multiply), Color Balance turns
preserve luminosity off (on), and a Paint Bucket step without a
tolerance doesn't play at all. Those, and the unverified Hue/Saturation,
Color Balance tone and Save for Web keys, are written again as before.
- `tools.setColors` exported only the foreground of a step that sets
both colours; such a step now doesn't export (the strict save refuses
it, as before the PR).
- `kind` on an imported `layer.setAdjustment` made Photoshop's "reset to
default" (`set` with an empty `T`) a no-op merge; it now joins only
params that edit something.
- `cargo xtask corpus --atn` (and `--all`) failed on Windows: the Bjango
archive has a macOS `Icon\r` file tar can't create there; it's skipped.
- `test-corpus --changed` now runs for `atn_cmds` changes, and the
`ALL` doc comment is back on `ALL`.
Tests: defaults_photoshop_plays_differently_are_written,
set_colors_exports_only_a_single_colour and
adjustment_layer_reset_still_resets fail without this commit; the psd
and engine .atn corpus tests still pass (16 files byte-identical; 513
kept Photoshop steps).
Co-authored-by: Fabio Lissi <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Color Table dialog: after Load…, picking a preset in the Table menu still
applied (OK) and saved (Save…) the loaded colors, as the hidden `colors`
field won over the menu (`image.mode.colorTable` applies `colors` over a
preset). The dialog now drops the loaded colors from the fields OK sends
once a preset is picked; Save… uses them only while the menu is on Custom.
- Swatches import from bytes (browser, drag and drop) sniffed the format by
content, and .act has no signature, so an .act whose first color is
00 01 xx or 00 02 xx went to the .aco parser and failed. A file named
.act now imports as .act; .aco and .ase are still detected by content.
- Removed "Export Swatches as Color Table…" from the Swatches menu: Photoshop
saves .act from Image > Mode > Color Table, not from the Swatches panel.
Co-authored-by: Abdul karim mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve conflicts with the shared checkmark column (#5705): menu rows and
submenu titles now go through menu_row_text, fed the decorated labels from
menu_keys::localized ("新建(N)…") so CJK rows keep their access keys. Keep
both the #5863 and #5705 tests.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
apps/photocraft{,-web}: main added "cin" to OPEN_EXTS; kept it and the PR's
"act". apps/photocraft/src/services.rs: main gave File > Open one filter per
format (#6337) and checks with every_open_format_has_its_own_filter that the
filters cover OPEN_EXTS exactly, so "act" joins the Swatches filter.
i18n: both sides appended sections at the end of every catalog; kept both.
Co-authored-by: Abdul karim mia <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Found while testing crates/ui-egui/src/color_picker_ui.rs:94-102 (parse_hex)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Older builds wrote every generated `grdm` 2 bytes short of four-byte
alignment (86 + 20n bytes), which Photoshop refuses. The PR pads new
blocks, but opening such a file and saving it again kept the old block:
the keep and Reverse/Dither retarget paths reuse any `grdm` that parses
to the same Gradient Map. Such a block is now regenerated, like the
legacy `phfl` repair. Only blocks that are byte for byte the old writer's
output are matched, so other writers' blocks (stops in CMYK/Lab/Gray)
are still kept verbatim.
Test: legacy_unaligned_gradient_map_is_repaired_on_resave (146 bytes
without this change).
Co-authored-by: Sir Starch <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
type.create stored the font field as a family name, so an exact
PostScript face such as Arial-Black warned and fell back. Resolve
only an exact PostScript match and keep that face's family, weight,
slant, and name. Unknown families still warn.
Fixes#8063
Signed-off-by: Gabrieljr42 <[email protected]>
The author rewrote the branch to a75039792e (the same Duotone code as
b10c9b91f1, without its main merge). This merge puts their new head
into the fix-up unchanged; the tree is identical to 2d7c62055e.
Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A normal big-endian TySh that the typed reader rejects (an unknown text
descriptor version, a stub from another writer) opened as a text layer on
main, with the best-effort text and its TySh saved back verbatim. With the
PR it became a raster layer, and the PSD export strips TySh from rasters,
so open + save turned the type layer into pixels for Photoshop. Now only
foreign-order blocks and unsafe transforms become pixels; the old fallback
runs under the same transform gate (shared as
photocraft_text::psd::transform_is_safe). The warning no longer claims the
type data is kept.
Test: canonical_type_the_typed_reader_rejects_stays_type_and_saves_its_block
(descriptor version 17, ordinary matrix) fails without this change.
Co-authored-by: Sean Lavery <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
crates/text/src/psd.rs: keep both appended test modules (transform_safety_tests
and main's nonfinite_tests). docs/file-format-parity.md: add the #4758 item to
main's current change line.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Found while testing crates/cms/src/lutfile.rs:227 (write_cube)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
lfx2_roundtrip_all_kinds compares by writing both sides again, so a reader
or writer that drops FrFX `overprint` on both sides still passes. The new
test checks the parsed flag for off and on and for a stroke without the key
(other writers); it fails with either the reader or the writer reverted.
The new "Overprint" checkbox label had no row in any of the 20 catalogs, so
every non-English UI showed it in English, unlike its translated siblings.
Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With the ink block read, an opened duotone has its real inks. A
single-layer one is saved flat, which writes the inks rendered as RGB
(flat.rs); the test compared that with the grey plate.
Refs #4814
The Duotone Color Mode Data is undocumented. From files Photoshop 2025
made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as
in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four
transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20,
..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain
and eleven overprint colours.
- Import interprets the inks, so an opened duotone shows them instead of
its grey plate; the block is still kept raw and written back while the
inks are unedited. A colour-book ink (not readable here) shows as black
with a warning.
- Export writes a block for duotones made in PhotoCraft (RGB ink colours,
curves on the 13-point grid or sampled at it, default dot gain,
computed overprints), so every 8/16-bit Duotone is saved as Duotone.
Photoshop opens such files as Duotone, and a black monotone whose curve
is 0 up to 60 % density stays white exactly up to 60 % there.
Refs #4814
docs/file-format-parity.md: main added three 2026-10-15 revision rows
(DDS save, PNG sTER, PSD guides) where the PR added its own; keep all four.
Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
lfx2_roundtrip_all_kinds compares re-serialized bytes, so dropping the
new `.with("antialiasGloss", ...)` line from write_one still passed every
test: both sides then lose the flag the same way. The new test writes a
Bevel with the Gloss Contour's Anti-aliased on and reads the flag back;
it fails with that line removed.
Co-authored-by: varanus-salvator <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A PANTONE (colour-book) ink is stored by its book key, so it read as
black: Photoshop's usual Black + PANTONE duotones showed, exported flat
and converted to RGB much darker than the grey plate before. Photoshop
writes every ink's Lab equivalent in resource 1066; it now gives the
ink its colour. Without it, the grey plate is shown, as before.
- Ink colours go through channel_map::decode_known_color (HSB inks were
black with a "colour book" warning; CMYK is colour-managed).
- Photoshop has only 8-bit Duotone and refuses to open a 16-bit Duotone
PSD: a 16-bit Duotone is written as Grayscale with the warning again.
- A curve on the 13-point grid without a 0 % or 100 % point was written
with -1 there, which Photoshop refuses to open: both ends are filled.
- A layered TIFF of an Indexed, Bitmap or Duotone document says it is
written in its pixel format (CodeRabbit's thread at tiff_layers.rs).
Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#4935 is stacked on #4816. Merging our reviewed #4816 fix-up (baa854341b,
which already contains origin/main) brings both up to date; no conflicts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Their noise was sampled in document coordinates, so moving a smart object re-rendered a
different texture with the same params. The pattern is now anchored at the filter's bounds:
the object for a smart filter, the canvas on a layer. Fibers, like Clouds, takes the canvas
as its bounds even with a selection, which only masks it, so layer results stay as before
(CodeRabbit review on #6160).
A fitted native print drops a caller's printable area and fits only the one the
driver reports for this paper. The Print dialog drops it whenever output or a
preview is set, also when that change and the confirm land in one frame.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The only conflict was the end of cs.tsv: this branch appends the
catch-up block and main appended the Czech rows of #6546 (Memory Usage),
Liquify's Show Backdrop and #1327 (DDS Options). Both blocks are kept;
no key is duplicated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Flush NTFS directory publication after replacing a saved file. Report a
failed barrier as uncertain durability instead of successful Save. Flush
directory-bundle objects before the manifest, including on same-writer
retries, and stop before garbage collection when publication fails.
Add regression tests for failed barriers, retained old objects, cached
object retries, and directory handles. Keep existing Windows metadata
preservation and the separate Unix flush-error policy.
Validation: 130 format tests, 657 I/O corpus tests, clippy, layers, wasm,
12 acknowledged-save power cuts, six publication/cleanup cuts, and 1,368
native fault cases with exact archive comparisons. Existing preview-only
failure ordering remains tracked by #7548/#7549.
Fixes#7858.
Found while testing crates/cms/src/lutfile.rs:111 (parse_cube) and :227 (write_cube)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Found while testing crates/ui-egui/src/crop_size.rs:86-107 (format_length / normalize_length)
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Inside a group (or an artboard), a layer's indent only made up for the
disclosure triangle it doesn't have, so its thumbnail and name lined up under
the group's and the hierarchy didn't show. A layer inside a group now indents
one step more than its depth. The step comes out of the indent budget, so a
narrow panel still squeezes the indent before the name. Top-level rows don't
move.
Test: a_groups_layers_indent_past_the_group (fails before: child and group
names start at the same x).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With Snap To › Layers on, other layers, selections, shapes and boards now snap
to an artboard's edges and centre, as in Photoshop. An artboard is a group, and
groups weren't snap targets, so a board offered nothing; its children still
offer their own edges. A board being moved (in the exclude list) doesn't snap
to itself.
Test: an_artboard_offers_its_own_edges.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The workspace test's filesystem-path classifier reads the command's
registry description, which mentions profile paths; the command takes no
host filesystem path as input, so it joins the allowlist beside
prefs.get/prefs.reset.
The comment still described the reverted encoder-batching design; on main
keep_overlap submits its own copy, so the pinned property is that the
kept overlap never overwrites tiles rewritten in the same update,
whatever the submission shape.
The shell checks an opened document's missing fonts on the UI thread.
missing_fonts locked the shared text engine even for a document with no
type layers, and the engine's first use waits for the installed-font scan
started at launch, which takes minutes on some machines: the window froze
at 2% or 95% on the first open after every launch. A document without type
now returns at once, and a background open with type layers waits for the
scan on its worker ("Loading fonts") so the later UI-thread check is quick.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a Windows ARM64 PC the Qualcomm Vulkan driver lost the device
mid-session (Layer Style > Inner Shadow edits), the window's egui-wgpu
renderer panicked on the lost device and PhotoCraft exited. With the
backend set to DirectX 12 the reporter could not reproduce it.
`auto` already ranks DX12 before Vulkan for Intel on Windows. Extend that
rule to Qualcomm, matching both the id its Vulkan driver reports (0x5143)
and the one DXGI reports ("QCOM", 0x4D4F4351). System Info names the
maker in its note.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
tl!("HSB") and tl!("RGB") are missing from some locale TSVs; reuse HSB/HSL and RGB Color so every_tl_literal_is_translated passes.
Co-authored-by: Cursor <[email protected]>
The D4 profile's saturation level of 15520 was measured on 14-bit files but
applied to the 12-bit files the body also writes, whose samples are the
14-bit values shifted right by two. 12-bit D4 NEFs developed about 2.6 stops
darker than the same scene at 14 bits, with no warning.
The profile now records the depth its level was measured at, and
cameras::apply takes the stored sample depth: 14-bit files keep 15520, 12-bit
files get 3880. A synthetic D4-sized NEF at both depths must develop within
0.2 stop (it was 2.5 stops apart before); on the public 12-bit D4 files the
difference to the 14-bit twin is now within 0.02 stop.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
Found while testing crates/engine/src/file_cmds.rs:690 (xml_unescape), used by li_values, find_attr and read_file_info
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A trackpad tap-to-click delivers PointerButton pressed+released in the same
frame, so both the modal dialog and the reopened ComboBox popup request
move_to_top in one pass; egui's stable sort then leaves a previously used
popup (its static LayerId sits earlier in Areas::order, persisted via
ui.ron) behind the dialog.
Register ctx.set_sublayer(parent, child) for open Foreground popups inside
a Foreground parent (press_menu::above_parent), applied to PressCombo,
press_menu::show, popup_value_field, swatch_popup, crop presets and
layer-style contours.
Regression test: single_frame_trackpad_tap_keeps_dialog_dropdown_above_modal
and_selects_option (1-frame tap after a prior open/close, on the Indexed
Color dialog's Palette dropdown).
Note: the palette grid context menu added by the Indexed Color PR
(storytold/photocraft#7320) gets the same one-line above_parent_ui
treatment in a follow-up once both are merged.
Preferences > Performance > Rendering Mode only takes effect when
PhotoCraft restarts, but the only "Applies at next launch." note sat at
the bottom of the tab in the Graphics block, so switching CPU/GPU looked
like it did nothing. The note now sits under the setting, as #1677 does
for System Title Bar, and goes with it when a search hides the row.
Found while testing crates/engine/src/file_cmds.rs:768
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The Zoom and Hand tools' context menu put Zoom In and Zoom Out first.
Photoshop's lists Fit on Screen, 100%, 200% and Print Size, then Zoom In
and Zoom Out after a separator, which is where users look for them. The
rows are reordered (Fill Screen keeps its place by Fit on Screen); the
order test follows.
The control.rs agent test (ui.inspect over the Zoom tool's menu) and the
two context-menu docs now state the new row order.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011K1peyrh5MdZ6ttHkVq8Gh
With one layer selected and no pixel selection, Align used the canvas
bounds, so a layer inside an artboard jumped out of its board (to the
middle of the canvas, between other boards). As in Photoshop, it now
aligns to the artboard holding it; a layer outside any artboard, and an
artboard itself, still align to the canvas. An explicit `to` is unchanged.
par::map and par::chunks_mut already pick rayon on native targets and run serially on wasm,
so the two hand-written cfg blocks go; the threshold gets a name.
Hovering a row in the font menu previews that family on the canvas, and for a family the
host serves but hasn't sent yet the preview asks the host for the whole family
(`served::request`). Sweeping the pointer down the menu therefore downloads every family
it passes over, each one a font file, or a file per style for a static family.
A served family that isn't installed now previews once the pointer has rested on its row
for 0.25 s; moving to another row starts the wait again. Installed fonts (bundled, system,
or served and already fetched) preview at once, as before, and picking a font still
fetches it immediately.
Tests: a served family is asked for only after the rest, and passing over rows asks for
nothing; an installed family previews on the first frame.
Since #4047 a stroke dabs every twentieth of the brush radius, four times as often as before,
and P30's 800 px update went from 9.6 to 35.9 ms on the perf runner. Each dab walked its
nodes (about 125,000 at 800 px) on one thread. A node reads only the snapshot and the field
as it was before the dab, and the writes land after, so rows are independent: dabs over
16,384 nodes compute their rows on the rayon pool (wasm stays serial). Same writes, same field.
The full-resolution preview read the warped region's source and premultiplied it on one
thread, about a million pixels at an 800 px brush and most of each update (P30). Each band
of rows now reads and premultiplies in one pass on the rayon pool (wasm stays serial); the
conversion is per pixel, so the samples are the same.
Neighbours are read at precomputed offsets into the window instead of
per channel through bounds-checked Image::get. Normal mode with 1-5
channels runs the interior (whole kernel in the window) offset-major
over each row with a branchless select: a rejected neighbour adds -0.0,
an exact identity, and every pixel sums in the same order, so the
output is bit-identical. Border pixels, the edge modes and 6+ channels
keep the per-pixel path.
24 MP RGBA8, High quality: r3 0.55 -> 0.23 s, r12 6.25 -> 1.78 s,
r100 3.64 -> 0.95 s. bench_filters2 gains r12 and r100 rows.
The old kernel stays as a test oracle, compared bit for bit across
colour modes, 1-8 channels, alpha, radii, qualities, modes, thresholds,
output rects and NaN/inf samples.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A misspelt filter rendered nothing and a misspelt setting rendered the
default, both silently (CodeRabbit review). Also documents apply and the
halftone test.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Every pixel was pure ink or paper at any Contrast, dots could never fill
their cell (white crosses in the shadows), mid-greys came out the wrong
darkness and the dot grid was square to the image.
Halftone Pattern now keeps a continuous range of tones, as Photoshop
describes it:
- each pattern is a threshold (spot) function spread evenly over its
cell: round dots on a 45° screen that grow from the centre, meet as a
checkerboard of squares at 50% and fill the cell; rings round the
image centre; horizontal lines
- each tone is offset (closed form, a 256-step table per Contrast) so a
cell's average ink is exactly that tone: black stays solid and white
stays paper at any Contrast
- Contrast is how steep the threshold is: soft "blurry squares" at 0,
crisp dots at 50
- Size 1 is a fine screen (3.5 px, 25.5 px at 12)
The gallery_sheet dev test can render one filter with chosen settings
(GALLERY_ONLY, GALLERY_SET, GALLERY_TAG).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Keep nested folders collapsible while filtering (#7257); only preset_list top-level headers force-open when the filter is non-empty.
Co-authored-by: Cursor <[email protected]>
save_in_place_with journalled only the JPEG answer, so a replayed or scripted file.save with
tgaBits replayed at the default bits (CodeRabbit on #7878).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The released-port version only proved the port was unused a moment earlier. Between
drop(free) and the connect, another process can be handed that exact ephemeral port,
and the case then reports a protocol error or blocks instead of Unreachable - the
same flake this branch was meant to remove, just quieter.
connect() now goes through open_tcp(host, port, addr, open), a private helper that
takes the connect call as a parameter. The refusal test decides what is closed, so it
owns no port: it asserts Unreachable, that the message names the endpoint, and that
the reason the connect failed survives. The end-to-end "nobody reachable" case now
names a host that can never resolve (RFC 2606) rather than a port nobody owns.
Fake-server protocol tests are unchanged, and no public injection point or dependency
was added.
`CUPS_SERVER=fe80::1%3:631` parses as `Tcp("fe80::1%3", 631)`, and host() wrote `[fe80::1%3]:631` into the HTTP `Host:` header and into the `ipp://` printer-uri. A bare "%" is not valid inside an authority - RFC 3986 section 3.2.2 reserves "%" for escaping - so a strict HTTP/1.1 server can answer 400 and the printer-uri is not a readable URI.
The zone is now written "%25"-escaped for the wire and kept raw for the socket, which is the only place a scope means anything (measured on Windows: `("fe80::1%3", 631).to_socket_addrs()` gives `[fe80::1%3]:631`).
The two reviews disagreed about the form. Codex-cloud (fork #68) cited RFC 6874; CodeRabbit here pointed out that RFC 9844 (Aug 2025) obsoletes RFC 6874 and suggested dropping the zone from the authority. Dropping it was considered and not taken: it would send `Host: [fe80::1]:631` and `ipp://[fe80::1]:631/...` for a server reached at `fe80::1%3`, naming a different and ambiguous link-local host from the one the socket opened to, and it would rewrite the printer-uri a queued printer is registered under. The escaped form is what CUPS, curl and the ipp:// URIs CUPS writes use, and current URL libraries (yarl) still read "%25" as the URI form of a zone while rejecting a bare "%".
Regressions: numeric zone ids in both authority forms, an interface-name zone, the raw host after parse, and the unchanged IPv6/IPv4/hostname authorities. The printer-uri assertion no longer echoes host() back, so it pins the authority the fake server receives. Reverting the escape fails the new assertion (checked).
Platform limit, unchanged by this commit: an interface-name zone (`fe80::1%eth0`) does not resolve on Windows (getaddrinfo 11001), so only numeric scope ids are usable there, and the client reports that as `Error::Unreachable`.
cargo test on a standalone copy of the crate: 11 passed / 0 failed; cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.
journal_as_plain_save relabelled a confirmed Save As over the document's own file only when the
step held nothing but its path, so file.save {"quality": 5} stayed a file.saveAs with the path
and a replay wrote the recorded file rather than the one played on. It now drops only the path,
keeping quality, jpegFormat and tgaBits; a step with any other answer (TIFF layers) keeps the
Save As (CodeRabbit).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
disk_change_prompt::intercept now keeps the save's choices in the dialog fields, and Replace
writes with them, so file.save {"quality": 5} over a file another app changed no longer asks
JPEG Options after the prompt or drops a tgaBits (CodeRabbit on #7878).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A File › Save that confirms the document's own flat JPEG was recorded as `file.save {}`, which
dropped a quality or format changed in JPEG Options; playing the action wrote at the file's own
quality instead. The step now carries the answer (`plain_save_step`: `quality` left out when the
file's own quality was kept, `jpegFormat` when Baseline), and a replayed `file.save` writes with
it, without asking (`save_in_place_with`). Ported from #7878.
Co-authored-by: kayrockscreenprinting <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Save As and Save a Copy to a JPEG now ask for JPEG Options before writing,
as Photoshop does. Re-creates #4330 on current main and folds in the
unique parts of #5580 (#5563).
- codecs: `JpegFormat` (Baseline, Optimized, Progressive) on
`EncodeOptions`. Progressive writes SOF2. Baseline Optimized fits the
Huffman tables only at 4:4:4 (Gray, CMYK) or with Progressive: fitted
tables make the baseline scans non-interleaved, which zune-jpeg 0.5.15
decodes wrongly with 4:2:0 chroma (zune-image#419), as Save for Web
already works around.
- engine: a `jpegFormat` save param (file.saveACopy, Batch, Image
Processor, Layers to Files and every SaveOpts user; anything but the
three names is BadParams before writing) and a shared 0–12 -> 1–100
`jpeg_quality()` that maps NaN/inf to 100.
- ui-egui: `jpeg_options_ui` (Quality 0–12 with Low/Medium/High/Maximum
presets, Format Options), parked like Targa Options. It starts at the
opened JPEG's own quality (`file_encoding`, #4032), else the Export
preference; left there, OK writes that quality exactly and the step
records no `quality`. `SaveChoices` (Targa bits + JPEG) replaces the
bare `tga_bits` through file.save / file.saveAs / app.save, replay and
Save a Copy; automation never prompts. `write_answered_save` is shared
with Targa Options. `park_at` + `Start` let any save seed the prompt.
A File › Save that confirms the document's own flat JPEG is still
journalled as a plain `file.save` (#3531).
- CLI: `--jpeg-format`.
- i18n: five new strings in all 20 catalogs (#4330's duplicate de/es/zh
`Low`/`High` rows dropped: main has them since #4008).
- Docs: book raster-formats JPEG bullet, control protocol, file-format
parity, gaps G10, target-app parity; scorecard FILE-215-19 done.
Co-authored-by: SckraT <[email protected]>
Co-authored-by: PierrunoYT <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolves the conflicts with main: the 20 catalogs keep main's new rows with the paragraph
direction rows after them, tool_reset.rs takes main's line plus type_direction, and gaps.md
re-applies the G8 entry on main's latest version.
Photoshop on Windows closes the current document with Ctrl+F4 as well as
Ctrl+W. PhotoCraft bound only Ctrl+W, so Ctrl+F4 did nothing.
Add ("file.close", "Cmd+F4") to prefs::ALTERNATE_SHORTCUTS off the Mac
(macOS keeps Ctrl+F4 for window focus, and Photoshop for Mac has no such
key). It goes through the same guarded Close, so unsaved changes still
get the save prompt; the test checks both cases.
Rolling Slope - Descending, as on the type in the report. Main's shader fails
it (CPU and GPU differ by 131/255); this PR's matches the CPU render.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The four labels show in the Keyboard Shortcuts dialog and the command palette, like the brush size and hardness rows next to which they go in all 20 catalogs. The test that checks those rows in every language now covers the four as well.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The cfg(test) budget was 4 MB, 2^20 px of 8-bit RGBA only, so the turned
crop test that landed in #5007 (an 862 x 803 px layer at 16 and 32 bits)
was refused. It is now 2^20 px of the widest format (32-bit CMYK with
alpha, 20 MB), so whatever the old 2^20 px test limit let through still
fits; the refusal tests stretch far enough to stay over it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
PhotoCraft's action recorder writes layer.select with a name parameter,
but select_to only checked for the layer key. This caused recorded layer
selections to fail strict .atn export or be silently dropped when saving
via the desktop UI with skipUnsupported: true.
Support both name and layer parameters, reject duplicate name occurrences
that cannot be faithfully represented in Photoshop .atn descriptors, and
add regression tests.
Fixes#7185
preserve was compared against the playback default "squareness", but
the command reads anything other than the exact string "roundness" as
squareness, so "Squareness" plays as the event does and lost nothing by
export -- and was refused all the same.
The table now says how each setting behaves on replay rather than only
what replay uses: ReplaysAs for a value replay substitutes (a seed, a
blur centre), DiffersAt for a setting whose one differing spelling is
all the command distinguishes.
A refused save must leave the action alone: its step is still there to be
played. The test now compares the action's steps after the rejected save and
again after the `skipUnsupported` one, rather than taking the result of the
save as the whole story.
Crystallize is mapped with one field, `ClSz` for `cellSize`, while the command
takes `{"cellSize":3..300=10,"seed":u32=0}`. Photoshop's `Crst` descriptor has
nowhere to put a seed, so `seed: 37` was dropped on export and replay seeded
from 0 -- a different pattern, with `skipped` empty and nothing in the file to
say so. The report measures 583 differing colour components at every bit depth.
Auditing every mapped event against the params its command accepts found two
more of the same kind:
filter.pixelate.crystallize seed replay seeds from 0 (#7668)
filter.blur.radialBlur centerX, centerY replay spins about the middle
filter.other.minimum/maximum preserve replay keeps squareness
(`layer.flattenImage`, `tools.swapColors` and `filter.blur.blur` also take params
their event has no field for, but those are targets and selectors rather than
settings that change the pixels.)
`UNHOLDABLE` names those settings with the value the event plays as. A step that
leaves the setting there exports exactly as before; a step that sets something
else is refused, the way `pixel_target` already refuses a filter aimed at a
mask. No descriptor key is invented for them: without a Photoshop-written file
to read, a guessed `FlRs` in `Crst` or `Cntr` in `RdlB` would be a clean-room
violation and could make the file wrong in Photoshop too.
Fixes#7668.
The warp places pixels within +-2^20 px (WARP_REACH) and clamps the rest
away. The byte budget admitted results wider than that (a 2,000,002 px
strip fits 8192 MB), and a plain move past it already emptied the layer
without an error. `warp_output_clipped` says when the result would be cut,
and the budget check refuses it: "would reach past +-1048576 px, the
farthest a transform places pixels". Docstrings for the touched functions.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Codex-cloud's review of this fix found two more places where an IPv6 print server did not work.
host() feeds the Host: header and the ipp:// printer-uri, and it wrote the endpoint the way it is stored: "::1:631". The last colon reads as the port separator, so CUPS got a malformed authority and a malformed printer-uri even though detect() could now connect. An IPv6 host is bracketed there, and in Display, which is what the user is shown; to_socket_addrs still gets the bare address, which is what it needs.
A bracketed endpoint with anything else after the bracket - "[::1]:abc", "[::1]:70000", "[::1]junk" - fell back to the default port, so a mistyped CUPS_SERVER was accepted whenever something answered on 631 and jobs went to an endpoint nobody configured. The whole string now stays the host: it cannot resolve, so detect() reports it unreachable.
Tests: an_ipv6_endpoint_writes_its_authority_in_brackets, and three mistyped endpoints in the parse test. Both fail before this commit and pass after. photocraft-ipp 11 passed / 0 failed; clippy --all-targets -- -D warnings and fmt --check clean.
The fixed 2^30 px cap refused any transform of a stitched layer past about
1 GP, on any machine. A result is now priced in bytes at its pixel format
(with the alpha the warp adds) against Preferences > Performance > Memory
Usage (default 8192 MB), which Session::apply_prefs sets. The report's
37708 x 35259 px 8-bit result (5072 MB) now transforms: 10.7 s, 6.1 GB on
top of the source. Past the allowance the message names it:
"... (10144 MB), more than the 8192 MB Memory Usage allows (Preferences >
Performance)".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every finished tile kept its f32 pixels until the last one was done, 16
bytes a pixel of an RGBA result on top of the result itself. Writing each
tile as soon as it is warped holds only the tiles in flight.
100 MP 8-bit layer (release, M4 Max, 16 cores), extra memory per result pixel
and time: scale 110 % 16.4 -> 4.8 B, 0.8 -> 0.6 s; rotate 15 deg 12.5 ->
3.4 B, 0.8 -> 0.5 s; 90 % + 15 deg bilinear 12.7 -> 3.4 B, 0.5 -> 0.3 s;
90 % + 15 deg bicubic 19.9 -> 12.4 B (its pre-reduced copy of the
source), 1.0 -> 0.8 s.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
normalize_shortcut title-cased every named key, so a binding set to Shift+PageUp was saved as Shift+Pageup, which egui's case-sensitive Key::from_name rejects: the shortcut never fired. Multi-word key names (PageUp, PageDown, Open/CloseBracket, Open/CloseCurlyBracket, BrowserBack, IntlBackslash) now keep egui's spelling, and shortcuts::parse matches key names case-insensitively so bindings saved by earlier versions work too.
Outside text fields the clipboard keys go to the document, so ⌘C/Ctrl+C
over text dragged across in a notice (where a refused Free Transform's
message lands) ran Edit › Copy on the layer and the message was never
copied. Text selected in a label (dragged across, double/triple- or
shift-clicked) now takes ⌘C, as egui-winit's Copy event or as the Mac
menu's key press; ⌘X and ⌘V stay the document's, and a plain click on a
label leaves ⌘C to Edit › Copy.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The last case of error_statuses_and_broken_replies_are_errors called printers(&Endpoint::Tcp("127.0.0.1", 1)) and expected Error::Unreachable. No port is guaranteed closed: on a FreeBSD CI VM something accepted on port 1 and reset the connection, so the client got as far as sending and the test failed with "print server reply: sending: Broken pipe (os error 32)" instead.
Bind 127.0.0.1:0, read its port and drop the listener, so the case uses a port the OS has just released, and keep the assertion. The fake() helper in the same file already takes its port that way.
A held port really does read differently, which is why the old case asserted a property of the machine rather than of the client: against a listener that accepts and closes immediately, printers() returns "print server reply: reading: An existing connection was forcibly closed by the remote host. (os error 10054)" on Windows, not Error::Unreachable.
cargo test -p photocraft-ipp: 9 passed / 0 failed, the changed test five runs in a row; clippy --all-targets -- -D warnings and cargo fmt --check clean.
The 2^30 px refusal went through allocation::error, so it read "not enough
memory for transforming pixels (...)" although nothing had been allocated.
It now says the result is too large to transform and names the limit.
A real allocation failure still says "not enough memory".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Endpoint::parse split the server string on its last ':', which is wrong for an IPv6 literal: "::1" became host ":" on port 1, and "[::1]:631" kept its brackets inside the host, which to_socket_addrs cannot resolve on Linux or macOS. An IPv6 CUPS endpoint was therefore always refused by detect(), even when CUPS was listening on it.
A bracketed server now takes its host from inside the brackets and its port from after them. A bare address with more than one ':' is the host with the default port, because its last ':' cannot be told apart from a port separator; a zone id (fe80::1%eth0) carries no ':', so a port can still follow it. host:port, a bare host and a socket path are unchanged.
Test: "::1", "[::1]", "[::1]:631", "[fe80::1]:6310", "fe80::1%eth0:631", and the cases that already worked. Fail before, pass after.
Inside a paragraph box, off the glyphs, a press places the caret, as in
Photoshop: the move zone now excludes it. The reach drops from 40 to 38 pt
so a press 40 pt outside a paragraph box still commits the text (#7606's
test). Photoshop measured 35 pt moving and 48 pt committing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Both sTER modes describe side-by-side subimages: mode 0 is cross-fuse (right eye first), and mode 1 is diverging-fuse (left eye first). Correct the enum and document comments without changing their serialized byte values. Add a labelled-pixel check against the literal mode bytes in PNG Extensions 1.4.0 section 3.6.
Co-Authored-By: OpenAI Codex <[email protected]>
When the source window is skipped (cloning near to far toward the
horizon), four `read_pixel` calls looked the tile up four times and made
that regime ~20% slower than `main`. One `read_region_into` into the
reused corner buffer looks it up once.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A print at a manual scale doesn't use the area, so it no longer
validates it (a stale one from a replayed action can't fail the print)
or asks the Windows driver for it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The outer and inner glow commands documented range as 0..100, while the
slider starts at 1 % and the command holds values below it at 1 %, as
Photoshop's Range does. Document 1..100=50 (like Bevel's contourRange) and
test the boundaries.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Also test a rejected printable area through `file.print`: the command
fails and leaves the document and the remembered print settings as they
were.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Alt-drag, Cmd+Opt-drag and Free Transform on a copy duplicate with
`inPlace: true` for every layer, but the flag only changes anything for
an artboard. With in-place duplicates now refused on strict .atn export,
a recorded copy of a plain layer could no longer be exported, although a
plain Duplicate replays it exactly. Recording now drops `inPlace` when no
copy is an artboard, as `layer.select` already rewrites its recorded
params.
Refs #7200
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Free Transform's preview already goes under the guides (#5086), but the
on-canvas distort modes still painted their warped pixels together with
their pins and planes in distort_ui::draw_overlay, after the grid and
guides, so every guide vanished across the layer being warped. Split
each mode's drawing: draw_content paints the warped pixels (Puppet Warp,
Perspective Warp) or the paste (Vanishing Point) right after the
document, next to transform_tool::draw_content and under the grid and
guides; draw_overlay keeps the mesh lines, pins, planes and nodes over
them, as in Photoshop.
Test: guides_draw_over_the_puppet_perspective_and_vanishing_point_previews
checks the painted order preview < guide (< mesh/plane) for all three
modes; it failed for each of them before the fix.
Review (CodeRabbit): skipping a case-insensitive duplicate before grouping dropped its group's first appearance, so a group could step out of the panel's order. Groups and folders are now ordered from all presets, and unselectable presets are removed from the result afterwards. Test added.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`layout()` fitted the image to the paper minus a fixed 18 pt margin, so a
borderless page (the driver reports the whole sheet as printable) still
printed with a white border, and a driver margin wider than 18 pt could
clip the image. A new optional `printable` parameter (the rectangle
`file.print.page` reports) is now the area Scale to Fit Media fills and
centres in, with the 36 pt mark inset kept inside it and Emulsion Down
fitting its mirror. On the native Windows route the engine reads it from
the driver, so Print One Copy, the CLI and replayed actions match the
Print dialog, which passes it for its preview. Without one (CUPS, PDF),
the layout is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop's Duplicate Layer has no in-place flag, so `layer.duplicate
{"inPlace": true}` was exported as a plain Duplicate and replayed with
the artboard copy beside the original. Strict export now fails on it and
skipUnsupported reports it, as for a Close with a document target.
Fixes#7200
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Reuse unchanged tile and blob hashes and bound retained compressed payloads. Carry the writer through native save settings while preserving save identity and exact output.
The scalar form's channels went through `jnum`, which returns `None` for
anything that is not a number, so `{"colors": "reds", "cyan": "35"}` exported as
an untouched range while the command errors on it
(`adjust_cmds::selective_from_params` → `percent`). The export would have
written an adjustment that is not the one recorded.
A channel that is present and not null must now be a finite number, or the step
cannot be written and takes the refusal path. `null` counts as absent, as the
command's `given` reads it.
With the Lasso and Alt held (Photoshop's straight-segment mode), a click closer
to the last vertex than the radius for grabbing the first one ended the
selection instead of placing the vertex:
three Alt clicks at (50,50) (90,50) (90,90) -> 3 vertices
a fourth at (85,91), 5 points away -> the selection was committed
The radius is `8.0 / point_zoom`, so every segment shorter than 8 points on
screen was impossible to draw -- the click meant to continue the outline
finished it, and tracing anything small ended the selection after three
vertices (#7629).
Both closing gestures stay; they stop sharing one radius. Clicking the first
vertex is aiming at a target and keeps the 8-point grab radius. Clicking the
last vertex again is the same click repeated, so it only has to allow for a
hand's jitter: 3 points. Enter still closes, Escape cancels, Backspace and
right-click retract, releasing Alt with the button up closes, and the Freeform
Pen still ends an open path by clicking its last point.
I could not reproduce this on the dedicated Polygonal Lasso: `polygon_click`
closes only near the first vertex, and egui counts a double click by the time
between clicks *and* their distance (`max_click_dist`, 6 points), so two quick
clicks further apart are not one. A probe placing a vertex 4 units from the
last, clicked in the next frame, keeps it.
`sample` read a 2×2 region (plus a default-pixel Vec) and collected a new
Vec for every covered pixel of each dab. It now writes into caller-owned
scratch and reads its corners from the dab's source footprint, read once per
dab; corners outside it come from `read_pixel`. Output is unchanged; corner
coordinates saturate instead of overflowing at the edge of i32.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Committing Edit > Transform > Warp ran on the UI thread: on an 8000x6000
layer the window froze for 6.9 s (Arc) or 9.3 s (Flag), with no progress or
cancel. edit.transform.warp now runs its edit through edit_job, as Free
Transform does since #4218: the mesh warp reports progress per tile and
stops when cancelled, and the transform session keeps its preview until the
result lands. Undo during the commit names Warp.
Most of a Flag warp's time was after the mesh warp: re-converting a layer
that already had alpha (2.0 s) and compositing the warp over a cleared
layer that held nothing else (2.3 s). The layer is now cloned, and the warp
is the result when the box held all of it. Flag drops to 1.4 s and Arc to
4.2 s inline.
`image.adjustments.selectiveColor` takes a range two ways, and the command reads
both: the array form `"reds": [c, m, y, k]`, and the scalar form that names one
range and gives the channels it changes, `"colors": "reds", "cyan": 35`.
`selective_to` looked only for the arrays. A step recorded the other way
exported with an empty `ClrC` list -- an adjustment that changes nothing -- with
`skipped` empty and no warning on import, so nothing said the values had gone.
On a single red pixel the step produces [0.6509804, 0, 0, 1] run directly and
leaves [1, 0, 0, 1] after a round trip through .atn.
The rows are now built the way `selective_from_params` reads them: the arrays
first, then the scalar form over the range it names, so a step that gives both
exports what playing it would produce. An unknown range in `colors` still
cannot be written and refuses the step.
Fixes#7672.
canvas.rs: the ants moved to crate::ants here; main's clippy::nonminimal_bool rewrite of
the finite/clip guard (f8cd3e79) is applied there instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The paths are compared before the save reopens the output, so a link swapped in meanwhile
is not caught (CodeRabbit nitpick on #7455).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The estimate encoded a ≤512 px proxy and scaled its bytes by the pixel ratio,
which reads low for detailed images (0.36–0.81× here). Photoshop's Export As
shows the encoded size. Up to 40 MP the full export is now encoded off the UI
thread with the exporter's own settings; the proxy figure shows until then.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Harness taps share Touch+pointer events on one frame so second_click sees touch; mouse path still rejects a 20 px gap.
Co-authored-by: Cursor <[email protected]>
Loading a layer's transparency as the selection and dragging it with the Move
tool left a ghost of a·(1−a). Photoshop lifts the selection's share of a
pixel's coverage, up to all of it, and leaves the rest, so its own
transparency lifts it whole. Opaque pixels split as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The `1/n s` test in `exposure_text` was relative to the reciprocal, so the
tolerance shrank as the exposure slowed. Under 1/50 s the reciprocal drops
below 50, which makes 1 % of it smaller than half a rounding step, and nearly
every fraction fell through to the decimal form: a stored 0.031714 s printed
as "0.03 s" (5.4 % off) and 0.0215 s as "0.02 s" (7 % off).
Below 1/10 s the reciprocal is above 10, so its nearest whole number puts the
shown time within 5 % of the stored one and is the form a photographer
expects. At or above 1/10 s the existing near-whole rule is kept, so 0.5 s is
still "1/2 s" and 0.4 s still "0.4 s". The decimal fallback moves from two
decimals to three significant digits ("0.105 s", not "0.11 s").
The two fixtures from the report now read "1/32 s" and "1/47 s" through
`photocraft-cli run ... --cmd file.fileInfo`.
While typing, a press outside the text committed it. Photoshop moves the
text instead when the press is just outside it (measured: 35 pt off the
glyphs moves it, 48 pt commits), and typing carries on. A plain press
within 40 pt of the text frame now starts the existing type transform as
a move, and the cursor shows Move there; further out a press commits as
before, and a paragraph box's handles still resize it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A draft whose subpath changed meanwhile is added to the path it came
from even with nothing selected in the Paths panel, instead of replacing
the Work Path. Undoing points in a resumed draft never removes the
path's own anchors: past the added ones it drops the draft.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A resumed subpath takes only the drawn anchors and closing, so its path
operation survives. The draft remembers its document and the subpath as
it was; if either changed before the commit, the drawing is added as a
new subpath instead of overwriting another one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Pen click on an endpoint of a finished open path started a new
subpath there; Photoshop carries on drawing that path. A press on the
first or last anchor of an open subpath of the selected (or work) path
now resumes it, reversed from its first anchor, and the commit replaces
that subpath in one step. Shape mode and a targeted vector mask, which
take new paths, are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A right-click with the Quick Selection tool opened the Brush Preset
picker; Photoshop opens the selection menu, as for the other selection
tools. The canvas menu now covers Quick Selection, and the right-click
opens the picker only for tools without one. The options-bar brush chip
still opens the picker.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The gloss contour was applied to the highlight and shadow amounts after
the lighting split, so a contour that starts high (Rolling Slope -
Descending) turned every unlit pixel into a full highlight: a bevelled
glyph's flat face painted (255, 40, 42) over its (151, 22, 23) fill. It
now maps the shading before the split, with the flat face's mapped
shading as the neutral, on the CPU and the GPU. Linear is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Inner Glow lacked Photoshop's Technique and Range, Outer Glow its
Technique, though the engine takes both. The pages now list them in
Photoshop 2025's order (Technique before the spread or choke, Range
after the contour), the dialog loads a glow's technique so OK keeps it,
and both glows' defaults carry Photoshop's factory technique and range.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#3755's hand-off listens on a loopback port, and every `flatpak run` gets
its own network namespace, so inside the sandbox the second launch could
never reach the first and opened a separate window. When FLATPAK_ID and
XDG_RUNTIME_DIR are set the primary now listens on
$XDG_RUNTIME_DIR/app/<app id>/instance.sock (0600), a directory every
sandbox of the app shares, and records "socket" instead of "port" in
instance.json. The lock, token, client cap, deadlines and reply are
unchanged; a crashed primary's socket file is unlinked under the lock,
and shutdown removes the socket.
image.autoTone accepts a target param but edits the active layer, so an
explicit channel target recorded the unchanged channel for Fade. Only
commands channel_cmds::routed sends to channels now record one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An explicit target (edit.fill with "target": "pixels" while an alpha
channel is targeted) edits the layer, but Fade recorded the channel from
the Channels panel. after_command now resolves the target as the command
did, with inject_target: the explicit target, else the panel's, and only
for the commands it routes to channels.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
FadeSource only knew a layer and whether its mask was edited. With an
alpha channel or the Quick Mask targeted, filters, adjustments and paint
change that channel, but Fade blended the untouched layer back and left
the channel as edited. It now records what the command changed, as a
FadeTarget (a layer's pixels or mask, an alpha channel, or the Quick
Mask), and fades that surface.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review: presets with no group share the panel's "My Brushes" group (UNGROUPED now lives in the engine, and brush_panel uses it); a preset whose name only differs in case from an earlier one can't be selected by name, so stepping skips it instead of sticking; the empty-library test checks the brush and current preset are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop records a switch to another open document as a `select` of
`Dcmn` by offset (-1 previous, 1 next). Import it as
`document.activate {"offset": n}`, the tab that many places from the
active one, and save it back as the same reference. Past the first or
last tab is refused rather than wrapped (#7103).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Window › Adjustments already selects Properties group tab 1, but Studio
themes float Properties as a card that only drew properties_body, so the
menu item (and the rail) opened the document's Properties and the
Adjustments grid could not be reached.
The card's title is now the group's pill tab strip, Properties |
Adjustments, as on the docked group: it follows and sets
dock_tabs.properties, honours hidden tabs, and its context menu closes a
tab or the card. The tab close logic moves out of the dock into
dock::close_tab so the card shares it.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Photoshop lists Previous Brush, Next Brush, First Brush and Last Brush in Edit › Keyboard Shortcuts
› Tools (`,` `.` `⇧,` `⇧.`); PhotoCraft had no way to step through brush presets from the keyboard.
`tools.previousBrush`, `tools.nextBrush`, `tools.firstBrush` and `tools.lastBrush` join the brush
key commands in `brush_key_cmds.rs`, so they can be rebound, recorded and driven by agents like
`[` / `]`. Each picks a preset with `tools.setBrush {preset}` (journaled, as a click in the Brushes
panel), walking the presets in the panel's order (groups by first appearance, then each group's
folder tree depth first, as `brushes_tab` draws them) and wrapping around; with no preset picked
yet, Next starts at the first and Previous at the last. No presets is an error, not a panic.
Tests: panel order with groups and nested folders; stepping, wrapping, first/last, the default
shortcuts, and the empty-library error.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Port the clipped-adjustment coverage path from #4711 and verify depth, alpha, masks, and document-coordinate GPU parity.
Co-authored-by: Vikram Bhaskaran <[email protected]>
Photoshop draws the Lasso's, Polygonal Lasso's and Magnetic Lasso's outline as
a thin solid line while it is being drawn, and marching ants only once the
selection is made. Ours used the animated ants from the first point.
- `tool_feedback::solid_path`: a 1 pt black line over a soft light edge,
Photoshop's look on light pixels and still visible on dark ones.
- The freehand Lasso's drag, the Polygonal Lasso's sides and rubber band, and
the Magnetic Lasso's traced border use it. Patch and Content-Aware Move
drags (the same branch as the Lasso) keep their ants, and so does every
committed selection.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Checked against Photoshop 2025 on a 15 px feathered shape moved from a loose
marquee:
- Its selection after the move follows the edge's transparency: the marching
ants run along the 50 % line and Quick Mask shows the edge partly selected
(measured strength 0.10 / 0.29 / 0.42 / 0.64 / 0.81 where alpha is 0.05 /
0.24 / 0.39 / 0.57 / 0.71). We had made those pixels fully selected.
- Moving it again, even after Deselect and Reselect, leaves nothing behind,
though Delete with the same selection leaves a ghost: the Move tool lifts at
most each pixel's alpha, not alpha × selection.
So the move cut lifts min(alpha, selection) of each pixel (feathered moves over
opaque pixels are unchanged: k lifted, 1 − k left), and the moved pixels'
selection is what they took. Moving them again lifts them whole, with no faint
outline, and their ants hug the soft edge's 50 % line.
Also: arrow nudges add up to one floating piece, so up then down was back at
no offset and the drop kept the loose marquee. A piece that moved and came back
now keeps the moved pixels' selection, in one step; a click still changes
nothing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
In a window too short for the tools, the toolbar's tool column scrolls, and
egui widens the UI by the scroll bar. The colour chips (and Default and Switch
Colors with them) were centred in that wider UI, so they sat a bar's width
right of the tools: about 3 px in the Studio and Pro themes, more with
Classic's wider bar. They are centred in the column as it is before the tools
scroll now, as in a window tall enough not to scroll.
Tested in every theme, one and two columns, at 1x and 2x: the chips paint at
the same place in a 420 pt and a 1400 pt window.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The README's clean-room section names every maker-note tag the crate reads and
where it is documented; 0x001E is added, and the code comment now names
ExifTool's Nikon tag documentation as its source.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
embedded_preview_color_space only read the EXIF ColorSpace, but Nikon bodies
write none: they say Adobe RGB only in the maker note (tag 0x001E: 1 sRGB,
2 Adobe RGB). A Nikon NEF with Adobe RGB selected had its JPEG preview read as
sRGB by the colour fit.
When the EXIF does not state a colour space, the Nikon maker note's is used
(the EXIF still wins, a DNG still states nothing, other makers' maker notes
are not read). The maker-note locator in tiffep.rs is shared. A packed Nikon
NEF with the tag joins the hostile-input seeds, and rawinfo prints the stated
colour space.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
A save whose added or lowercased extension names another entry in the
folder now asks before replacing it, even when that entry is a hard
link to the file the save dialog asked about. Two spellings of one
name on a file system that ignores letter case still count as the same
entry and do not ask.
The remote commit 759b68c2 and our e4bc5c25 are the same work at different
dates; the merged result is our fuller catalog (4,227 keys incl. the
shortcut-conflict dialog and the current GPU-memory notice wording). The one
remote-only row carried the superseded English wording no longer in the code,
so it is not kept. The docs keep our side (4,227 keys / 1,229 labels,
2026-10-18).
Review follow-ups (CodeRabbit on #7574):
- A zero offset returned before `selection` was checked, so
{"dx": 0, "dy": 0, "selection": "yes"} succeeded. It is validated first now,
and the test checks a rejected call changes nothing.
- A test pins down a Move drag of several layers while a piece floats: the
press drops the piece first, then the ants follow the drag from there.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With several layers selected their selected pixels can't float, so the Move
tool moves the layers whole; the marquee stayed behind. Photoshop moves it with
them: the ants follow the drag, and the layers and selection land in one Move
step.
- Engine: `layer.translate` takes `"selection": true` to move the selection by
the same offset in the same step.
- UI: the Move tool passes it when several layers are selected and there is a
selection, and the ants follow the drag live.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An imported Close with Save: Yes plays as `file.save` then `file.close` with no document.
A Save Document script event that closed the saved document left another document active,
and the Close then closed that one, unsaved edits and all. Playback now fails the Save step
when the document it saved is no longer open, so no later step acts on a fallback document.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Since pasteboard_quads returns [Pos2; 4], only mod tests uses egui::Rect,
so the non-test build failed clippy -D warnings with an unused import.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Feather, Contract and Smooth (and Expand) carry only their radius, so
applyAtCanvasBounds was dropped (#7540, #7541, #7542); the event table
gains an uncarried: list of such flags. A pattern Paint Bucket's Pttn
names no pattern and holds no placement, so an explicit pattern, scale,
angle or phase replayed as the current pattern at its defaults (#7543,
#7544, #7545, #7546). Those steps are now refused instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A numeric target exports as the active layer (#7248, handled by #7262),
so it didn't show the carried form.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The importer read a transform's interpolation (Intr) only for a layer
target, so a Transform Selection replayed with the default resampler
(#7515). The export scaled and rotated about the centre without skew or
warp, so a step with another reference point, a skew, a warp or the
corners alias of quad exported as a plain scale; it is now refused
(#7516, #7517, #7518, #7519).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Strict .atn export wrote these steps without one of their options, so
the imported step replayed differently. Neither side maps the options,
so the step is now refused (strict export fails, a partial export skips
it) instead of silently changing:
- New Layer: clipToPrevious, a colour label, fillNeutral, position below
(#7502, #7512, #7513, #7514)
- New Document: a black or hex background, or backgroundColor (#7503)
- Load Selection: invert, or an add/subtract/intersect operation (#7506)
- Solid Color: a colour the opaque RGB descriptor can't hold (#7507)
- Layer Properties: keys other than layer, name, opacity, fill, blend,
visible and color, such as locks, clipping or a layers list (#7196,
#7246)
- Group Layers: an explicit layer target (#7236)
- any mapped 32-bit integer field outside i32, which was clamped (an Add
Noise seed above i32::MAX replayed as i32::MAX, #7287)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review follow-ups (CodeRabbit on #7574):
- A marquee or lasso press while a piece floats tested the selection it was
cut with, so a press in the margin the cut left (where no ants are) dragged
the piece. It tests the piece's outline now; there it starts a new selection.
- With several layers selected and the active one's pixels floating (a
marquee Cmd-drag), Show Transform Controls shrank to that piece and dropped
the other layers. The outline frames the piece only when it is framed alone.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Extends a_damaged_preset_set_keeps_the_other_sets (#5602) to save an Export As preset, check it survives each damaged sibling set, and damage export_presets itself. Fails with export_presets: None in load_presets_json (the build break #6906 fixed), passes on main. Test suggested by dotCooCoo in #6906.
The shrunk selection took the moved pixels' alpha as its coverage, so
anti-aliased edges were only partly selected. Once the move was dropped
(changing layers, say), moving them again lifted a × a of each edge pixel and
left a × (1 − a) behind: up to 25 %, a faint outline where the shape had been.
The selection now keeps its own strength wherever the layer has pixels, so a
later move lifts edge pixels whole. Tested at 8, 16 and 32 bit: nothing is left
behind and the shape's total alpha is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
After a drag of selected pixels, Show Transform Controls still framed the
selection they were cut with, shifted by the drag, while the ants hugged the
pixels. Photoshop frames the pixels.
- The box frames a floating piece's outline (`transform_controls_rect`).
- Snapping starts before the press cuts the pixels, so the cut now retargets
the move's box to their outline (`snap_ui::follow_cut`), for the Move tool
and a selection tool's Cmd-drag. This replaces an estimate from the layer's
bounds, which failed when the layer held other shapes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A marquee drawn loosely around an object on a transparent layer kept its
rectangle when the Move tool dragged the pixels. Photoshop shrinks the
marching ants to the pixels being moved as the drag starts: the selection
times the layer's transparency.
- Engine: the floating piece keeps its outline (`CutParts::outline`, the
piece's alpha), and `select.drop` makes it the new selection. A mask,
which has no transparency, and an empty piece keep the selection's shape.
- Canvas: a floating piece's ants follow that outline during the drag.
- Snapping and the move box use the moved pixels' bounds.
Tests at 8, 16 and 32 bit, Alt-drag copies, soft selections over
half-transparent pixels, the Background, a mask target and an empty piece.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#7415 writes and reads every TIFF alpha and spot channel with Photoshop's
channel resources, so this branch's single-alpha-channel handling would
only make the two rework the same decode and encode paths. The codec,
io, test and doc changes for it go back to main's versions; the
tiffCompression save parameter, the CLI --tiff-compression flag and their
test stay, and the format-parity TIFF row names the option.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit on #7242: the LaunchArgs destructuring asked for a mutable
launch_options, but the only mutation happens inside the
cfg(not(target_os = "macos")) block, so a macOS build warns about an unused
mut. The binding is made mutable inside that block instead, where the fold
happens. No behaviour change on any platform; upstream CI has no macOS leg,
so this is warning hygiene, not a CI fix.
#6499 moved the argument parsing into parse_launch_args, and main() now destructures the result without mut, so the line that folds the PHOTOCRAFT_* overrides into launch_options fails with E0596. One mut on that field; nothing else in the change moves.
The open job captures the demosaic preference when it starts; the dialog is queued when the job lands and read the preference again, so a `rawDefaults.demosaic` change in between gave `RawOpen::developed_with` the newer method, and Open with the dialog unchanged skipped the re-develop although the document had been developed otherwise (CodeRabbit on #7417).
The default open's note already says which method it used ("developed with default settings (ahd demosaic, …)"); `photocraft_io::raw::developed_demosaic(warnings)` reads it back and `queue_open_dialog` takes the method from there, the preference only as a fallback.
Tests: `the_developed_note_says_which_demosaic_method_was_used` (io), and `the_demosaic_method_is_chosen_at_open` now changes the preference between the open and the queued dialog.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
After an error on a device that still works (out of memory, validation), Retry GPU builds a new GPU canvas on the same render state with a fresh health flag, and the views go back to the GPU. After a loss or a failed GPU start it still schedules the retry for the next launch. The dialog says which. Two new strings, translated in all 20 catalogs.
When the window's own wgpu device was lost (a driver reset, e.g. after sleep), egui-wgpu could
not draw another frame: a frame with shapes panicked inside it ("Failed to create staging buffer
for index data", #1215, exit 101, unsaved work lost), and a frozen window's close button opened
an unsaved-changes prompt nobody could see (#3438). The #243 fallback only covered the canvas.
- DeviceHealth::device_lost (wgpu reported the loss, not a recorded fault) and ::probe (a
non-blocking poll that keeps running after a recorded fault).
- RecoveryStore::flush: finish queued autosaves and wait for them.
- gpu_status::check_window, at the end of every frame: once the window's device is lost the
frame draws nothing; the first time every unsaved document is written to crash recovery
(Services::flush_autosaves) and the app closes without asking. The next launch reopens them.
- The app watches the window device on the CPU canvas too; ui.gpu.simulateLoss {destroy: true}
destroys the real device to drive the path.
Verified on an RX 6900 XT, Vulkan and DX12: destroying the device in the running app exited 101
with the egui-wgpu panic before, and now exits 0 with the document recovered on the next launch.
Kept held an Arc<Document>, so up to KEEP old states (and every tile only they used) stayed
allocated outside Memory Usage after History dropped them. It now holds a Weak: a state still in
History (undo/redo, the eye toggled back) is found as before, by pointer or by equality with the
upgraded state, and an entry whose state is gone is dead and dropped at the next trim.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- DocumentSettings gains an optional `profile`, so document.presets.save and Recent keep the
Color Profile and command_params gives it back to file.new. Older presets load as the
working space.
- Applying a saved preset drops the profile picked before, and a profile that doesn't suit the
mode is removed from the dialog state, so Create never sends it.
- The rejected-profile test also checks the active document and its history are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Export picked refineSelectionEdge, whose fields have no Sample All Layers, so the value was
dropped and re-import fell back to false. A step that sets it now exports as
smartBrushWorkspace, which keeps it; other steps still export as refineSelectionEdge.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The ants were snapped to the middle of an egui point and drawn one point
wide without antialiasing. At a fractional display scale (125 %, 150 %) a
point is not a whole number of pixels, so one edge of a selection came out
2 px thick and the next 1 px, and dashes were 5 px here and 4 px there.
New ants.rs draws every ants outline (committed selection, marquee/lasso/
zoom/move-box drag previews, Free Transform, Content-Aware Move) in device
pixels: every edge ceil(ppp) px thick, dashes and gaps 4x the thickness,
a screen-fixed pattern whose rows shift against each other so dash ends
slant as in Photoshop, and an unchanged march speed. Straight edges are
pixel-aligned mesh quads cut to the view; slanted edges stay antialiased
line shapes at the same thickness.
Tests: ants::tests (unit), tests/marching_ants_pixels.rs (GPU render at
1/1.25/1.5/2x; on the old code it fails with top 1 px, right 2 px).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
cicp_profile left every integer BT.709 image untagged (read as sRGB), including transfer 8
(linear), which cicp_rgb already maps to linear sRGB. Only the SDR transfers stay untagged now.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Add 127 labels that the interface looks up through variables and that
cs.tsv was missing (live keys only, checked against the code), update one
hint to quote the translated option, and fix the capitalisation of five
existing entries.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The size field's drag area is laid over the icon after it, so egui hovered the
area and never the icon. scrub_option_icon hands the tooltip to that area.
Conflicts and how they were resolved:
- crates/ui-egui/src/canvas.rs (ensure_filter_preview): kept our edit_command(), which
also covers the Blending Options dialog and checks the target guard; main's
smart_ui::confirm_command(fields, cmd, params) form (#6324) is the same preview
(setParams on the proxy) without the guard.
- crates/ui-egui/src/filter_dialog.rs (preview_document_for_target): kept our scaling
of the smart filters' lengths and the mask feather on the proxy (proxy_document
already scales cache, placement and mask, so nothing is scaled twice); dropped
main's run-at-full-size branch for setParams and its now unused
SMART_FILTER_SET_PARAMS constant.
- crates/ui-egui/src/smart_ui.rs (open_editor): kept our open_editor (target guard,
adjust_dialog then filter_dialog with reset_fields, then __smartFilter and the
stored params) over main's edit_filter helper; adjustment edits keep their live
preview (adjust_preview skips the layer path for __smartFilter), so main's
__preview removal for __adjust is not needed.
Adapted to main's API: smart_ui::preview_command (main, #6324) now also returns the
Blending Options command for our blending dialog, so the preview window matches its
key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
layer.layerStyle.transferEffects names the layer that receives the effects in
"to"; it has no "layer" param, so the disclosure target fell back to the active
layer and the wrong layer's effects were expanded or collapsed. Resolve "to"
for that command before recording new_fx_target. Every other Layer Style
command keeps resolving "layer" as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Conflict in crates/engine/src/commands.rs (layer.newFillLayer.solidColor):
main now reads the colour with the checked color_param(cmd, p, key, default)?,
and this branch reads the Use Default Masks on Fill Layers option next to it.
Kept both.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On Windows `same_file` compared canonical path strings, so a hard link to
the input (another name for the same file) passed the new convert guard
without --in-place. It now compares the volume serial number and file index
(the `same-file` crate, already in the dependency tree; std's file_index is
unstable), as unix compares dev and inode, falling back to the
case-insensitive canonical path when a file won't open. The engine's
same_file test now checks hard links on every platform. (Review on #7454.)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`photocraft-cli convert in.jpg in.jpg` re-encoded the original in place and
exited 0, so one wrong output template destroyed the source (a lossy JPEG
re-encode can't be undone). `batch` already refuses an --out folder that is
its --in folder (#492); convert now applies the same rule: an <out> that is
the <in> file, however it is spelt (./, absolute, hard or symbolic link), is
refused before anything is read, and --in-place opts in.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit measured the docstring coverage of the PR at 78.75%, under its 80%
threshold. Add short doc comments to the functions #3975 adds or changes and
to the ones next to them, saying what each does and which behaviour each test
pins. The Layers panel functions of #3289 are left to that PR. Comments only;
no code changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit counts the functions around the lines a PR changes, not only the
changed ones, so the PR's docstring coverage stays under its 80% threshold
unless the untouched neighbours are documented too. Comments only; no code
changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit counts the functions around the lines a PR changes, not only the
changed ones, so the PR's docstring coverage stays low (59.49%, threshold 80%)
unless the untouched neighbours are documented too. Comments only; no code
changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit measured the docstring coverage of the PR at 59.49%, under its 80%
threshold. Add short doc comments saying what each helper does and which
behaviour each test pins. Comments only; no code changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
open_editor opened the filter's dialog and copied the recorded parameters over
its fields. filter_dialog::open starts from the user's last-used values, so a
recorded filter that lacked a key (an older file, or a command that gained a
parameter) showed, and on OK saved, the remembered value instead of the
default. Reset the freshly opened filter dialog to the schema defaults, as
Alt+Cancel does, before the recorded parameters are copied in. Adjustment
editors are unchanged, and a plain Filter menu dialog still remembers.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
CodeRabbit measured the docstring coverage of the PR at 79.41%, under its 80%
threshold. Add short doc comments saying what each helper does and which
Photoshop behaviour each test pins. Comments only; no code changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The disclosure target only read a numeric "layer" param, so a layer named by
its name (which every command accepts through explicit_layer) fell back to the
active layer and the wrong layer's effects were expanded or collapsed. Resolve
the target with commands::layer_param, which understands every explicit form
and still falls back to the active layer. layer.layerStyle.clear needs no case:
it leaves the layer without effects, which the existing post-run check already
excludes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
starts_mask_drag reads the mask thumbnail rects recorded in egui memory on an
earlier frame. With the thumbnail size set to None no mask thumbnail is drawn
and nothing is recorded, so rects from when thumbnails were on stayed behind and
a drag of the row that started there was classified as a mask drag. Forget the
layer's recorded rects before the drag is classified when thumbnails are off.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The options persist on their own in the preferences (persist_options and
restore_options, as Photoshop's Panel Options do), and engine commands read
them from there. Restoring a workspace layout that carried the key put the UI
out of step with the preferences, so the panel and the commands disagreed.
Drop layersPanelOptions from the layout snapshot and from apply; layouts saved
by earlier builds keep the key, which is now ignored.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
When a raw opens, Shadows, Highlights, Clarity, Texture and Dehaze act on the sensor data (`retone`, in linear light before the camera's tone curve) and Open develops the file that way, but the dialog's full-resolution preview ran the filter's RGB versions of those sliders: at 100 % the preview showed one thing and Open made another.
`DetailPreview` gets the raw's tone curve (`raw_curve`, set when the open-time dialog opens); with it, `develop` retones the whole area from the whole frame's maps exactly as Open does, then runs the rest of the pipeline with those five sliders at zero. Without a curve (the filter on a layer) nothing changes. `retone` runs its rows in parallel (`crate::par`) and recomputes the linear colours per band instead of keeping a second copy of the image, since the detail preview develops 16–45 MP frames.
Test: `a_raw_open_refines_raw_stage_sliders_from_the_whole_frame`.
Split out of #3816 at the reviewer's request.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
A sparse layer (a dot at each far corner) has huge bounds that also raise
the content estimate, so the whole union was chosen and its buffers could
exhaust memory. Past the canvas, work on at most MAX_BLEND_PIXELS; beyond
that keep to the canvas as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A camera raw always opened with AHD demosaicing; `photocraft-raw` has three methods but neither the open-time Camera Raw dialog nor Preferences offered them.
- Preferences › Camera Raw › Demosaic (`rawDefaults.demosaic`: ahd, mhc, bilinear; default ahd): raws open with it.
- The open-time dialog's Detail panel gets a Demosaic row that starts from the preference; Open re-develops when the choice differs from the method the file was opened with (`RawOpen::developed_with` / `demosaic`, `RawTuning::demosaic`), as it does for the raw-stage sliders. The preview keeps the method the file was opened with. Control channel: `ui.demosaic`, `openingRaw.demosaic`.
- Imports take `photocraft_io::ImportOptions` (SVG group depth and demosaic) instead of the depth alone: `import_with_options`, `import_with_options_and_interrupt`, `jobs::import_options(prefs)`, `ImportFn` takes `&ImportOptions`; desktop, web, background opens and Place pass the user's settings.
- The eight new strings in all 20 catalogs; the scorecard counts the new preference.
Test: `camera_raw_ui::tests::the_demosaic_method_is_chosen_at_open` (another method re-develops with different pixels; the preference develops the open itself and Open then changes nothing; unknown methods and the choice outside a raw open are refused); `prefs_ui` shows the setting.
Split out of #3816 at the reviewer's request.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
On equal cost the seam search took the lowest column/row, so an image wholly
inside the composite on its left (or top) got a seam on its own outer edge
and no pixels. Break ties away from such an image's side; test the left and
top cases too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
pasteboard_rects returned each piece's screen bounding box, which in a rotated
view covers the loose layers (and boards) it was cut around. pasteboard_quads
returns each piece's four screen corners and the canvas paints them as meshes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review on #6410: any visible layer covering the whole canvas (the default
white Background, or a fill layer whose bounds are unbounded) removed the
pasteboard entirely. Those are backdrop, not loose work: pasteboard_rects
now skips the document's Background and any layer whose composite bounds
are unbounded or contain the canvas. The test helper is back to the
default white document, and full_canvas_layers_keep_the_pasteboard covers
it. Merging main also drops the rustfmt-only change to
smart_filter_authorization.rs from the PR diff.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Per review, the canvas-wide area, Seamless gain compensation and Background
conversion move to their own PRs. Adds an engine test for the crop case.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A hovered field that owned a gesture kept it while the pointer wheeled
vertically off it: the view scrolled, and coming back over the field
in the same gesture stepped it again. Any wheel with the pointer off
the field now hands the rest of the gesture to the view, as a panel
gesture already keeps scrolling the panel over a field.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Render filters on a smart object asked for the placed stage each time. When the stage cache had
evicted it, every one of them placed (and decoded) the source again: four Lens Flares, four
decodes. The bounds are now found once and reused (CodeRabbit review on #6160).
The hand-off from a5aa05f5 sat behind the hover check, so a sideways
swipe with the pointer off the field kept the gesture the field's;
coming back over it in the same gesture stepped it. The hand-off now
runs before the hover check.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A trackpad gesture that starts with a zero-delta event over a hovered
field let the field claim it; a plain sideways Move that followed was
not the field's, yet the field kept the gesture and zeroed the smoothed
scroll, so the view could not pan. Wheel the field doesn't take now
hands the rest of the gesture to the view.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
macOS opens a trackpad gesture with a zero-delta wheel event. Since plain sideways scrolls
were left to the view, that event was too, so a field under the pointer never claimed the
gesture and the swipe didn't step it. An event with no delta at all is the field's again.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
While a field had focus, every frame without a wheel event zeroed egui's smoothed scroll,
cutting the tail off the ⌘/Ctrl + wheel the field leaves to the view (a 40 pt pan moved
36). Only the tail of a gesture the field took is dropped now. A plain sideways scroll (a
trackpad swipe) is the view's too; Shift + a sideways wheel still steps by ten.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With the Hand selected, ⌘ or ⌥ pressed during a middle-button drag turned it into the
temporary Zoom tool, which outranks the middle button's Hand, so the pan stopped. A held
middle button now keeps the Hand's modifiers from zooming.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Press plain F5 in the F5-reassignment scenario so capture does not inherit Shift from unrelated key events; satisfy clippy on capture_key_press.
Co-authored-by: Cursor <[email protected]>
Users can switch the Color panel from HSB to RGB sliders under the field, with control-channel support and tests.
Co-authored-by: Cursor <[email protected]>
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks (the
"Contract" and "Duplicate Layer" rows main now has are dropped from the
branch).
Holding Shift while drawing a freeform path segment keeps points on a
straight 0°/45°/90° line, reusing the painting-tool stroke constraint.
Co-authored-by: Cursor <[email protected]>
Track modifier keys across capture frames and walk events so ⇧/⌃/⌥ count even when egui reports no modifier bits on them.
Co-authored-by: Cursor <[email protected]>
Skipping with_icon on .app bundles let eframe substitute its default glyph once
the window opened; pass IconData::default() so the bundle .icns stays in the Dock.
Co-authored-by: Cursor <[email protected]>
transformed_text_lands_where_resvg_draws_it returned early, and passed
without checking anything, when the import found no font: it relied on
system fonts, and photocraft_text::CRAFT_FONTS is empty unless the build
sets CRAFT_FONTS_DIR. With the system fonts hidden it passed in 0.00 s.
It is now a unit test in src/svg.rs that parses through parse_with with
a font database holding only the bundled Inter Regular (always compiled
in), and renders its resvg reference from the same parse, so both sides
use the same face. An empty import is now a failure. It fails with the
#5334 fix reverted, and passes with the system fonts hidden.
Raised in review of #5334 by tanish2k09.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With a single type offered the portal reports no filter, and the dialog opens on the stem (services::dialog_file_name), so accepting the name as shown returned a bare path: Swatches became a file named "Swatches" instead of "Swatches.aco" for the Swatches and preset-export flows. The sole filter's extensions are now used when the answer names no type this dialog offered; several types with no pick still add nothing, and an extension the user typed stays their own (with_chosen_type keeps one it recognises).
Tests: the mock portal answers the bare stem for a single filter and gets back Swatches.aco; chosen_type is also checked directly, so the rule is covered where no dbus-daemon exists to run the portal test.
open seeds width and height from the document, and open_layer replaced only the internal __w/__h, so the dialog showed and edited the document's size while the export used the layer's trimmed pixels: a 10x6 layer in a 40x30 document opened at 40x30, and editing one side started from the wrong value. In scale mode the fields now follow the layer's trimmed size; a remembered exact pixel size stays, because that is a size the user asked for.
Test: a 10x6 layer in a 40x30 document opens at 10x6, a remembered 50 percent at 5x3, and remembered pixels are untouched.
PHOTOCRAFT_PRESENT_MODE, PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS and the two automation roots are documented as the equivalent of the per-launch flags, and a per-launch flag makes a launch keep its own process. A launch that sets only variables left launch_options empty, so it handed its files to the running instance (or merely raised its window) and exited before any override was read. Those variables now count as the flags they stand for; empty and 0 mean off, as with PHOTOCRAFT_NEW_INSTANCE.
Test: each variable maps to its flag, a value that means off maps to nothing, and skip_reason names the flag.
`liquify_ui::commit` discarded the results of
`layer.smartFilter.setParams` and `filter.liquify`. `PhotocraftApp::run`
showed a failed command's error in the status bar, but the control
channel answered `filter.liquify {"ui": {"commit": true}}` with
`{"committed": true}`. `commit` now returns the command's result, and
the control channel returns the error, as `gallery_ui::commit` does. The
dialog closes on OK whether or not the command succeeds, as the Filter
Gallery's does.
Two tests send OK through the control channel, one after the smart
filters are cleared and one after the layer is deleted. Each checks
that the dialog closed, the status bar shows the error, no history step
was added, and the control channel returned the error.
The Russian catalog passed `cargo xtask i18n-coverage` (100% of measured keys)
but lacked the runtime-only labels the other catalogs already carry, so they
showed in English: filter and adjustment parameter names and choices, gallery
names, Camera Raw panels (Balance/Clarity/Dehaze/Color Mixer/Texture/…),
History/Layers/paths/channels menu labels, metadata and File Info fields,
System Info and hardware status lines, rasterize prompts, composed status
templates and dialog text.
All of them are translated (1,229 keys added; the catalog is now 4,227 keys
against upstream's 2,998), written from the meaning of the English labels and
reusing the vocabulary the catalog already uses (Highlights → Света,
Midtones → Полутона, Feather → Растушёвка, Pattern → узор). Filter and gallery
names are aligned with the menu labels already in the catalog so a filter never
reads two ways (Texturizer → Текстуризатор, Unsharp Mask → Нерезкое
маскирование, Shape Blur → Размытие по фигуре, …).
Tests: `russian_covers_generated_filter_options_and_gallery_names`
(filter_dialog.rs, the Italian/Korean/Spanish check) and
`russian_dynamic_status_hardware_and_dialog_labels_are_translated` (i18n/mod.rs).
Docs: docs/localization-ru.md (terminology), localization-parity, gaps G13,
ROADMAP, ATTRIBUTION and LICENSE-translations.
Validated on a rebase onto upstream/main: `cargo test -p photocraft-ui-egui
--lib` 2,998 passed / 0 failed; clippy clean (the only lint is a pre-existing
`nonminimal_bool` in canvas.rs, untouched here and present on upstream main);
`xtask layers` OK; `xtask wasm` OK; `xtask i18n-coverage` ru 1,863 / 1,863;
offscreen `snapshot` in Russian checked.
File › Open EXR Parts… ran the engine command with no params, failing
with `missing "path"` and never showing a dialog: the picker and parts
form in view_cmds were never reached because the id was missing from
`wraps()`. Add it, with a regression test that drives the menu through
the file picker, the parts dialog and OK.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`type_cmds::refresh` read only the document's resolution and pixel format,
but took `&Document`, so 15 call sites that hold the layer mutably cloned the
whole document to pass it, once per keystroke and pointer move. The new
`refresh_at(dpi, format, t)` takes the two values; those sites read them
before borrowing the layer. `refresh(doc, t)` stays as a wrapper for callers
that already hold the document. `with_text_layer` and `with_text` hand their
closures the resolution, the only thing any of them read from the copy.
type.edit on a 6000 x 4000 document: 60 filled layers 5.60 -> 5.14 ms,
20 layers 4.33 -> 3.90 ms (median of 300).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
The hue/component strip and the colour field only reacted to
dragged() || clicked(), so a click-and-hold updated the selection only
when the button was released. Photoshop updates immediately on the
press. React to is_pointer_button_down_on() instead, which is true from
the press frame onwards and covers dragging too.
Regression test drives the picker with a press-and-hold on the hue
strip and asserts the colour changes before release.
build.rs embeds every font in craft-fonts' fonts/manifest.txt, but
FontDb::new registered only the entries whose scripts include Jpan.
craft-fonts 8dcdacd5 adds Noto Sans CJK SC and Noto Sans Arabic, and
3d9ab3a1 adds Noto Sans Thai. Built with either checkout, the binary
carried those fonts, but the font list did not show them, text layers
did not fall back to them, and works_without_craft_fonts failed with
"Noto Sans CJK SC registered under its manifest name". Chinese and
Arabic set in Inter in a TextEngine::new() engine drew .notdef glyphs.
FontDb::new now registers every CRAFT_FONTS entry. A single-face file
registers under its manifest family, whatever family its name table
gives; each face of a collection keeps the family its name table gives.
With Noto Sans CJK SC built in and no Simplified Chinese system font,
Japanese text set in Inter takes Noto Sans CJK SC when the UI locale is
not Japanese, following cjk::script_order.
craft_fonts_cover_japanese_without_system_fonts now expects the first
CJK craft family in that order.
New tests register in-memory craft entries made from Inter (Hans, Arab
and Jpan entries, two whose name tables give another family, and a
collection), and lay out Chinese, Arabic and Thai in Inter when the
build has a craft font for the script.
Double-clicking a Liquify smart filter in the Layers panel opened the
generic parameter form, whose only field is Mesh Size, instead of the
Liquify workspace. OK on that form wrote the new mesh size into the
filter and replayed the recorded strokes on a different mesh. Filter >
Liquify always opened with no strokes, so the recorded strokes could
not be edited again.
smart_ui::open_editor now sends a Liquify smart filter to the new
liquify_ui::open_smart_filter, as it does for Camera Raw and the Filter
Gallery. The workspace opens on the smart object's pixels below that
filter, with the filter's recorded strokes and mesh size. OK runs
layer.smartFilter.setParams with the strokes and the mesh size, so the
filter is updated in one history step and no second Liquify filter is
added. OK after Restore All leaves the filter with no strokes. Cancel
leaves the filter unchanged.
Group Layers now creates the group collapsed (#5571), so
shift_range_follows_visible_rows_on_both_sides_of_an_expanded_group expands it first, as its name
says. The line is from #5605 by @nrq6993-a11y.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Grows a raster layer one tile per render and rewrites everything with
per-step noise, on the whole-texture and the paged compositor, comparing
each render against the CPU oracle. Fails at 254/255 when keep_overlap
does not route the resident through encoder-ordered writes (verified by
temporarily dropping new.ordered = true).
Picks up the build fixes on main (#6906, #7053).
Keep main's crates/engine/src/presets/mod.rs: #6906 restores export_presets there, so
this branch's copy of the same line would set the field twice.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Take main's version of crates/algo/src/features.rs: #7053 gates the same test helpers this
branch gated in 6bda896a.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The label is only recorded when the redraw finishes, so after a single step it still
read "kept" on slower adapters (FredApps' run on Linux's software adapter). Assert that a
redraw is running instead, and that it finished as "background".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A paged render with a zero resident budget evicts the previous cell's pages as each
new page is created, stays on the GPU and matches the CPU compositor.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
a_damaged_preset_set_keeps_the_other_sets (#5602) checks that one
damaged set in the preferences file does not discard the others. It
left out export_presets, which #5344 added and #6906 put back into
load_presets_json, and recent_documents.
The test now saves an Export As preset and checks that it survives each
damaged set, recent_documents included. It also damages export_presets
and documents on their own, and gives a null set beside the Export As
presets. This is brandongunderson's change to the test in #6479,
unchanged.
Co-authored-by: brandongunderson <[email protected]>
Dissolve in Smart Filter blending follows the inherited color-blending path
without per-pixel coverage sampling (docs/smart-filters.md). Show a short faint
note under the Mode control in Blending Options when Dissolve is chosen, so the
limitation is visible where the mode is picked. No behaviour change.
The note is added to all 20 catalogs. The test renders the dialog body for
every layer blend mode and checks that only Dissolve paints the note.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With "Use Default Masks on Fill Layers" off, only the default reveal-all mask
goes away. Cover that contract directly for Solid Color, Gradient and Pattern
Fill: no selection and no path gives no mask; an active selection still
becomes the raster layer mask (revealing inside, hiding outside); an explicit
vector path still becomes the vector mask, with or without a selection, and no
raster mask is added next to it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
main stopped compiling when #5616 rebuilt Persisted field by field without
the export_presets field #5344 added. Same one-line fix as #6906, so it
merges cleanly whichever lands first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`blocks` and `fnv` are only used by the harris/describe hash test, which runs
on Linux x86_64 only, so on Windows they were dead code and `-D warnings`
failed the build.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The hover preview (preview_move) re-implemented the layer removal and
insertion but skipped the rest of move_to: the clip-group rules
(clip_group::Before/settle), the Background copy rules (unlocked copy)
and the Background-stays-at-the-bottom check. So the preview showed
different clipping than the drop commits, e.g. B C1* C2* U with U dropped
above C1 committed B C1* U* C2* but previewed U unclipped, and moving a
clipping base away left its layers clipped in the preview.
Factor the document-only part of move_to into apply_move and call it from
both, so they cannot drift. A drop move_to would refuse (Background off
the bottom, groups nested too deep) now has no preview either.
Tests compare preview and committed documents (stack order, clip flags and
the flattened composite) for clip-group drops, a base moved away or up
into its own group, option-drag copies, a Background copy, and refused
Background drops.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The merge with main kept both factor lines in ensure_filter_preview, and the
second (main's filter_preview_factor, #6351) shadowed smart_preview_factor, so
a smart filter edit was sized by the edited command alone again. The shared
filter_preview_factor now starts from smart_preview_factor, which falls back to
preview_factor for ordinary filters, and caps that by the view as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Layer > Smart Objects > Relink to File... ran the engine command without a
path, so it always failed with "pass `path`". Front it with the open-file
picker like Replace Contents..., relinking every instance to the picked
file's path (the web picker's name-only answer is an error). A single smart
object's layer context menu now also offers Replace Contents... and Relink
to File... after Edit Contents, as in Photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Since Clouds became a filter, its cloud size came from the filter's reference bounds, which are
the selection's when there is one: clouds in a selection were finer than the canvas's. Before,
they were sized by the document. Clouds now take the canvas as their bounds; the selection only
masks them, as before (review on #6160).
Coverage::finish allocates its output while the accumulator it reads is
still live, so a plain pass peaks at two coverage buffers rather than
one. The budget charged one, which contradicted finish's own doc comment
and left the plain path under its real peak. The bold path's three
buffers were already right.
The exactness assertion in the budget test now pins 18 bytes a pixel for
a grey 8-bit plain render, and fails if the undercount comes back.
Caught in review by @FredApps.
The save dialog asks before replacing only the file it answers with.
After it closes, pick_save can change that path: it gives a name
without an extension the suggested file's extension (#5039), and
Lowercase Extension changes CAT.PSD to CAT.psd. The save then wrote to
the changed path at once, and a file there was replaced without a
question. On Linux this happens when the Swatches panel exports to a
folder that holds Swatches.aco (the one-type portal dialog shows and
asks about "Swatches"), when CAT.PSD is typed where CAT.psd exists, and
in rfd's zenity fallback dialog.
When the path written differs from the dialog's answer and a file
exists there, the save now asks "“{name}” already exists. Replace it?".
Replace writes the file, and ui.dialog.confirm returns the save's
result. Cancel writes nothing and ends the save like the dialog's
Cancel, and Save in the close prompt of an untitled document returns
to that prompt. PhotoCraft counts the file dialog as open until the
question is answered.
A name that reaches the same file, as CAT.PSD and CAT.psd do on
Windows and macOS by default, is not asked about again. The new
same_file service decides this: the desktop app compares device and
inode on Unix and the canonical paths on Windows. The web has no file
stamps and does not ask. The two new strings have rows in all 20
catalogs.
When the pro Color field stretched to a 120 pt minimum, the hex and RGB
controls were clipped below the scroll area in compact groups (storytold/photocraft#6885).
Co-authored-by: Cursor <[email protected]>
The recorded-hash test for harris/describe is cfg-gated to
linux x86_64 (the libm the hashes were recorded from), but its two
helper fns were not, so on other platforms the lib test build has dead
code and fails under -D warnings (#6489).
cargo xtask --help and the layers command output both referred to plan/architecture.md, which does not exist in this repository; there is no plan/ directory at the root. The dependency layering rules are documented as section 3 of docs/architecture.md, so point both references there.
Keeps both the caret-blink reset from main and the document-aware TextEdit.
Also adds the missing export_presets entry in load_presets_json, which broke
the engine build on main.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The Persisted initializer in Session::load_presets_json lost the
export_presets field when the sets were refactored to load independently
(#5616 landed after #5344 added the field), so photocraft-engine does not
compile on main. One-line build fix, kept as its own commit: drop it once
#6906 or #6930 lands.
With a crop ratio preset that differs from the document, entering the Crop
tool fits the default frame to the ratio. That fit went through keyed(),
which marks the frame as drawn, so pressing V (or picking another tool)
committed the crop and changed the document even though the user never
touched the frame. #3451 (#3368) established that an untouched default
frame is dropped on leaving the tool and only a drawn one commits; the
auto-fit was accidentally turning the untouched frame into a drawn one.
fit_frame now moves the untouched default frame to the fitted rect
without clearing default_frame, so leaving the tool drops it like the
plain default frame. Everything else keeps its old meaning:
- a handle resize, a nudge or a drag inside the fitted frame still makes
it a drawn frame, and leaving the tool commits it (#3368);
- Enter still commits the fitted frame (it crops to the ratio, as
choosing_a_ratio_fits_and_commits_without_a_resize pins);
- a W x H x Resolution output size is an explicit instruction and still
marks the frame pending even when it already fits (crop_size.rs).
Display-only decimals on value_field_prec and value_field_units keep range-based 1.0 steps on wide fields like New Document dimensions.
Co-authored-by: Cursor <[email protected]>
Making, changing or dropping a selection (marquee, lasso, Magic Wand,
Quick / Object Selection, Select menu) is still a history step, but it
no longer dirties a clean document, nor do its undo, redo or a History
panel jump over selection-only states. A step counts as selection-only
when the documents differ in nothing but `selection`
(`layer_multi_cmds::selection_only_change`: pixels by tile pointer,
every other field structurally, exhaustive so new fields must be
classified).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
In the browser eframe 0.36 turns one finger or pen tap into two primary
presses and two releases: one pair from Pointer Events (pointerdown runs
a frame of its own) and one from Touch Events (touchstart/touchend). The
menu bar titles and press menus toggle on the press, so the twin press
closed the menu the first had just opened.
The web shell's raw_input_hook now drops a primary press that arrives
while the button is already held (pressed within 0.5 s) and a release
while it is already up. A press held longer still passes, so a lost
release can't lock the pointer down.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Over the GPU memory budget a large visibility change is redrawn by the
CPU compositor in blocks, and nothing kept the result: Alt-click on an
eye and again recomposited every layer (4 s on a 50 MP PSB, ~100 s on a
multi-GB one). The last two finished redraws are now kept, keyed by the
whole document they drew (compared with ==, as the GPU prefix cache
does), the display texture key, the texture format and the block grid;
a redraw of an equal state uploads those blocks. Bounded by what Memory
Usage leaves beside the document and its History, dropped with the
document.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The tool buttons are plain Sense::click widgets and need no hover first.
The offset the report describes is #2246, fixed by #2401 in 0.7.0.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Document the boundary-based classification change and keep semantic segmentation explicitly open so the parity notes do not imply Photoshop-level recognition.
Closes#6548
Co-Authored-By: OpenAI Codex <[email protected]>
Use canvas-boundary coverage instead of a total-area cutoff so dominant enclosed foregrounds reach GrabCut while their surrounding backdrop stays unselected. Verify independent masks, 8/16/32-bit commands and undo, and 24 MP release timing.
Closes#6548
Co-Authored-By: OpenAI Codex <[email protected]>
autoCutout -> select.subject, smartBrushWorkspace -> select.refineEdge,
colorRange presets -> select.colorRange, groupEvent/ungroup -> create/release
clipping mask, make of a layer mask -> layer.layerMask.*, set of the
foreground/background colour -> tools.setColors. Selective Color's yellows
range is Photoshop's Ylws (legacy Yllw still reads). Steps whose params
can't be translated stay unsupported.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Admission now evicts before creating a page, so the post-cell evict found
nothing over budget and Stats::evicted stayed 0 (pages_are_evicted_under_the_budget).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The colr nclx primaries and transfer were dropped, so a 10-bit BT.2020 HLG
HEIC opened as if its samples were sRGB. photocraft-heif now returns the
nclx (inherited from grid tiles like the ICC profile), codecs decodes PQ and
HLG to linear light in 32-bit float (BT.2100 inverse OETF + OOTF at 1000
cd/m2, 203 cd/m2 diffuse white = 1.0), and io tags the document with the
matching profile from the new photocraft_cms::builtin::cicp_rgb. BT.709/sRGB
SDR files are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Compositor::sync created each pc_compose_layer page texture with a bare
create_texture: nothing checked the new bytes against the resident budget
(eviction ran only after each cell) and an Out of Memory error went
uncaptured, invalidating every later texture in the pass.
Now least recently used pages are evicted to admit the new page's bytes
before creating it; creation runs in error scopes (first_error) on native,
and on Out of Memory every page the current cell doesn't use is dropped and
creation is retried once. If it still fails the refresh returns
Unsupported(OVER_BUDGET) so that refresh draws on the CPU and the next one
tries the GPU again; the captured error no longer reaches the health
callbacks. The LRU admission logic is a pure function with a unit test.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop treats Deselect with no selection as a silent no-op, so imported
.atn actions that start with Deselect stopped at step 1 here.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The card's memory caps the GPU budgets (#5234), so a huge canvas can fall
back to the CPU on a card with little of it. System Info (Help and
Preferences › Performance) now lists "Graphics memory" after the driver
when the card reports one; integrated GPUs, which share system memory,
show no such line. Translated in all 20 catalogues.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
wgpu-hal 30 creates Iris Xe textures as committed resources (its
gfx-rs/wgpu#3552 workaround) with D3D12_HEAP_FLAG_CREATE_NOT_ZEROED, and
egui-wgpu fills each new texture with a single copy. On this driver the
copy leaves 4x4 blocks of all-zero texels showing stale memory, so the
empty corners of Brush Preset picker strokes and tool icons show freed
textures' pixels (often red).
ui-egui's new texel_guard plugin rewrites fully transparent texels in
each frame's texture uploads as premultiplied (0, 0, 0, 1) before eframe
renders them (under one 8-bit level of difference). The desktop app
installs it only for DX12 on "Iris(R) Xe", the same adapter test wgpu
uses; PHOTOCRAFT_TEXEL_GUARD=1/0 forces it on or off.
The gpu example zero_block_probe reproduces the driver bug without the
UI: 630/1500 egui-style uploads read back wrong, 0/1500 with the guard.
Driving the release app (tool cycling, picker rescaled): 6/40
screenshots with red blocks before, 0/120 after.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Add 'Reference point location' translation to cs, de, ko, vi, zh-hans, and zh-hant catalogs
- Derive highlighted 3x3 reference point cell dynamically from t.pivot matching point_on_quad
- Add unit test assertions for matching_anchor
Retain both progress histories and integrate the existing Brush Settings changes with upstream. Preserve the response-curve editor, options-label scrubbing and independent preset regression tests where they overlap.
Co-authored-by: GPT-6 <[email protected]>
The engine has had TextDirection (Auto / LTR / RTL) and type.setStyle's "direction" key, but
nothing in the UI set it. Now:
- Window > Paragraph and the Properties Paragraph section have a row of three buttons under the
alignment row: Automatic, Left-to-right and Right-to-left paragraph direction. They set the
selected layers' direction (or the selected text's paragraphs while typing) in one undo step,
and show the current one.
- The Type options bar has the same three buttons after its alignment buttons. Like alignment,
they also set the direction new type starts with (new tool option `type_direction`, "auto" by
default, reset with the tool's other options).
- The glyph is drawn (a pilcrow over an arrow), so no new icon asset.
- The three tooltips are translated in all 20 catalogs (every_tl_literal_is_translated).
Tests: paragraph_direction_buttons_set_and_show_the_direction and
options_bar_direction_sets_selected_layers_and_new_type.
TYPE-218-2 goes from missing to partial: the visual RTL caret (arrow keys moving on screen
rather than in logical order) is still to do. scorecard/type.toml, docs/scorecard.md and
docs/gaps.md (G8) are updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WpQQNxTPBzSLvgAhX27Vtv
Retain both progress histories and integrate the existing Brush Settings changes with upstream. Preserve the response-curve editor, options-label scrubbing and independent preset regression tests where they overlap.
Co-authored-by: GPT-6 <[email protected]>
Retain both progress histories and integrate the existing Brush Settings changes with upstream. Preserve the response-curve editor, options-label scrubbing and independent preset regression tests where they overlap.
Co-authored-by: GPT-6 <[email protected]>
The rubylith (`\`, ⇧⌥-click on a mask thumbnail) was always red at 50 %. Its colour and
opacity are now Photoshop's Layer Mask Display Options: one setting for every mask, kept
across restarts and not an undo step, as Photoshop does it. They live in the preferences as
`layerMaskOverlay` (`#rrggbb`, 0..100 %, red at 50 by default) and are set through
`channel.options {"channel":"mask","color","opacity"}`, which with no fields reads them back
(prefs.set and prefs.reset work too).
In the UI, as in Photoshop: double-click the layer mask's row in the Channels panel or pick
Layer Mask Display Options… from its menu, Mask Options… on the Layers panel mask thumbnail,
or Channel Options… with the mask targeted (`\` counts). Each opens the Channel Options dialog
with just Color and Opacity.
The rubylith is still drawn over the picture as it looks, mask applied, as Photoshop draws
it. The channel view's texture key carries the options, so changing them redraws it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On Windows the fist is a cursor bitmap with a `Crosshair` fallback icon, not the
`None` that `tool_cursor::hand` returns, so comparing the frame's icon to that
return value failed there. Check the icon and bitmap each platform gets.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The merge with main kept the old precondition, job conflict and hidden layer
checks next to main's `self.admit(...)` (#5684), which runs the same checks,
so every started command was checked twice. The New Effects disclosure block
now follows admit, as it followed those checks before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every canvas overlay (the Pen path being drawn, paths, guides, grid,
transform box, crop/slice/gradient overlays) was skipped for as long as
a Hand drag lasted (the tool, Space or the middle button), so the Pen
path vanished while panning. Overlays now always draw; only the tool
cursors yield to the fist (#2196). The transform-only workaround from
#3610 is folded into the general fix.
Test: the_pen_path_being_drawn_stays_visible_while_space_pans (fails
without the fix); transform_preview_stays_visible_while_panning stays
green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ending an empty Type Mask undoes its session, and undo restores the layer selection from
before it, so selecting another layer (or Deselect Layers) during the session was reversed.
`end_if_left` now restores the selection the user made when the commit moved it, through a new
`Session::restore_layer_selection` (drops gone layers, refuses a gone active layer, keeps a clean
document clean).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Clicking another layer while the caret was in a type layer left the edit open, so single-key
tool shortcuts were typed instead. #5612 covered Layers-panel row clicks; every other route
(layer menus, the canvas layer picker, Deselect Layers, automation, a document tab, New/Open)
still left the session open.
A frame guard (`type_tool::end_if_left`) now commits once the edit's layer isn't the active
layer or its document isn't active. `TextEdit` records its document: layer ids repeat across
documents (one .pcraft opened twice), and typing after a tab switch used to edit the other
document's same-id layer. Commits run in the edit's own document; a closed document's edit
is dropped without running anything.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The dock lists the tabs that lay out their own scrolling list and footer so it
does not wrap them in a scroll area of its own (Layers, History, Navigator).
Channels and Paths do exactly the same, but were left off, so each sat inside
the dock's scroll area as well as its own, and the two nested areas shared the
mouse wheel. At 125 % display scaling, for some window heights, the outer
content overflowed by one physical pixel and scrolled with the list: the footer's
buttons moved about a pixel while scrolling down and back while scrolling up.
Group::scrolls_itself now covers all three tabs of the Layers group.
Measured: with 30 paths, the footer moved at 31 of 723 window-height and scale
combinations (all at 125 %); with the change, none. The reporter's video agrees:
the footer strip shifts 1 px and back repeatedly while the tab strip never moves.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
panel_footer lays its 30 px bar over the body's bottom margin ("the bar runs on
over the margin"), but the bar's Ui inherited the body's clip rect, which ends
where the content does. The bottom of every footer button was clipped: a 26 px
hover highlight was drawn about 21 px tall with a flat bottom edge. This is the
"icon looks cut off when you hover over it" in the Paths panel, and it affects
the Layers, Channels and History footers too, since they share the widget.
The footer bar now extends its own clip to the whole bar.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The mock made its MessageIterator inside the spawned thread. A zbus stream
only sees messages that arrive after it is created, so on a loaded runner the
client's SaveFile could land first, go unanswered, and hang the test until
nextest's 600 s timeout (a_typed_extension_wins_over_an_untouched_file_type
on ubuntu-latest, PR #6468).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With a shape layer selected, picking a colour in the Color panel, clicking
a swatch or confirming the foreground Color Picker only changed the
foreground colour: the panels recoloured selected type and nothing else,
so the shape kept its fill. A Gradients panel click recoloured a selected
Gradient Fill layer but ignored shapes. As in Photoshop, these now fill
the selected shape layer.
A new foreground fills the shape with a solid colour, keeping the fill's
own opacity; a Color panel drag is one undo step (edits coalesce on the
press time), each click its own. A gradient preset fills the shape with
the gradient, keeping an existing gradient fill's angle, style, scale and
reverse. Locked shapes are left alone: the Color panel only changes the
foreground, and gradient.presets.select refuses before it changes the
Gradient tool's gradient.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keep the selected shape's stroke when width reaches zero so subsequent
width edits retain its paint and geometry settings in both UI controls.
Preserve explicit No Color removal and gesture-scoped undo behavior.
Add real-widget regressions for style retention, native persistence,
rendering, undo/redo, and deliberate stroke activation.
Fixes#6191.
With port 0 the OS picks a free port, but the control server printed the
requested one (`127.0.0.1:0`), so a client had no way to find it. It now
prints `listener.local_addr()`, like `photocraft-cli serve` does, falling
back to the requested port if the OS can't report it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
build_palette (Adaptive, Perceptual, Selective; Exact shares the histogram) now
gives exactly the same colours, faster. Release, 24 MP, 256 colours, median of 5:
| case | before ms | after ms | speed-up | 1 thread before | 1 thread after |
|-------------------------|----------:|---------:|---------:|----------------:|---------------:|
| photo-like Perceptual | 5068 | 601 | 8.4x | 4841 | 1651 |
| photo-like Adaptive | 4044 | 608 | 6.7x | 3859 | 1251 |
| photo-like Selective | 4862 | 617 | 7.9x | 4720 | 1716 |
| noisy Perceptual | 14124 | 1643 | 8.6x | 13726 | 4018 |
| noisy Adaptive | 10891 | 1485 | 7.3x | 10600 | 2701 |
Max RSS, photo-like Perceptual (whole probe, 384 MB image included): 802 MB
before, 644 MB after. Noisy: 1667 MB before, 1151 MB after.
What changed:
- median_cut keeps each box's (axis, score) and rescans only the two boxes a
split makes, instead of every box on every split.
- median_cut sorts big boxes with a stable radix sort on the f32 total_cmp
order (same order as sort_by, ties keep their input order).
- histogram packs pixels to u32 and sorts and counts them, instead of a
SipHash HashMap insert per pixel.
- k-means finds the nearest centre for all points in parallel, then adds the
f64 sums serially in the original point order.
- The Lab conversion of the histogram runs in parallel (order kept).
- to8 no longer calls libm roundf per channel; it is checked equal to
f32::round for every one of the 2^32 floats.
Every palette hash matched before and after for Adaptive/Perceptual/Selective/
Exact at 256/64/16 colours with no forced colours and Black and White, on a 24 MP
photo-like image, a 24 MP noisy image, a small image with fewer colours than
requested, a 300-colour image and a semi-transparent image. New tests pin a
recorded palette from the old code and compare median_cut, k-means and the
radix sort with the old code bit for bit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
With the Hand tool selected, Cmd (Ctrl off the Mac) held is a temporary
Zoom In and Alt (or Cmd+Alt) a temporary Zoom Out, as in Photoshop: a
click steps the zoom, a drag scrubs (or draws a zoom box with Scrubby
Zoom off), the magnifier cursor shows, and letting go gives the Hand
back. Latched Window > Modifier Keys count too.
Not while Free Transform is open (Cmd is its distort key), and a
modifier pressed during a Hand press, release included, doesn't turn
it into a zoom: only a press made with the modifier held zooms.
docs/ui-parity.md: Navigation row added; Scroll All Windows and Bird's
Eye View recorded as missing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
testkit::gpu_lock documents that WARP faults in d3d10warp.dll when two GPU
harnesses render in parallel in one test process, and that every test that
builds a GPU harness or device holds the lock. Fifteen tests didn't, so on
Windows checker_canvas and marquee_autoscroll (and the lib test binary's GPU
tests) could take their whole test binary down with STATUS_ACCESS_VIOLATION.
They now take the lock on their first line, as the others do.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Adding an artboard next to earlier work made that work vanish: the canvas
painted the opaque pasteboard over the whole image outside the boards,
hiding layers that are not in an artboard. Photoshop shows them, so the
pasteboard now leaves out where visible loose layers draw (their
composite bounds).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop's Print dialog has Functions › Bleed: crop marks print inside
the image by the bleed width, so trimming on them cuts through the
artwork and leaves no paper-white sliver. target-app-parity listed it as
missing ("No Separations/bleed").
- file.print / file.printOneCopy take "bleed" in inches (default 0),
report it back, and Print One Copy remembers it.
- Corner crop marks move onto the trim line, still starting 6 pt outside
the image. Centre and registration marks are unchanged (a symmetric
bleed doesn't move the midlines).
- One helper (corner_crop_segments) drives both the PDF writer and the
native Windows primitives; the dialog preview draws the marks there too.
- Negative, non-numeric or too-wide bleeds are BadParams, never a panic;
bleed without corner crop marks changes nothing and reports 0.
- With bleed absent or 0 the PDF is byte-identical (tested).
The Print dialog field is a follow-up: every tl! label needs all
translations.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop's New Document has Advanced Options › Color Profile; PhotoCraft's always opened in the
working space, so a document in another space had to be made and then assigned a profile.
`file.new` takes `profile` (working, a built-in id or an .icc path), checked against the colour
mode before the document opens, and the New Document dialog lists the working space (by its
profile) and the built-in and installed profiles of the mode's colour space. Changing the mode
drops a profile of the old space.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review on #5412: `sync_views` and `pen_commit` left three ways for a
control batch to reach another document's path in the same frame, before
the next sync. `cancel_stale` now also runs at the top of `pen_down`,
`curvature_down` and `pen_undo_last_point`, and `draw_overlay` draws the
path only on the document it was started on, as #5427 does.
The regression test covers all three entry points after a document switch
and the overlay, comparing the same view with and without the path so the
count measures the path alone.
Co-authored-by: Daktyl198 <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
`pen_commit` resets `ui.direct_selection` when it was targeting the Pen's
path; `cancel_stale` dropped the path without it, leaving the target
pointing at anchors that no longer exist. Spotted by @Daktyl198 on #5412.
The test now drives the Curvature Pen as well as the Pen through File >
New, and covers the Direct Selection reset.
Leaving a path open with the Pen and creating a new document showed that
path on the new document: `UiState::pen` had no document owner, so it
survived the switch.
Follow-up to #5015, which did the same for the Polygonal Lasso. The path
now remembers the document it was started on (`UiState::pen_doc`, set at
its first anchor by `pen_down` and `curvature_down`) and is dropped when
another document becomes active, as the Polygonal Lasso and the Crop tool
already do.
`cancel_stale` also runs at the top of `pen_commit`: the control channel is
drained after `sync_views` (`logic` calls them in that order), so a
document switch in the same frame would otherwise commit the path into the
wrong document. The Crop tool guards its commit path the same way.
Adds a regression test that File > New drops the path, that a switch before
the commit writes it to neither document, and that it still commits to the
document it was drawn on.
Review found that a horizontal guide dragged sideways onto the left ruler keeps
its y, and that reads as a click on the guide: the click branch selected it and
returned before the ruler drop was considered, so the guide survived the drop.
Require `!over_ruler` in that branch, so a drop on a ruler always removes the
guide, and cover the constant-axis case in the direct test and in the canvas
test, which now runs the gesture at two release offsets. `dropped_on_a_ruler` is
now the strips `content_rect` trims off the canvas rect, strictly outside it,
with the boundary, corner and outside-rect cases asserted.
A guide was kept whenever the release still read a position inside the document
on the guide's own axis. The two rulers measure one axis each, so a horizontal
guide let go on the left ruler, or a vertical one let go on the top ruler, was
moved, and a new one dragged out of a ruler was created, instead of discarded.
`dropped_on_a_ruler` reports whether the release point is in a ruler band while
the rulers are shown. `GuideDrag` carries the answer and `finish_drag` treats
such a drop as off-canvas. The canvas sets it from the release position in
screen space, where the rulers are; the ruler's own drag sets it from its
pointer position.
Two of the four new tests fail on main: a guide dragged out of the top ruler and
let go on the left ruler is created, and an existing guide dragged onto the left
ruler is moved. A guide released on the canvas still lands where it was let go.
Fractional stops use the declared decimal precision for arrow keys and scrub steps, not only narrow ranges (storytold/photocraft#5914).
Co-authored-by: Cursor <[email protected]>
The Russian catalog passed `cargo xtask i18n-coverage` (100% of measured keys)
but lacked the ~1,233 runtime-only labels the other catalogs already carry, so
they showed in English: filter and adjustment parameter names and choices,
gallery names, Camera Raw panels (Balance/Clarity/Dehaze/Color Mixer/Texture/…),
History/Layers/paths/channels menu labels, metadata and File Info fields,
System Info and hardware status lines, rasterize prompts, composed status
templates and dialog text.
All 1,233 rows are translated (ru rows 2,936 → 4,208), written from the meaning
of the English labels and reusing the vocabulary the catalog already uses
(Highlights → Света, Midtones → Полутона, Feather → Растушёвка, Pattern → узор).
Filter and gallery names are aligned with the menu labels already in the
catalog so a filter never reads two ways (Texturizer → Текстуризатор,
Unsharp Mask → Нерезкое маскирование, Shape Blur → Размытие по фигуре, …).
Tests: `russian_covers_generated_filter_options_and_gallery_names`
(filter_dialog.rs, the Italian/Korean/Spanish check) and
`russian_dynamic_status_hardware_and_dialog_labels_are_translated` (i18n/mod.rs).
Docs: docs/localization-ru.md (terminology), localization-parity, gaps G13,
ROADMAP, ATTRIBUTION and LICENSE-translations.
Validated: `cargo test -p photocraft-ui-egui --lib` 2,935 passed / 0 failed;
clippy clean (the only lint is a pre-existing `nonminimal_bool` in canvas.rs,
untouched here and present on main); `xtask layers` OK; `xtask wasm` OK;
`xtask i18n-coverage` ru 1,886 / 1,886; offscreen `snapshot` in Russian checked.
Dragging an edge or corner of a paragraph text box with ⌥ held mirrors the
opposite edges about the box's centre, so the block grows symmetrically;
without ⌥ only the dragged edges move. The drag snapshots the box and its
transform at the press (state::BoxResize), so ⌥ pressed or released mid-drag
applies as if it had been held from the start — the mirror always turns about
the centre the box started with. The ⌥ box clamps at MIN_BOX about that centre.
Free Transform and the Ctrl-frame already did ⌥-from-the-reference-point; this
closes the plain Type-tool box-edit path.
Write Kodak Cineon 4.5: the 1024-byte generic header (V4.5, one or three
channels designated gray or R/G/B, 10 bits, code values 0-1023 over
densities 0-2.048) and a 1024-byte film header left undefined, then the
samples three to a big-endian 32-bit word, left justified (packing 5),
lines on a word boundary. Samples are written as round(v * 1023 / 65535),
so a frame opened from Cineon saves back with its code values; other
images take the encode plan (alpha dropped, CMYK and Lab to RGB) and are
quantized to 10 bits.
Cineon leaves ASYMMETRIC_EXCEPTIONS, Save As offers .cin, and the codecs
README, file-format parity and G10 are updated.
Keep capture and visible-label assertions GPU independent. Add opt-in checked snapshots at both scales under the existing GPU lock and Nextest group, with a fresh-output command that rejects empty test selections.
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
PSD smart filters Clds, DfrC, Fbrs and LnsF open as editable PhotoCraft filters instead of
kept-verbatim entries, and are written back like Photoshop: seeds, the item's foreground and
background colours, brightness, centre and lens (Zm/Nkn/Nkn1/PnVs = 50-300mm Zoom, 35mm,
105mm and Movie Prime), with Photoshop's descriptor names. Checked against items saved by
Photoshop 27.
Clouds and Difference Clouds ran outside the filter pipeline (map_pixels with the session's
colours), so on a smart object they failed instead of adding a smart filter. They are now a
regular render filter (FilterParams::Clouds, same noise as before) that records its colours,
so a smart filter re-renders the same clouds after the colours change.
Render filters on a smart object (Clouds, Difference Clouds, Fibers, Lens Flare) now use the
placed content's bounds, as Photoshop does: a flare's centre is a fraction of the object, not
of the canvas.
Resolves the append-only conflicts in the ko, zh-Hans and zh-Hant catalogs
(keeps main's Keyboard Shortcuts set rows and this branch's Make Selection
row) and picks up main's CI fix (#5941) so the PR no longer inherits a red main.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011cC1iWzaGcNtdsW8LXuKQh
The helpers-only use made cargo clippy -p photocraft-io --all-targets
flag is_psd_input as never used; import_stages now calls it for the
extension branch, keeping the magic-first order that lets an Affinity
file renamed .psd stay with the Affinity loader.
In the desktop app Free Transform commits as a background job. When
another tool or layer was picked, end_if_left committed the box (#1400)
and then, seeing it still open while the job ran, cancelled it as if
the engine had refused the commit. Cancelling the job threw the
transform away: an existing layer went back to where it was, and a
dropped file was taken back, or, after New Layer, kept untransformed
because its place was no longer the latest step (#5667). The tests
commit inline, so they never saw it.
A commit still running now ends the session when it lands. If the
engine refuses it then, the box ends as Esc would, as an inline
refusal already did.
A transformed place folds into its Place Embedded step only when the
place is the step just before the transform. After New Layer, the
place, the new layer and the transform stay three steps, instead of the
new layer being folded into the transform and labelled Place Embedded.
Saved workspaces get dynamic Window › Workspace items after the presets
(window.workspace.saved.<index>), checked while current and selecting
the workspace; the top-bar dropdown lists them after the presets.
Co-authored-by: Cursor <[email protected]>
Main's proxy_document now scales a smart object's placement, perspective and
filter mask with the proxy (#5779). The smart-edit preview still did it again,
so a reduced preview drew the object and its mask at a quarter size
(reduced_preview_scales_placement_stack_and_mask_without_adding_filters).
Only the filter lengths and the mask's feather are left to scale here.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
rasterize_warped guarded its size with MAX_PIXELS alone, which says nothing
about bytes. The buffers it keeps live at once are all as deep as the format:
the f32 accumulator and the output surface are both stride deep, a finished
coverage buffer sits beside them for the colour being drawn, and each faux-bold
pass holds a second Coverage and the vector its finish builds. A CMYK bold
render at f32 therefore reserves several times what a plain grey one does for
the same pixel count, and every one of those buffers went through vec! or
Vec::with_capacity, which abort instead of returning.
Admit the peak before reserving any of it: fits_raster_budget derives it from
the stride, the sample size and whether a bold pass runs, refuses an overflowing
size rather than wrapping, and is checked against MAX_RASTER_BYTES (4 GiB, 1 GiB
where usize is 32 bits, so the wasm build keeps room for the document). Coverage
now reserves fallibly and finish returns an Option, and the accumulator goes
through a fallible zeroed(). A refusal returns the empty Rendered the oversized
path already returned, so callers see the existing "too big to draw" result
rather than a crash, and the document and its cached appearance are untouched.
Ordinary text is unaffected: the budget only binds above what MAX_PIXELS
already allowed for narrow formats.
Test: the peak tracks channels, sample width and faux bold against small
injected allowances, an overflowing size is refused, and a buffer that cannot
be served reserves nothing.
Photoshop stores Stroke › Overprint as `overprint` in the FrFX
descriptor. StrokeFx had no field for it, so an edited stroke was
written back without it and a stroke with Overprint on lost it.
Add `StrokeFx::overprint` (serde default, so older .pcraft files load),
read and write `overprint`, accept it in layer.layerStyle.stroke and show
the Overprint checkbox in the Layer Style dialog, as Photoshop does.
Rendering is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop stores Bevel & Emboss › Gloss Contour › Anti-aliased as
`antialiasGloss` in the effect descriptor. The document model had no
field for it, so import dropped it and export regenerated the bevel
without it: once a bevel was edited (the Layer Style dialog re-applies
all its settings), a saved PSD reopened in Photoshop with the gloss
contour aliased.
Add `Bevel::gloss_anti_alias` (serde default, so older .pcraft files
load), read and write `antialiasGloss`, accept `glossAntiAlias` in
layer.layerStyle.bevelEmboss and show the Anti-aliased checkbox next to
the Gloss Contour in the dialog, as Photoshop does. Rendering is
unchanged: contour anti-aliasing is not rendered for any effect yet.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks (the
duplicate "Blur Angle" and "Random" rows are dropped).
main's Spanish catalog (#4306) added New Layer…, Panel Options… and Lock
Layers, so bundled_catalogs_are_consistent failed on the duplicates.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With Auto-Select (on by default) a press on an empty spot starts the layer
marquee, not a move; the test pressed at (32, 32), just outside the 16..32
square. It now presses inside it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
ask_file only queues the request; the file dialog service is called from
poll_file_dialog, so the request count is checked after the poll that shows
the picker and acts on its answer.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The menu bar draws a title with no Latin letter with its English access key
("文件(F)"), so the three live-language tests now look for the title as the
bar draws it (menu_keys::localized) rather than the bare translation.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
server.rs takes main's ui_set layerFilter description (#5776), which
replaces the earlier stopgap here. es.tsv: main's Spanish catalog (#4306)
now has New Layer…, Panel Options… and Lock Layers, so this PR's copies
of those rows are dropped (bundled_catalogs_are_consistent).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fixture builds "Sheets" with layer.groupLayers, which now creates the
group collapsed. The unfiltered baseline lists only expanded groups'
contents (Photoshop), so open the group as the other group tests do. The
filtered checks already look inside collapsed groups (display_rows gets
show_all while a filter is active).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Type tool's options bar now has Photoshop's Create warped text button
after the text colour swatch. It opens the same Warp Text dialog as
Type > Warp Text... for the selected type layers (or the layer being
edited), and is greyed when there is no type layer to warp.
- assets/icons/text-warp.svg: original glyph (a T above a bowed arc) on
Lucide's 24 px grid and stroke; listed in ATTRIBUTION.md.
- The tooltip is translated in all 20 catalogs, reusing each language's
existing "Create Warped Text" wording (new for Russian).
Test: options_bar_warp_button_warps_the_selected_type_layers.
Co-Authored-By: Claude
Saving an Indexed Color document as BMP went through the ordinary RGB
path and wrote a 24-bit file, so the colour table was lost on reopen
(the PNG exporter already had a palette branch).
export_mode_specific now sends Indexed Color to BMP as well as PNG,
through a new encode_bmp_indexed that writes an 8-bit palette BMP with
the document's table. BMP has no transparency, so a transparent index
is written as its palette colour and the export says so.
bmp_palette returned the decoder's 256-entry padding, so a 3-colour BMP
reopened with 253 extra black entries. It now trims the table to the
colours the header declares.
Photoshop's Pen options bar in Path mode offers "Make: Selection…". PhotoCraft
had it only on the canvas's right-click menu. The options bar now has a Make
Selection button (enabled when there's a path) that opens the same dialog for
the path being drawn, finished into the Work Path first, or the active path.
"Make Selection" gains its zh-Hans, zh-Hant and ko catalog rows.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Mnemonics (#4988) were assigned from the label as shown, so a label with no
Latin letter had none and its menu couldn't be opened with Alt+letter. Such a
label now shows its English label's letter in parentheses, before a trailing
ellipsis ("文件(F)", "新建(N)…"), and takes it as its mnemonic, as Windows
menus do in those languages. English and other Latin-script menus are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Filter > Distort > Displace took its map only from an open document or a
path typed into a text field; OK without either ran with no map, and no file
picker ever appeared. Confirming the dialog with no map now opens the file
picker and runs Displace with the chosen file (smart filters included);
Cancel applies nothing.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
As Photoshop's rounded triangles and polygons, a live polygon (the Triangle
tool draws a 3-sided one, stars included) now has one corner radius:
- doc: LiveShape::Polygon gains corner_radius (serde default, so older
.pcraft files load sharp);
- vector: shapes::fillet and shapes::round_corners round every vertex with a
circular arc tangent to both edges, each capped so neighbouring arcs meet
at most halfway along an edge;
- engine: shape.create / shape.edit take "cornerRadius"; transforms scale it
like a rectangle's radii;
- ui: the Properties panel shows a Corner radius field for polygons, and the
on-canvas corner widgets now cover every selected rectangle, triangle,
polygon and star (Path Selection, the Rectangle/Triangle/Polygon tools,
Move with Show Transform Controls). Dragging any widget gives every
selected shape the same radius; Alt still changes one corner of a
rectangle. The widgets take the unsnapped press: snapping used to pull a
shape tool's press off the widget, so the tool drew a new shape instead.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Panorama seams skipped an image lying wholly inside the composite, so after
Auto-Align moved the second photo partly off the canvas it got an all-black
mask and nothing changed. Such an image now takes the side of the seam it
lies towards; blending covers the layers past the canvas; Seamless Tones and
Colors evens out exposure; the Background becomes a layer to take its mask.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main landed Clip Thumbnails to Layer Bounds (#4281, #4312), the same
setting as this PR's Panel Options › Thumbnail Contents, and the Layers
panel filter-type dropdown (#5048, #5268):
- Thumbnails use main's frame and cache (layer_thumb, thumb_image); this
PR's own Layer Bounds region code is gone. The Panel Options dialog's
Layer Bounds / Entire Document now shows and sets
interface.clipThumbnailsToLayerBounds, which the panel menu's checkbox
toggles too, so the two can't disagree; it is no longer saved with the
panel options.
- Filter Options › Show hides main's new filter bar.
- Catalogs keep main's hu/sr/tr/vi rows and this PR's.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve smart_ui.rs: a Filter Gallery smart filter opens main's gallery
window (#3426) before this PR's editor routing; catalogs keep main's
hu/sr/tr/vi rows and this PR's.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop's 32-bit Hue/Saturation raises the saturation by 1 / (1 - s)
like 8/16 bits, but doesn't clip it at 1: saturated colours leave 0..1
(the photoshop corpus's rgb32 hue-saturation.psd, Hue +40, Saturation
+25, Lightness -10, takes red to (1.0477, 0.649, -0.1477), where 8 bits
clip it to (0.9, 0.6, 0)). Its slider is cut to whole 8-bit levels: 25 %
raises by 1 / (1 - 63/255) = 1.328, not 1/0.75; in-gamut colours show
the same factor. The file becomes exact (0.1477 -> 0.0002).
hue_saturation_in carries the depth; the GPU kernel does the same under
F_HDR. Photoshop oracles 186 -> 187 (with the Colorize change, 185 ->
187), floor 187.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop's 32-bit Colorize builds its colour with less chroma than the
8/16-bit one: in the photoshop corpus's rgb32 hue-saturation-colorize
(hue -160, saturation 40, lightness +5) every output has the same hue
and lightness as ours but exactly 0.95 of the chroma, at every input
lightness from black to white. Scaling the saturation by 1 - |Lightness|
at 32 bits makes the file exact; 8 and 16 bits keep the full saturation
(their oracles are exact without it, and scaling there breaks them).
Only a raised Lightness is measured; a lowered one is taken to scale
the same way.
CPU (adjust::colorize_saturation) and the GPU planner alike. Photoshop
oracles 185 -> 186, floor 186.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keep both sides in commands.rs, lib.rs and the workspace sandbox list. Drop stale duplicate ja/hu rows left by the tsv union, add the contour strings to the new sr.tsv catalog, regenerate docs/scorecard.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The native Lab path (compose::native_lab) worked a stack out on the
stored L, a and b but kept each adjustment layer's result unrounded, so
the screen showed values between two samples where Photoshop, which
stores every result at the document's depth, has whole bytes (or
1/32768 steps). On saturated colours half a byte of L is a couple of
RGB levels. Each adjustment's result is now rounded to the depth's
samples (adjustment_quantum: 8 and 16 bits; 32-bit stays unrounded),
as the RGB compositor already does.
photoshop lab8 curves.psd goes from 0.0084 to exact (PASS); Photoshop
oracles 185 -> 186, floor 186. lab8 brightness-contrast (modern) stays
DIFF: our modern curve is within half a byte of Photoshop's on L, not
exact, so rounding now shows its misses (0.02 % -> 1.05 % of pixels).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An Affinity container with many embedded images decoded (and kept) one
surface each with no cumulative cap, so a few-megabyte file could exhaust
memory: each image decodes up to 256 MB and resamples into another
surface of the same order, and nothing tracked the total.
The import now shares a 1 GiB decoded-pixel budget: an embedded image is
charged its decoded w×h×4 bytes, resampling is charged its staging
buffer, and an image the budget cannot cover is left out with a warning,
the same shape as the existing per-image caps. ORA and KRA budget their
decoded pixels the same way.
Regression tests cover the budget arithmetic and both charge sites (an
embedded PNG decode and an RGBA resample) with tiny in-memory fixtures.
Only Free Transform drew a readout of how far the layer moved (its
Hit::Inside offset), so a Move-tool drag inside the transform controls,
or without them, showed nothing. Photoshop shows ΔX/ΔY for a Move drag.
The drag preview now draws the offset the release will commit
(move_ui::drag_offset, whole pixels), for a layer drag and a drag of
selected pixels, once the pointer has moved. It goes through
draw_value_readout, so Preferences > Tools > Show Transformation Values
hides it like the other readouts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are and the branch adds only the keys main lacks (the
duplicate "Content-Aware Fill" row is dropped).
Revert the effect of a23f7778b: every row that main already has is back to
main's text, the rows this branch adds keep the wording of the first commit,
and the header note recording the exception is removed. The catalog stays
clean-room as its header says.
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are, the row main removed stays removed, and the branch adds
only the keys main lacks.
Resolve the new conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows
are kept as they are, the three rows main removed stay removed, and the branch
adds only the keys main lacks.
An animated WebP used to bring only its first frame into a video layer. Now
New Video Layer from File, Replace Footage, Reload Frame and Video Frames to
Layers take every frame, as they do for an animated GIF (#572):
- photocraft-codecs: `decode_webp_frames` returns each frame composited onto
the canvas by image-webp (offsets, blending, disposal), with its duration in
milliseconds and the file's ICC profile. A still WebP is one frame, read as
`decode` reads it. A damaged later frame keeps the frames before it and marks
the first `Truncated`; the frame count and total bytes are checked against
`Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz
target.
- photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop.
- photocraft-engine: `load_frames` takes an animated WebP's frames; the
timeline rate comes from the most common duration. A still WebP keeps the
existing path and the 30 fps default.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keeps main's single format detection (file_encoding_as) with this branch's alpha_to_channel; progress docs keep both sides.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes.
Co-authored-by: GPT-6 <[email protected]>
Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes.
Co-authored-by: GPT-6 <[email protected]>
Upstream 7250e6e2 independently fixed the same manifest/sidecar race. Adopt its canonical test, including the nonzero revision guard, to clear the conflict while preserving the Brush Settings changes.
Co-authored-by: GPT-6 <[email protected]>
Photoshop's Group Layers (Cmd/Ctrl+G) and Group from Layers create the new
group collapsed, so the Layers panel shows one row in place of the grouped
layers. layer.groupLayers and layer.new.groupFromLayers now do the same.
Layer::group still defaults to expanded, so New Group, PSD import and
artboards are unchanged.
Tests that walk group contents now open every group first with
layer.setExpanded {"all":true,"expanded":true}.
Closes#5571
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BNoCkwuu6CgnxJAii7QExa
Persist Character and Paragraph formatting as Type tool defaults and apply it through type.create. Preserve selected-text scope, foreground color, undo, reset, and nonblocking font loading.
Fixes#5556
Co-Authored-By: Codex <[email protected]>
A field that has been clicked into takes the wheel wherever the pointer
is: field_wheel::begin_frame takes plain and Shift wheel events out of
the frame's input before anything is laid out, so the canvas and panels
neither scroll nor pan. Alt and Ctrl/Cmd + wheel stay the view's.
Over a field that hasn't been clicked, the wheel steps it too (as in
Photopea, #4061), but only for a gesture that starts over the field. A
panel being scrolled keeps scrolling when a field passes under the
pointer.
One step a notch, as the Up/Down keys: 1, or 0.01 for small ranges,
x10 with Shift. A typed sum is worked out first. Hovering never takes
focus, and a non-finite step is dropped. The slider wheel and the
fields share widgets::count_notches.
Closes#5473
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions.
Co-authored-by: GPT-6 <[email protected]>
Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions.
Co-authored-by: GPT-6 <[email protected]>
Recovery discovery can list a committed bundle before its sidecar is published. Wait for the expected revision rather than treating a fallback entry as write completion. Keep all failure, retry, revision and pixel assertions.
Co-authored-by: GPT-6 <[email protected]>
Magic 8BPS still goes to the PSD reader. A .psd suffix without that signature is PSD only after Affinity (and other native) magic is ruled out, so a renamed Affinity preview does not fail as an invalid PSD.
The docked Actions panel menu already had Load/Save, but it omitted New Action, Play, Record, and Delete, so it looked like only docking commands. Match the Actions panel id in both casings and list the Photoshop-parity items.
With the ink block read, an opened duotone has its real inks. A
single-layer one is saved flat, which writes the inks rendered as RGB
(flat.rs); the test compared that with the grey plate.
Refs #4814
File › Open sent Photoshop extensions that lacked an 8BPS prefix (or
reached import by name) to photocraft-codecs, which reports
"unrecognized image format". Route them like other native formats, and
offer a Photoshop filter in the open dialog.
Photoshop writes a document's alpha channel as an unspecified extra sample (ExtraSamples 0). The decoder dropped it, so such a TIFF opened without its channel and a save never wrote one. With no extra sample marked as transparency, the first unspecified one is now decoded as the alpha and flagged as an alpha channel (Metadata::alpha_is_channel): flat TIFFs open it as a named alpha channel over an opaque Background, layered ones through their Photoshop channel names. Saving writes the first alpha channel as an unspecified extra sample when the document has no transparency (flat and layered), naming the channels it leaves out; extra samples beyond the alpha are dropped with a warning. Saves also take tiffCompression (none, lzw, zip) and the CLI --tiff-compression. Fixes#5301.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A directory save lists the existing objects, writes its own, publishes its manifest and removes what that manifest doesn't reference. Two writers (two stores, or two processes) saving the same bundle could interleave, and one writer's cleanup could remove an object the other's winning manifest needs. Each save now holds an OS advisory lock on save.lock for its whole transaction; a held lock is waited for (10 s by default, PcraftWriter::set_lock_wait), then the save fails without writing. A crash releases the lock; a file system without file locks saves unlocked as before. Fixes#4957.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Closes#1127. Photoshop's .act (256 RGB triplets, optional count/
transparent trailer) now loads everywhere swatches do: a new
photocraft-psd act parser/writer (byte-stable round trip, hostile
lengths are errors), Format::Act in swatches.import/export with
length/extension sniffing, the Swatches panel menu, File > Open
(desktop + web filters, drag-and-drop) and Load/Save buttons in
Image > Mode > Color Table. Verified against a Photoshop-saved
table (256 colors, byte-identical re-export).
Photoshop records Layer > Duplicate Layer with Destination: New as a
Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy
name), verified against a ScriptListener/XTensions export. Import maps
it to layer.duplicate {document: new}; export writes it back, so the
steps round-trip instead of becoming kept Photoshop steps that fail at
playback. A Dplc with a T document reference (another open doc) now
imports as a kept step rather than misplaying as a same-doc copy.
Keep X/Y flags through ABR import, presets, commands and painting; retain legacy combined inputs and expose both controls with regression coverage.
Co-authored-by: GPT-6 <[email protected]>
Resolve the conflict in crates/ui-egui/src/i18n/fr.tsv: main's new rows are
kept as they are, and the branch adds only the keys main lacks (the duplicate
"Folder" row is dropped).
Picks up main's rustfmt fix for rotate_view.rs and wheel_nav.rs, which
failed the Format step at 74cb2935.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pass the active tool to the section renderer and disable the complete
Wetness/Mix Transfer blocks when they do not apply, preserving saved settings.
Cover all themes/tools, disabled input, active edits and real tool-cache swaps
with three regressions, including exact tools.setBrush journal routing.
Addresses the Wetness/Mix tool-applicability subsection of #3825.
Co-authored-by: GPT-6 <[email protected]>
Amiga IFF/ILBM (EA 85) artwork opens read-only: the FORM chunks BMHD,
CMAP and BODY, both the interleaved ILBM and planar PBM variants, 1-8
bitplanes through the palette (a grey ramp when there is none), masking
0/1/2, and both stored (0) and ByteRun1/PackBits (1) compression. Chunk
lengths and Run lengths are checked, so a malformed file errors rather
than panicking; other bit-plane counts return Unsupported. No writer, so
.iff/.lbm/.ilbm join the open filters and the format opens in Photoshop
parity's Amiga IFF row.
Resolve filter_dialog.rs: main's Blur Gallery pin scaling for proxy
previews (#3801) moves into scale_preview_params, which this PR uses for
every preview, so pins also scale when Blur Gallery runs as a Smart
Filter.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Type tool click on a shown path (the active shape layer's, else the work
path) starts type on that path at the point clicked, as in Photoshop: one
history step, placeholder selected, the paragraph alignment kept about the
click. Over such a path the pointer is the path-type I-beam, and the Type
tool shows the paths as the vector tools do.
- text: warp::arc_length_at, the arc length of the nearest point on a path.
- engine: type.attachToPath takes "at": [x, y] (a document point) instead
of "start".
- ui: the caret, selection and underline follow the curve; a click in path
type puts the caret at the glyph there; glyphs past the path end are not
highlighted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve smart_ui.rs: main's filter row selection, drag and reorder
(#4371, #4764, #4897) keep working on rows sized by this PR (compact
sub-rows, the Smart Filters header and its mask follow the thumbnail size);
this PR's scaling test sits next to main's inline unit tests.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve the conflicts by keeping both sides: the live reorder preview next
to main's Content-Aware Move preview (canvas.rs), the Layers panel options
dialog next to main's rasterize prompt (lib.rs), the thumbnail size test
next to main's lock icon tests (layer_row_ui/tests.rs), and main's hu/tr/vi
rows plus this PR's.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolve the conflicts with main's smart filter row selection, drag and
reorder (#4371, #4897), channel-targeted filter previews and the new
hu/tr/vi rows:
- smart_ui.rs: filter rows keep this PR's layout (name and Blending
Options icon as separate targets, indents) and take main's click to
select, drag and drag-to-reorder; the name forwards them to the row.
This PR's widget tests move to smart_ui/editor_tests.rs next to main's
inline unit tests.
- filter_dialog.rs, canvas.rs: the smart filter preview scaling and
smart_preview_factor run inside main's preview_document_for_target,
with its channel target and source documents.
- i18n: main's rows plus this PR's; hu rows use informal address (#4334).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review feedback on #5062: Adobe's Italian Photoshop calls the Dodge tool "Strumento Scherma" (Burn is "Strumento Brucia") and "Schiarisci" is the Lighten blend mode. Renaming the tool to Schiarisci would have given two different things the same name and split the Dodge/Burn/Sponge family the catalog already had (Scherma, Brucia, Spugna). Only the Screen blend mode changes: it read "Schermo" (the display) and is now "Scherma". The test pins the tool family and the blend modes.
main gained a contributors/people.toml entry ([people.yeniufuklar-com]) while this PR added one ([people.LorenzoFasolino]); both are kept. The i18n change merged cleanly.
Port grouped-tool press-drag-release onto the current panels toolbar,
preserving pointer-outside dismissal and long-press behavior. Add
an egui interaction regression plus boundary cases for gesture pickup.
## Summary
- Translate `Vertical Type Tool` as `直排文字工具`
- Translate `Pencil Tool` as `铅笔工具`
- Translate `Color Sampler Tool` as `颜色取样器工具`
These labels previously fell back to English in the Simplified Chinese UI.
Contributors entry for the About window: real name and display name. The public GitHub profile name is empty, so sync_github_name stays false rather than falling back to nothing.
- 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.
The Duotone Color Mode Data is undocumented. From files Photoshop 2025
made it is 524 bytes: version 1, ink count, four 10-byte ink colours (as
in swatch files, space 0xFFFF = none), four 64-byte Pascal names, four
transfer curves (13 i16 values in tenths of a percent at 0, 5, 10, 20,
..., 90, 95, 100 % gray density, -1 = no point, then an i16), dot gain
and eleven overprint colours.
- Import interprets the inks, so an opened duotone shows them instead of
its grey plate; the block is still kept raw and written back while the
inks are unedited. A colour-book ink (not readable here) shows as black
with a warning.
- Export writes a block for duotones made in PhotoCraft (RGB ink colours,
curves on the 13-point grid or sampled at it, default dot gain,
computed overprints), so every 8/16-bit Duotone is saved as Duotone.
Photoshop opens such files as Duotone, and a black monotone whose curve
is 0 up to 60 % density stays white exactly up to 60 % there.
Refs #4814
usvg's fontdb maps the generic families to Windows fonts (sans-serif to
Arial, serif to Times New Roman, monospace to Courier New) and knows
nothing of Fontconfig's aliases. On a Linux without those fonts (Fedora
44 ships neither Arial nor DejaVu Sans), every family of a usual
font-family="Arial, Helvetica, sans-serif" misses and usvg drops the
text, although hundreds of fonts are installed; the import then only
says "nothing to draw", because the no-font warning (#3168) fires only
for an empty font database.
Point each generic family at an installed font, as a browser falls
back: the system's choice (fc-match on Linux) when the database has it,
then metric-compatible and common stand-ins (Liberation, Croscore,
DejaVu, Noto), the bundled craft fonts, the sans-serif choice, and any
font at all. A generic whose font is installed is left alone.
The tests use the bundled Inter only, so they don't depend on the
fonts of the machine; tests/svg.rs's text test now passes on Fedora.
Address @echelon's review:
- Interleaved/planar sample indexing no longer double-counts the channel and
uses checked arithmetic and get(), so RGB/YBR cannot index past the pixel
data; RGB and YBR_FULL require exactly three samples per pixel.
- The transfer syntax is read from (0002,0010) exactly, and the meta walk
stops at group 0002 (the previous code matched on a literal element number
and validated the whole file as explicit VR LE, breaking implicit VR and
big-endian files).
- Undefined-length sequences outside the pixel data are skipped (nested items
and sequences tracked); encapsulated pixel data is refused.
- Tests added for RGB interleaved and planar, a multi-element meta group, and
implicit VR.
Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was
written, so every_tl_literal_is_translated fails on the merged tree.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main gained Hungarian, Turkish and Vietnamese catalogs after this branch was
written, so every_tl_literal_is_translated failed on the merged tree. Main
also translated New Layer…, Panel Options… and Lock Layers in Italian
(#3742); drop this branch's copies, which bundled_catalogs_are_consistent
rejects as duplicates.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Rewords French entries, old and new, to the terms French retouchers know: Couche for Channel, Zone de travail for Canvas, Masque de fusion, Objet dynamique, Pixelliser, Calque de réglage, Contour progressif, Tampon de duplication, Transformation manuelle, Tons clairs / Tons foncés, Masque d'écrêtage. 88 entries use terms attested in Adobe's public French help pages; 278 come from the reviewer's knowledge of the French interface. The catalog header records the exception to the clean-room note. Keys with two meanings (Fill, Canvas, Screen, Sharpen) are left unchanged.
Adds the 1,041 keys that other catalogs translate and fr.tsv lacked, so French covers the union of all catalog keys. New entries reuse the vocabulary the French catalog already uses (Canal, Rastériser, Canevas, Masque de calque, Objet intelligent); placeholders, edge spaces, trailing ellipses and the two French plural forms are preserved.
Photoshop keeps the Effects, effect and Smart Filter rows at their compact
height and font at every Layers panel thumbnail size; only the layer row
grows, and the Smart Filters header grows just enough for its shared-mask
thumbnail, which follows the thumbnail size (hidden at None).
Previously every sub-row took the layer row height and a 13 pt font at Large,
so sub-rows outsized the layer name. Keep sub-rows at 22 px, 11.5 pt and a
12 px eye; scale only the Smart Filters header and its mask. Effect rows are
back to main's layout. The test checks both and can save renders with
PHOTOCRAFT_LAYERS_SNAPSHOTS=<dir>.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fix the effect-defaults delimiter and preserve independent bevel shading on partial edits. Add the six dialog labels to all current translation catalogs and retain the UI/engine regression tests. Integrate upstream main to address review feedback.
Add a pure-Rust DICOM reader: the file meta group's transfer syntax
(implicit VR little-endian, explicit VR little/big-endian), MONOCHROME1/2
and RGB/YBR_FULL, 8/16-bit allocated samples, interleaved or planar,
BitsStored/HighBit scaling and signed pixel representation. Compressed
syntaxes, sequences, multi-frame data and other photometric interpretations
are refused with Unsupported/Malformed, never wrong pixels. DICOM is
read-only; .dcm/.dicom/.dc3 join the open filters (and .pcx is added to the
web filter, where it was missing).
Add Photoshop's classic 3x3 reference point grid to the Free Transform options bar, enabling quick anchor point selection (corners, edge midpoints, and center). Snaps pivot using projective homography to maintain exact reference positioning across scale, skew, and perspective adjustments.
Both reply with four RGBA floats in 0..1, not 0..255, but control-protocol.md
gave a range for neither: the Engine commands table row for document.pixel and
the Methods line for document.sampleColor. A client that assumes 0..255
mis-reads every probe.
translate_vectors re-rendered a shape layer on every move. For a shape from a
PSD that replaced Photoshop's anti-aliasing with PhotoCraft's, so Artboards to
Files (which moves each board to the origin), the Move tool, nudges, layer
comps and moving artboards changed how imported shapes look.
A whole-pixel move now shifts the rendered pixels, through one shift_shape
helper shared with the Move tool's live preview (layer_multi_cmds::
shift_shown), which already did this. The render is cut at the canvas, so a
shape reaching past it still renders again, as does a fractional move; the
path moves either way, and fit_canvas still re-renders shapes when an
artboard move grows the canvas (#2389).
Measured on a Steam store asset template with #3218 (the same fix for type):
the "How to use" artboard has no pixel off by more than 2 levels from
Photoshop's own composite; before, both its shapes differed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Update to upstream 1e0f884; integrate UI/menu improvements and 17-language catalog updates while retaining Layers Panel functionality and screenshots outside the source branch.
Add a functional Layers flyout and Layers Panel Options dialog, configurable thumbnail sizes and bounds, mask/effect/copy preferences, and canvas reorder previews. Scale smart filter and layer effect rows with layer thumbnail size, and add regression coverage and translations.
Known limitation: Animation Options remains disabled because frame-property unification controls are not implemented.
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# macOS is tested only by release.yml.
platforms:
timeout-minutes:10
@@ -78,7 +79,7 @@ jobs:
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
8.**Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9.**Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
10.**Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`,`slice-select.svg`,`sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`,`text-warp.svg`,`clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -116,6 +120,7 @@ next to the files:
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/atn/` (16 files, 0.9 MB) | Photoshop Actions sets, one folder per repository: `sbaril/` (Frequency Separation, Firefly, Dimension, Harmonize, Photo, Quick Object Extraction, Generative Upscale), `bjango/` (Bjango Actions, Adjustment Actions, Scale Actions), `bkwld/` (Save layers for web), `atntools/` (test.atn), `trimmer/` (Trimmer), `alpha-utility/` (AlphaUtilityAdditions), `gif-creator/` (BannerActions) | Stéphane Baril; Bjango and Marc Edwards; BKWLD; Maurits Rijk; damsmcfly; UCLA Game Lab; overbyte | [sbaril/Photoshop-Scripts](https://github.com/sbaril/Photoshop-Scripts), [bjango/Bjango-Actions](https://github.com/bjango/Bjango-Actions), [BKWLD/photoshop-actions](https://github.com/BKWLD/photoshop-actions), [mrijk/atntools](https://github.com/mrijk/atntools), [damsmcfly/photoshop-cleaner-trimmer](https://github.com/damsmcfly/photoshop-cleaner-trimmer), [uclagamelab/AlphaUtilityAdditions](https://github.com/uclagamelab/AlphaUtilityAdditions), [overbyte/photoshop-gif-creator-script](https://github.com/overbyte/photoshop-gif-creator-script), pinned in [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | MIT (sbaril, BKWLD, atntools, gif-creator), BSD (bjango, per `Help/License.md`), BSD-2-Clause (alpha-utility), Unlicense (trimmer); each licence file sits next to its files |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png" width="128" height="128" alt="PhotoCraft icon: an engraved nine-tailed fox on a blue tile">
</p>
<h1 align="center">PhotoCraft</h1>
<p align="center">
@@ -217,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@@ -339,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Korean | `ko` | 100% | partial |
| Vietnamese | `vi` | 0% | none |
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
## Upcoming
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
@@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered.
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
Loaded 100 of 872 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.