Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
* Filter dialogs: paint the preview computed mid-drag instead of waiting for the pointer to stop
A preview that finished while the slider was still moving was thrown away before it
could reach the canvas, because the worker result had to match the request on every
field including the parameters. Every completion of a drag lands after the value has
moved on, so none of them matched, the worker was handed the newest value instead, and
that one went stale in turn. Nothing was ever painted while the pointer moved: the
effect appeared only once the slider had been still for about a frame of preview
time, which is what "I have to stop the slider and wait" looks like.
The display gate a few lines below already had the opposite rule. It compared the
document, revision, dialog, layer, channel target, command and proxy factor and
deliberately left the parameters out, so that a slightly old frame stays on screen
mid-drag. The publish gate disagreed with it and won, so the frame the display gate
was written to keep never existed.
Publish a successful result when the finished request matches the current one on
everything except the parameters, and let the existing display gate decide whether to
keep it. The two gates now share one predicate, FilterPreviewKey::same_target, so
they cannot drift apart again.
Errors keep the exact match. A value the pointer has already dragged past should not
raise a notice, and a failure is not something to show stale.
What this deliberately does not change:
- A reopened dialog has a new id, a committed document a new revision, a different
command or channel target a different key, so a result from another preview is still
never painted. Covered by a_reopened_dialog_discards_the_previous_dialogs_finished_preview.
- The freshly computed request is still what decides whether a new computation starts,
so the worker still converges on the value the dialog actually holds.
- One preview runs at a time and a stale one is still cancelled only once the values
settle, so this adds no work to the worker; it only stops discarding completed work.
- OK still recomputes at full resolution, so a frame shown mid-drag is never what gets
written to the document.
Test: a_preview_computed_mid_drag_reaches_the_canvas starts a blur at one radius, moves
the slider before that preview lands, and requires the completed frame to reach the
canvas while the slider is still on the new value. It fails on the old gate, timing out
after 10 s with no frame published.
* Filter dialogs: three guards on a mid-drag preview, after review
The first version of this let any completed frame through that matched the dialog
on everything but the parameters. Review found three ways that is wrong, and all
three are now closed and pinned by a test.
A stale result with nothing to show. Storing it replaces a good frame with an empty
one, which the display gate paints as "draw nothing": the canvas blinks back to the
unfiltered layer mid-drag. The exact request may still store an empty result, which
is how a filter that no longer applies clears the canvas, so the guard belongs on
the stale arm only. Splitting the match arm is what makes that separable; the
earlier single arm forced a false choice between the two.
A late frame replacing an exact one. Drag the slider away and straight back: the
frame on screen is already exact for the dialog, and the in-flight frame for the
value on the way out would have overwritten it, showing a blur the user has already
undone. A stale result is now dropped when the stored preview is already exact.
Errors were already scoped to the exact request and stay there: a value the pointer
has dragged past should not raise a notice, and the value it comes to rest on will
raise its own when it is recomputed.
Also pinned by test, because both are easy to undo by accident:
- The reopen test was not reaching the gate. discard_closed reclaims a closed
dialog's job a frame earlier in the real app, and the test called it too, so the
stale result never arrived and the test passed even with the dialog id removed
from the comparison. It now drives the gate directly. Verified by mutation.
- The mid-drag test asserted nothing about what the display gate does with the
published frame, so re-adding params there passed. It now asserts the canvas
actually returns something to draw, and it keeps polling until a frame for the
dialog's current value lands, which is what pins fresh to an exact match.
Tests are deterministic: the worker job is gated on a channel and signals when its
result is already in the worker channel, so nothing sleeps and nothing races the
150 ms settle timer that cancels a job whose values stopped changing.
Verified by mutating each guard and confirming a test catches it:
publish gate back to exact-match ................. killed
drop the is_some guard ........................... killed
drop the already-exact guard .................... killed
same_target ignores the dialog id ............... killed
display gate compares params again .............. killed
fresh relaxed to same_target .................... killed
same_target ignores k ........................... killed
k stays in the comparison on purpose. A 36 MP document previews at k=4, and
preview_factor lowers k as a pixel-sized parameter approaches its minimum, so k does
change under the slider. Relaxing it would paint a frame at a scale the preview
policy exists to avoid, and would loosen the display gate further than upstream
wrote it. The cost is a one-computation pause when the slider crosses a threshold
on a large document; that test keeps the choice visible.
* Filter dialogs: fix a race in the new tests that let CI pass them
The four new tests signalled each other through a channel the worker sends from inside
`compute`. But the worker only puts the result into the channel after `compute` returns:
let result = compute(&work);
let _ = tx.send(result);
So a test that woke on the signal and called `ensure_filter_preview` immediately could
reach `poll()` before that send. On a loaded CI runner it did: ubuntu failed
`a_mid_drag_empty_result_leaves_the_preview_alone` with no frame published at all. The
comment in the test claimed the result was already in the channel, which was the one
thing it was not.
The tests now drive the canvas until the state they need appears, which does not depend
on the order of that send. They also check their invariant on every pass rather than
only at the end: a wrongly published frame can be replaced by the next, correct one
before the loop stops, which had already left the reopen, late-frame and empty-result
tests unable to see their own mutation.
Unchanged: the assertions, the production code, and the four mutations that broke each
guard — re-verified, 7 of 7 still caught.
Runs: 30 back-to-back runs of the four tests on 12 cores, no failure; the full crate
twice, 2800 pass.
---------
Co-authored-by: agent-346022 <[email protected]>
The disabled Number field still drew a DragValue with range 1..=1000,
which clamps the default rows 0 to 1 and reports a change; writing that
back made the Rows checkbox's `positive` fallback turn on, so OK added
guides at the top and bottom. Disabled number fields no longer write
back. Kittest regression covers Rows and Columns.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The ⇧ square (and a 1:1 fixed ratio) is built in continuous document
coordinates, and `marquee_px` then snapped each axis to the pixel grid on
its own: floor the near edge, ceil the far one. A press between pixels,
dragged 100.5 px, snapped x to 101 and y to 102, so the readout and the
committed selection were off by one. `marquee_preview_px` now grows the
shorter side to the longer one, away from the press point, so the square
still covers every pixel the drag touched. Ants, readout and commit share
this path.
Fixes#5472
Holding Up/Down in a value field (Character panel tracking, size, …)
stepped it a couple of times, then jumped focus to the field above.
Each arrow step re-requested focus on the field that already had it.
egui's request_focus resets the field's event filter, so on the next
frame egui no longer knew the field takes vertical arrows. A key repeat
landing on that frame was read as "move focus up". Single presses
rarely hit that frame; held keys always do.
Only request focus when the field lost it (after a typed sum gives it up
to be worked out). Regression test feeds key repeats on consecutive
frames with a field above and failed before the fix (focus moved after
two steps).
Fixes#5474
Co-authored-by: Claude Opus 5.5 <[email protected]>
Double-clicking the empty workspace shows File › Open (#5367)
Also covers the same fix as #5461; thanks to its author.
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
fix(ui): expose toolbar column toggle in all themes
Also covers the same fix as #4680; thanks to its author.
Co-authored-by: Marco Arnulfo <[email protected]>
fix(heif): a grid's ICC profile is inherited from its tiles
Also covers the same fix as #5591; thanks to its author.
Co-authored-by: nrq6993-a11y <[email protected]>
Limit whole-selection transform traces to a padded one-million-cell grid
while preserving normal viewport caching and thin boundary samples.
Use double-precision intermediates for clipping long perspective lines.
Add regressions for large/disconnected selections and viewport accuracy
near the projective horizon. Preserve the full-resolution selection and
the transform behavior already landed in #5337.
Related: #5297, #3402.
Co-authored-by: LabDennis <[email protected]>
Curves: apply to masks, alpha, and Quick Mask (#5335)
Also covers the same fix as #5578; thanks to its author.
Co-authored-by: Zer0C00L01 <[email protected]>
Adjustments and filters on a smart object add a smart filter, so their live
preview re-renders the smart object from its source through its placement.
The preview runs on a proxy document scaled down by k (large documents), and
proxy_document scaled the smart object's cache but left its transform (and
perspective and filter mask) in full-size document coordinates. The re-render
then came out k times too large on the proxy canvas, so the object looked
zoomed in until OK committed the full-size result.
proxy_document now scales the placement and the filter mask with the proxy.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Options bar: Brush and Eraser size go to 5000 px in every theme (#5708)
Also covers the same fix as #5724; thanks to its author.
Co-authored-by: nrq6993-a11y <[email protected]>
A 16/32-bit document saved as PSD has its layers in a global Lr16/Lr32 block, which the writer put first among the global blocks. A Mtrn/Mt16/Mt32 marker kept from the opened file then followed it, and Photoshop refuses such a file (verified in Photoshop 2026 with psd-tools' transparentbg.psd re-saved both ways: Mt32 first opens, Lr32 first doesn't). The layer block now goes right after the last kept marker, as Photoshop orders them. Fixes#5068.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Width/Height fields in Properties > Document and in the New Document
dialog clamped their shown value to at least 0.01 of the unit. A 1 x 1 px
document at 300 ppi is 0.0033 in, so showing it in inches clamped it to
0.01 in, the field reported a change and the document became 3 x 3 px
(two history steps, no user edit). The fields now accept down to 0; the
pixel conversion afterwards already clamps to at least 1 px.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627)
Also covers the same fix as #5642; thanks to its author.
Co-authored-by: nrq6993-a11y <[email protected]>
AppImageLauncher exports DESKTOPINTEGRATION even after
X-AppImage-Integrate=false makes it skip the AppImage. AppRun treated that
variable as "already integrated" and wrote no .desktop file, so the menu
stayed empty. Skip our entry only when another .desktop file's Exec already
points at this AppImage.
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: nrq6993-a11y <[email protected]>
warp() computed the source-centre fallback as (x0 + x1) in i32 and the
inverse-mapped tile footprint as `floor() as i32 - 3` / `ceil() as i32 + 3`.
With a strongly skewed finite affine (for example Homography
[1, 1e12, 0, 0, 1, 0, 0, 0, 1] on a 32x32 clip) the float-to-int cast
saturates at i32::MIN/MAX and the following -3/+3 overflows, panicking in
debug builds and wrapping in release. A source rect with coordinates near
1.5e9 would overflow the centre sum the same way.
Widen the centre sum to f64 and use saturating_sub/saturating_add for the
footprint, which is intersected with src_rect afterwards anyway.
Add crates/algo/tests/warp_clipped.rs covering clipped-vs-full equivalence,
huge scale allocation, the extreme-affine footprint, and the far-origin
source centre.
Refs #3446
* fix(doc): variable definitions follow layer id remapping on copy and load
A duplicated document (image.duplicate, history.newDocument) or a .pcraft
loaded with remapped ids assigned fresh layer ids but cloned VariableDef.layer
unchanged, so datasets silently drove no layer (or, after remap, a colliding
one). Remap variable targets through the same id map as layer comps and
slices; a dangling target keeps a non-colliding placeholder. A merged-only
duplicate drops the whole variable binding set, matching how it drops comps.
Closes#4969
* style: cargo fmt duplicate_variables test
---------
Co-authored-by: highperfocused <[email protected]>
fix(export): prompt for a Render Video output directory (#3365)
Also covers the same fix as #3390; thanks to its author.
Co-authored-by: buivanvu94 <[email protected]>
* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)
- flatten: a path's flattened points are capped at 1 << 20; past it every
curve keeps a proportional share of its pieces (at least one), so hostile
handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
dash_polyline keeps the dashes made so far and draws the rest solid
instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
slope (vanishingly short) are skipped, so huge finite coordinates no
longer produce NaN coverage. fill_rasterizer sorts edges once instead of
after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
shared mask render body, map_knots helper, shared straight-segment test.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Vector: bound stroke outline points (#5211)
- stroke_polygons dashes all lines first, then counts round caps and
joins. When their full-resolution tessellation would exceed 1 << 20
outline points, every round cap and join gets the same smaller segment
count (down to 4 per circle, 1 per join; area-exact radius kept).
Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
instead of per polyline, so many subpaths cannot multiply the dash
(and cap) count. Past it the rest is drawn solid, as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Blur Gallery: Field Blur pins on the image (#3373)
Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.
The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Blur Gallery: Iris Blur's pins and handles on the image (#3373)
Iris Blur opened a parameter dialog with one ellipse. It now uses the
Field Blur workspace too, with Photoshop's handles on the selected pin:
the four on its outline resize that axis and turn the shape, the square
at its corner rounds it towards a rectangle, and the four dots inside
set the sharp core, where the blur starts. A click adds a pin with the
selected pin's shape and blur; pins move, their ring sets the blur,
Delete removes them, and the preview follows.
The pins keep the command's own keys (radiusX/radiusY as fractions of
the bounds' shorter side, angle, roundness, feather), and the handles
use the engine's superellipse geometry, so what the outline shows is
where the filter blurs. Values from automation are cleaned before use.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373)
Tilt-Shift and Spin Blur opened parameter dialogs; they now use the
Blur Gallery workspace with Photoshop's handles.
Tilt-Shift: the solid lines bound the sharp band (drag to widen or
narrow it), the dashed lines mark where the blur is full (drag to set
the transition), the dots on the solid lines turn the band, the pin
moves it and its ring sets the blur. The engine takes one band, so a
click adds none and the dialog keeps the command's own centerX,
centerY, angle, focus, transition and blur.
Spin Blur: pins like Iris Blur's, with the outline handles resizing and
turning the ellipse; the ring and the dialog set the blur angle (one
turn of the ring is 360 degrees). Pins keep the command's keys
(radiusX, radiusY, angle, blurAngle).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(vi): translate the Field Blur pin strings
main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(vi): translate the Iris Blur handle hint
main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (tay nắm for handle, Kéo for drag).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(vi): translate the Tilt-Shift and Spin Blur hints
main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (ghim, tay nắm, Chuyển tiếp, Góc làm mờ).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* Blur Gallery: Alt+Cancel Reset starts again from one pin
main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Field Blur pin strings
main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Iris Blur handle hint
Terms follow each catalog: fogantyú for handle (Hungarian), tutamaç
for handle (Turkish).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Tilt-Shift and Spin Blur hints
Terms follow each catalog: tű, fogantyú, Átmenet (Hungarian); pim,
tutamaç, Geçiş (Turkish).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(sr): translate the Field Blur pin strings
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(sr): translate the Iris Blur handle hint
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(sr): translate the Tilt-Shift and Spin Blur hints
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n: keep main's catalog row where both added the same label
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs/gaps.md: the Tilt-Shift and Spin paragraph replaces the Field/Iris one
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259)
Tapping or holding an arrow key nudged the selected layers one pixel per
History state, so one Undo took back one pixel. Nudges of the same thing
that follow each other now share one `coalesce` key (the engine already
folds edits with the same key into one step), so a run is one state and
one Undo takes it back.
- nudge_bundle.rs decides which run a nudge belongs to: the previous nudge
was of the same thing (layers, selection outline), came within the
pause, and left the document as it is now (no other edit, Undo/Redo,
layer selection or save in between).
- move_mods.rs runs layer.translate and select.transformSelection through
it; Alt-nudges (a copy each) never join a run. Selected pixels float
until dropped, which is one state already.
- Free Transform keeps its own session history; its box nudges fold into
one session step (transform_tool.rs).
- Tools preferences: Bundle Arrow-Key Nudges (default on; off gives a
state per press as before) and Nudge Bundle Pause (ms) (default 1000,
100..10000, hidden while bundling is off). Labels translated in all
catalogues.
The two #1428 tests that asserted a state per press for the selection
outline now cover both settings.
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Export As: exact pixels, named presets, and profile options
Users can set an exact pixel size with the aspect ratio locked or free,
save and reuse named export presets in the preferences document, and
choose whether to convert to sRGB, embed the colour profile, and keep
EXIF or all metadata. Percent scale and the last-used settings stay.
Part of #2289
Co-authored-by: Zer0C00L01 <[email protected]>
* Translate Export As's Color Profile and EXIF labels in every catalog
---------
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* feat(engine): view.editGuide changes a guide in one step
Photoshop's Edit Selected Guides (the guide context menu) reopens New
Guide on the selected guide. view.editGuide takes the guide by orientation
and index and changes whatever is given: "newOrientation", "position"
(pixels or a length with a unit, % of the new orientation's extent) and
"color" (a name or "#rrggbb"), as one undoable "Edit Guide" step. A guide
that keeps its orientation keeps its index; one that turns goes to the
end of the other list. Without "color" the guide keeps its own colour, or
none. Bad params are refused without a history step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(ui): select guides, the guide context menu and Edit Selected Guides
As in Photoshop, a click on a guide with the Move tool (or ⌘ with a
selection tool) selects it, drawn in Photoshop's selected-guide blue
(#0000ff); a press anywhere else deselects it, and Delete or Backspace
deletes it, before Edit › Clear can reach the pixels. A guide that is
dragged stays selected, and a click no longer nudges a guide to the
pointer. One guide at a time.
Right-click on a guide selects it and opens Photoshop's guide menu: Edit
Selected Guides, Lock Guides, Clear Guides, Clear Canvas Guides, Clear
Selected Guides and Clear Selected Artboard Guides. Edit Selected Guides
opens the New Guide dialog on the guide (its orientation, its position in
the ruler unit and its colour, Cyan for a guide dragged from a ruler) and
OK runs view.editGuide, sending the colour only when it was changed.
The selection is view state (UiState.selected_guide, readable over the
control channel), keyed by document, orientation and position like guide
colours, so it lapses by itself when its guide goes. The menu's two new
labels are in every catalogue.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(ui): Alt-click turns a guide; double-click opens the guide preferences
As in Photoshop, with the Move tool:
- Alt-clicking a guide turns it: a vertical guide becomes horizontal
through the point clicked, and the other way round. It keeps its colour
and stays selected; one undoable Edit Guide step (view.editGuide).
Locked or hidden guides can't be turned.
- Double-clicking a guide opens Preferences › Guides, Grid & Slices.
docs/ui-parity.md lists the guide interactions.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(i18n): Hungarian and Turkish strings for the guide menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(ui): the spelling rows call engine_call with the app
main's spelling suggestions (choose_spelling) came in calling
engine_call(menu, command); on this branch engine_call takes the app
first (the guide menu needs it), so the merge didn't compile.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(sr): translate the guide context menu items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(ui): give main's move-hover test guide drag a press point
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665)
Photoshop's default set gives some commands a second key besides the one
its menu shows: F2/F3/F4 Cut/Copy/Paste beside Cmd+X/C/V, Shift+F7 Inverse
beside Cmd+Shift+I, Cmd++ beside Cmd+= for Zoom In, Shift+Backspace for
Fill. A command could hold only one key here, so these couldn't exist and
a .kys import kept only a row's first key (a set listing Copy as Cmd+C and
F3 could leave Cmd+C unbound).
- prefs::ALTERNATE_SHORTCUTS holds Photoshop's second keys;
Preferences.alternate_shortcuts (alternateShortcuts) replaces a
command's defaults, [] removes them. Old settings files load unchanged.
- edit.keyboardShortcuts takes `alternates`; reset, moving a key (taken
from another command's main key or alternates) and the conflict list
count every key, and a call's changes now apply atomically.
- .kys import keeps a row's other keys as the command's alternates.
- Dispatch matches every key (a user's main key wins over a default
alternate); the UI-only SECONDARY table is folded in, so Fill's
Shift+Backspace survives an override of its main key. Menus still show
only the main key.
- Keyboard Shortcuts dialog shows each command's alternates, removable
one by one, with Add Shortcut to add one; Use Default / Reset All
restore them.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Translate Add Shortcut into Hungarian; regenerate the scorecard
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Scorecard: regenerate for the new alternate_shortcuts preference
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
As Photoshop's menus on Windows: a tap on Alt (or F10) puts the keyboard
on the in-window menu bar, with File lit; ← and → move along it, ↓, ↩
and Space open the lit menu with its first row lit, Esc or a click
leave. Alt+letter opens the menu with that mnemonic, open or not, and
in an open menu a letter runs the row with that mnemonic or opens its
submenu.
Mnemonics are assigned per menu level from the labels as shown (so
translated), unique within the level: Photoshop's F E I L Y S T V W H
for the top menus where the label has the letter, else the first unused
letter, word starts first; only ASCII letters and digits, since those
are the keys the keyboard reports. They are underlined while Alt is
held, the bar is focused or the menu was opened from the keyboard; a
menu opened with the mouse shows none until Alt, as on Windows. The
underline is a layout job with the placeholder colour, so disabled rows
stay dim; without underlines the text path is unchanged.
The shortcuts defer to the focused bar as they do to an open menu, so E
opens Edit instead of picking the Eraser and the arrows don't nudge the
layer. Rows carry their mnemonic in the navigation state. macOS keeps
the native menu bar, where this doesn't run.
Tests: two unit tests (assignment, key letters) and seven harness tests
(Alt focus and arrows, second Alt / Esc / click, Alt+letter and Enter,
letters in an open menu, underline conditions, the layout job, and the
whole app's shortcut deferral).
Co-authored-by: Brandon Thomas <[email protected]>
* Add Shift+scroll snap zoom to Photoshop zoom ladder levels
When zooming with the scroll wheel (either via Alt+scroll or via the
"Zoom with Scroll Wheel" preference), holding Shift snaps to the next
Photoshop zoom ladder level instead of the continuous ×1.1 step.
- zoom_with_wheel ON: Shift+scroll snaps to the ladder
- zoom_with_wheel OFF: Shift+Alt+scroll snaps to the ladder
Co-authored-by: Claude <[email protected]>
* Handle Shift+wheel arriving as horizontal delta in snap zoom
Some systems (notably egui on certain platforms) deliver Shift+scroll
as a horizontal delta (x != 0, y = 0) rather than preserving the
vertical axis. Use `raw.y + raw.x` so the snap-zoom path catches
both orientations, matching the existing Shift+Alt pattern on the
line above.
Co-authored-by: Claude <[email protected]>
* Snap zoom: per-viewport fractional accumulation, Camera Raw ladder stepping, and horizontal-delta handling
Port the improvements from TheSeanLavery/photocraft#33:
- `read()` gains `owner: Id` and `hovered: bool` so each viewport
(canvas, navigator, Camera Raw) accumulates its own snap residual
without leaking into others.
- Fractional scroll input (sub-notch point events) now accumulates
across frames with an idle timeout, so precision trackpads and
high-resolution mice reach the next ladder rung.
- Camera Raw snap zoom steps through the ladder via `step_in_range()`
instead of being a no-op.
- Shift+wheel arriving as a horizontal delta (x != 0, y == 0) is
handled by using `raw.y + raw.x` in the snap path.
- `zoom_levels::step_in_range()` extracted for reuse with custom bounds.
- 8 new tests (24 wheel_nav total), 4 new Camera Raw snap tests,
1 new zoom_levels test.
Co-authored-by: Claude <[email protected]>
* Fix test destructure after merge with main (#5080) and reformat
#5080 added a test that destructures gesture() as a 2-tuple, but this
branch makes it return 3 values. Update the destructure and run
cargo fmt --all.
---------
Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823)
image.crop gains contentAware (bool, default false). After a turned crop or one that extends
past the canvas, the area the original image doesn't cover is filled from its surroundings on
the Background (else the active pixel layer), in the crop's one undo step. Each connected piece
(corner, edge strip) gets its own Content-Aware sampling window and samples only covered pixels;
the patch search is Edit > Fill > Content-Aware's (fill_cmds::content_aware_native, shared). A
crop that uncovers nothing is unchanged; one with nothing to sample from fails and rolls back.
Crop options bar: Fill: Background (Default) / Content-Aware Fill (ToolOptions
crop_content_aware, ui.set cropFill). Generative Expand is not offered.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Crop: adapt to Document::background after main removed is_background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Crop: translate Background (Default) for Hungarian and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Fix the merge with main: UI_SET_FIELDS length, Serbian Background (Default)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Merge main: UI_SET_FIELDS counts both new fields
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12)
As in Photoshop, with Path Selection or the Rectangle tool the active shape
layer's live rectangle shows a round widget at the centre of each corner arc
(a few screen points inside a sharp corner). Dragging one rounds every corner
alike, Alt drags that corner alone; the radius follows the move along the
corner's diagonal from the press, within half the shorter side. The drag
previews the new outline and commits one shape.edit with radii on release,
one history step; a click changes nothing.
Docs: ui-parity row done, TOOL-214-12 note updated (still partial: shape
feather), target-app-parity, regenerated scorecard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Corner radius widgets: every selected rectangle, Move tool, cursor and readout
As in Photoshop:
- every selected live rectangle shows its widgets, also with the Move tool
when Show Transform Controls is on;
- dragging any widget gives every selected rectangle the same radius (each
capped at half its own shorter side); Alt changes that corner on each;
all the shape.edit calls share one history step (coalesce);
- the pointer over a widget is the arrow with a rounded-corner badge, and a
drag shows an "R: 25.0 px" readout;
- while dragging, the transform box and the Move rollover highlight hide, and
the Move tool's snapping started by the press is dropped.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
#5267 and #5263 gave 16-bit adjustment layers Photoshop's measured Posterize and
Threshold rules, but the destructive commands still passed no depth for 16-bit
layers and used the 8-bit rules. The live preview (an adjustment layer) then
disagreed with what OK wrote by up to 92/255, failing ui-egui's
adjust_preview::preview_composite_matches_the_destructive_command on main.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Blur Gallery: Field Blur pins on the image (#3373)
Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.
The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Blur Gallery: Iris Blur's pins and handles on the image (#3373)
Iris Blur opened a parameter dialog with one ellipse. It now uses the
Field Blur workspace too, with Photoshop's handles on the selected pin:
the four on its outline resize that axis and turn the shape, the square
at its corner rounds it towards a rectangle, and the four dots inside
set the sharp core, where the blur starts. A click adds a pin with the
selected pin's shape and blur; pins move, their ring sets the blur,
Delete removes them, and the preview follows.
The pins keep the command's own keys (radiusX/radiusY as fractions of
the bounds' shorter side, angle, roundness, feather), and the handles
use the engine's superellipse geometry, so what the outline shows is
where the filter blurs. Values from automation are cleaned before use.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(vi): translate the Field Blur pin strings
main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(vi): translate the Iris Blur handle hint
main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (tay nắm for handle, Kéo for drag).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* Blur Gallery: Alt+Cancel Reset starts again from one pin
main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Field Blur pin strings
main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Iris Blur handle hint
Terms follow each catalog: fogantyú for handle (Hungarian), tutamaç
for handle (Turkish).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(sr): translate the Field Blur pin strings
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(sr): translate the Iris Blur handle hint
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Curvature Pen: drag an anchor of the active path without a modifier
As in Photoshop, the Curvature Pen with no path being drawn shows the active
path's anchors (work path, active shape layer or targeted vector mask), and a
press-drag on one moves it with no Ctrl/Cmd. The curve reflows: every smooth
knot of that subpath gets its handles recomputed with the Curvature Pen's own
curvature_knots, so it keeps flowing through all its anchors; corners keep
their handles. The drag previews live and commits one history step.
- vector: edit::move_anchors_curvature
- engine: path.moveAnchors takes "curvature": bool=false
- ui-egui: direct_select routes the idle Curvature Pen press (Edit::Curvature)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Curvature Pen: add anchors, toggle smooth/corner and adapt the cursor on finished paths
As in Photoshop, with no path being drawn the Curvature Pen also:
- adds an anchor where a segment of the active path is clicked
(path.addAnchor), and a drag pulls it like any anchor; adding and dragging
are one history step;
- toggles an anchor between smooth and corner on a double-click
(path.convertPoint with the new "curvature" param, vector
edit::toggle_curvature_point): a Pen-drawn corner rounds off and the
segments around it curve through it; a second double-click makes it a
corner again;
- shows what a press does in its cursor: + over a segment, four arrows over
an anchor (also an anchor of the path being drawn).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* rustfmt
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* feat(psd): map Filter Gallery smart filters to filter.filterGallery (Chrome)
A PSD smart filter from Filter > Filter Gallery (`GEfc`) was kept verbatim
as psd.unsupportedFilter, so it could not be edited. Its effect stack
(`GEfs` list of `GEfc` effects with `GEfk` effect enum and `GELv`
visibility) now maps to `filter.filterGallery {effects, foreground,
background}` when every effect and setting is modelled, and is written
back as `GEfc` on export. Layout taken from a Photopea-authored PSD.
The table starts with Chrome (`Chrm`: `Dtl ` detail, `Smth` smoothness).
Stacks with other effects or unknown settings stay verbatim as before.
* feat(smart-filters): edit Filter Gallery smart filters in the gallery dialog
Double-clicking a Filter Gallery smart filter in the Layers panel did
nothing. It now opens the Filter Gallery dialog with the recorded effect
stack and colours, previewing on the pixels below that filter; OK updates
the filter in place with layer.smartFilter.setParams (one history step).
* fix(psd): read and write Photoshop's inline Filter Gallery effect
Photoshop 27 writes a Filter Gallery smart filter with a single effect
inline in `Fltr` (`GEfc { GEfk, <settings> }`) rather than as a `GEfs`
list, so such files still opened as psd.unsupportedFilter. Both layouts
are read now. A single visible effect is written inline as Photoshop does;
several or hidden effects keep the list, which is the only layout with a
visibility flag (`GELv`).
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Modern Brightness/Contrast: Photoshop's brightness curve
The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.
The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
inverted through the (exact) contrast curve.
Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.
Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.
Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Brightness/Contrast curves each ink, as Photoshop does
Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.
- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
the channel curve once (brightness_contrast_lut) and runs each
separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
inks directly, so it is exact; a selection mixes them.
adjusted_channels now takes the per-pixel operation, and works out
luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
ink rules.
cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%
The rest is our separation of the RGB composite, not the rule.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK Threshold compares the inks' own luma, as Photoshop does
Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.
stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK legacy Brightness/Contrast pivots at 128 in ink
Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.
brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: adjustment layers composite on the document's own inks
The compositor works in display RGB. In a CMYK document, an adjustment
layer that Photoshop evaluates on the inks could only act on our
separation of the RGB composite, and that separation's black differs
from the file's. So even exact ink rules missed: CMYK Invert was 10%
off, Levels and Curves 50-65%.
The ink-native compositor (compose::native, until now only for
flattening plain raster stacks) now takes adjustment layers too:
- Invert, Posterize, Threshold and Brightness/Contrast (modern and
legacy), and Levels and Curves on CMYK channels, prepared once
(InkStack, InkOp) and applied to the straight inks.
- Opacity and an unfeathered layer mask mix the inks linearly, as in
Photoshop; raster layers' masks scale their coverage the same way.
- A CMYK stack of plain raster layers and such adjustments renders this
way for display, export and reduced views (render_cmyk, tiles in
parallel); flattening and merging keep the adjusted inks exactly.
- Stacks of raster layers alone keep the RGB compositor, which the GPU
matches. The GPU already leaves these adjustments to the CPU.
Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128,
levels, curves, brightness-contrast and brightness-contrast-legacy all
pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io
corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on
the inks, against 442 ms through the RGB compositor.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK ink compositing inlines open groups
The ink compositor gave up on any group. Now a group at full opacity
without a mask is inlined:
- a pass-through group's children composite straight onto the backdrop,
so an adjustment inside it reaches the layers below;
- an isolated (Normal) group of raster layers alone composites as its
children would, since Normal is associative.
An isolated group with an adjustment, which only sees the group's
content, keeps the RGB compositor. So do a translucent group and an
artboard. Nesting is bounded.
psd-tools' CMYK adjustment files keep their swatches in pass-through
groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3%
bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is
unchanged at 165.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Hue/Saturation and Color Balance keep the black ink
In a CMYK document Photoshop applies Hue/Saturation (not Colorize) and
Color Balance to C, M and Y as the complement of an RGB colour, and
leaves the black ink alone. On the photoshop corpus's own inks,
cmyk16 hue-saturation.psd follows this on every pixel within half a
level (cmyk8 within two), and color-balance within our RGB Color
Balance's own error. We applied both to the separated screen colour,
which also rewrote K.
- adjust::cmy_adjustment and apply_cmy run the RGB adjustment once per
band on (1 - c, 1 - m, 1 - y).
- The ink compositor (InkOp::Cmy), the RGB compositor's CMYK branch and
destructive Image > Adjustments (pixels::adjusted_cmy) use them. The
GPU leaves these to the CPU in CMYK documents, as it does the other
ink rules.
- Colorize separates its colour again, black included, so it keeps the
screen-colour path.
cmyk8/16 oracles, bad pixels:
- hue-saturation: 100% -> 0.4-0.7%
- color-balance: 74% -> 25%
Neither passes yet. Half a level of cyan ink is three levels of red
through the profile at saturated cyan, and our RGB models leave that
much. The pass count stays at 165.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Modern brightness: knee at output 126, fit out to -95 and +150
psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.
Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.
The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: whole levels at every step and Photoshop's shadow toe
In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).
The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).
Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Lab: adjustment layers composite on the stored L, a and b
The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.
compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.
Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Posterize works on the stored values (1 - ink)
Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the
ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ,
e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops
it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off
to exact; psd-tools 241 -> 242, floor 242.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Raise corpus floors to the results of the landed adjustment fixes
Photoshop-authored 165 -> 185 of 258, io 146 -> 148 of 170, psd-tools 242 -> 249 of 309,
measured with the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and
pass-through PRs merged together.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Modern Brightness/Contrast: Photoshop's brightness curve
The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.
The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
inverted through the (exact) contrast curve.
Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.
Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.
Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Brightness/Contrast curves each ink, as Photoshop does
Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.
- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
the channel curve once (brightness_contrast_lut) and runs each
separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
inks directly, so it is exact; a selection mixes them.
adjusted_channels now takes the per-pixel operation, and works out
luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
ink rules.
cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%
The rest is our separation of the RGB composite, not the rule.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK Threshold compares the inks' own luma, as Photoshop does
Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.
stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK legacy Brightness/Contrast pivots at 128 in ink
Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.
brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: adjustment layers composite on the document's own inks
The compositor works in display RGB. In a CMYK document, an adjustment
layer that Photoshop evaluates on the inks could only act on our
separation of the RGB composite, and that separation's black differs
from the file's. So even exact ink rules missed: CMYK Invert was 10%
off, Levels and Curves 50-65%.
The ink-native compositor (compose::native, until now only for
flattening plain raster stacks) now takes adjustment layers too:
- Invert, Posterize, Threshold and Brightness/Contrast (modern and
legacy), and Levels and Curves on CMYK channels, prepared once
(InkStack, InkOp) and applied to the straight inks.
- Opacity and an unfeathered layer mask mix the inks linearly, as in
Photoshop; raster layers' masks scale their coverage the same way.
- A CMYK stack of plain raster layers and such adjustments renders this
way for display, export and reduced views (render_cmyk, tiles in
parallel); flattening and merging keep the adjusted inks exactly.
- Stacks of raster layers alone keep the RGB compositor, which the GPU
matches. The GPU already leaves these adjustments to the CPU.
Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128,
levels, curves, brightness-contrast and brightness-contrast-legacy all
pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io
corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on
the inks, against 442 ms through the RGB compositor.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK ink compositing inlines open groups
The ink compositor gave up on any group. Now a group at full opacity
without a mask is inlined:
- a pass-through group's children composite straight onto the backdrop,
so an adjustment inside it reaches the layers below;
- an isolated (Normal) group of raster layers alone composites as its
children would, since Normal is associative.
An isolated group with an adjustment, which only sees the group's
content, keeps the RGB compositor. So do a translucent group and an
artboard. Nesting is bounded.
psd-tools' CMYK adjustment files keep their swatches in pass-through
groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3%
bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is
unchanged at 165.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Modern brightness: knee at output 126, fit out to -95 and +150
psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.
Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.
The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: whole levels at every step and Photoshop's shadow toe
In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).
The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).
Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Lab: adjustment layers composite on the stored L, a and b
The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.
compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.
Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Posterize works on the stored values (1 - ink)
Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the
ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ,
e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops
it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off
to exact; psd-tools 241 -> 242, floor 242.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Modern Brightness/Contrast: Photoshop's brightness curve
The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.
The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
inverted through the (exact) contrast curve.
Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.
Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.
Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Brightness/Contrast curves each ink, as Photoshop does
Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.
- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
the channel curve once (brightness_contrast_lut) and runs each
separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
inks directly, so it is exact; a selection mixes them.
adjusted_channels now takes the per-pixel operation, and works out
luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
ink rules.
cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%
The rest is our separation of the RGB composite, not the rule.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK Threshold compares the inks' own luma, as Photoshop does
Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.
stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK legacy Brightness/Contrast pivots at 128 in ink
Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.
brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Modern brightness: knee at output 126, fit out to -95 and +150
psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.
Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.
The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: whole levels at every step and Photoshop's shadow toe
In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).
The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).
Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Lab: adjustment layers composite on the stored L, a and b
The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.
compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.
Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Type and Content-Aware Move boxes: resize arrows follow a rotated view
The type box (paragraph text handles, Ctrl/⌘ transform corners) and
Content-Aware Move's Transform On Drop box chose their resize arrows with
type_transform::resize_cursor, which mirrored the direction for a flipped
view but ignored a rotated one: on a view turned 90°, the side handles
showed ↕ although they resize left and right on screen.
The helper now takes the view and maps the handle's direction through it
(ViewXform::map_vec: rotation, flip and pixel aspect) before rounding to the
nearest of egui's four resize arrows. It moves to the canvas cursors as
tool_cursor::resize, so the other tools with resize handles can use it too.
The new test, resize_follows_the_view, checks views turned 0°, 45° and 90°
and a flipped view, with the expected arrows written out by hand; it fails
when the view's rotation is ignored.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Free Transform, Warp, Artboard, guides, Crop: arrows follow the view
The remaining tools with resize arrows chose them without the view:
- Free Transform picked the arrow from the handle's index alone (corners 0
and 2 ↖↘, 1 and 3 ↗↙, edges 0 and 2 ↕, 1 and 3 ↔), so a rotated box showed
wrong arrows (all 8 handles at 45°, 90° and 135°), and so did a rotated or
flipped view.
- Warp's Split Vertically/Horizontally cursor was always ↔/↕, though the
split line follows the grid (turned when Warp starts on a rotated box) and
the view.
- The Artboard tool used a fixed table, ignoring a rotated or flipped view.
- Guides turn with a rotated view (#3277), but the cursor over them stayed ↔
for a vertical guide and ↕ for a horizontal one.
- Crop followed the frame's angle and a rotated view, but not a flipped one:
its corners showed the other diagonal.
Free Transform and the Artboard tool now get the canvas's view and use
tool_cursor::resize: Free Transform with the direction from the opposite
handle, the Artboard tool with the handle's outward direction.
rulers::guide_cursor uses it for the direction across a guide, and Warp's
split_cursor for the grid's own direction across the split line at the
pointer (split_cursor and quick_split_kind take the grid position, so the
cursor looks it up once). Crop keeps its own resize_icon, which now mirrors
the turned direction when the view is flipped. On an upright, unflipped view
every arrow is as before.
New tests, with the expected arrows written out by hand, fail on the old
code: resize_cursors_turn_with_the_box_and_the_view (Free Transform),
warp_split_cursors_turn_with_the_grid_and_the_view,
resize_cursors_turn_with_the_view (Artboard),
guide_cursors_turn_with_a_rotated_view, and flipped-view checks in Crop's
cursor_follows_the_frame.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Modern Brightness/Contrast: Photoshop's brightness curve
The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.
The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
inverted through the (exact) contrast curve.
Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.
Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.
Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Modern brightness: knee at output 126, fit out to -95 and +150
psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.
Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.
The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: whole levels at every step and Photoshop's shadow toe
In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).
The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).
Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Lab: adjustment layers composite on the stored L, a and b
The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.
compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.
Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: whole levels at every step and Photoshop's shadow toe
In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).
The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).
Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Lab: adjustment layers composite on the stored L, a and b
The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.
compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.
Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Layers panel: Clip Thumbnails to Layer Bounds (#4281)
Add interface.clipThumbnailsToLayerBounds (off by default), toggled from the
Layers panel menu or Preferences > Interface. When on, a pixel layer's
thumbnail frames the layer's own pixels (cut to the canvas) instead of the
whole canvas, so small objects in a large document stay visible. Empty or
fully off-canvas layers keep the canvas frame.
The frame is computed only when the thumbnail is rebuilt (tile-cached
content bounds) and is part of the cache key, so toggling rebuilds it.
Closes#4281
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: badaaif <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: whole levels at every step and Photoshop's shadow toe
In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).
The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).
Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Empty commit to resync the pull request head
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751)
Shift-click puts the next Pen anchor on the nearest 45-degree ray from the
previous anchor (at the pointer's projection onto it); Shift held while
dragging out direction handles turns them to 45-degree steps, read live
per move. The first anchor, closing on the first anchor and the last-anchor
break are unaffected; after Alt breaks the pair Shift constrains the
outgoing handle alone. The rubber band previews the constrained point.
Reuses stroke_constraint::snap45. Flips scorecard TOOL-214-10 to done.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Brush Settings: a response curve per dynamic control (#3294)
Every brush Dynamic (Size, Opacity, Flow, Roundness, Scatter, Count,
Depth, Foreground/Background...) now carries its own response curve for
its control (Fade, Pen Pressure, Pen Tilt, Stylus Wheel, Rotation),
applied after the global Preferences pen curve. It is part of the brush
JSON, so tools.setBrush sets it (e.g. shapeDynamics.size.curve), actions
record it and presets keep it; linear by default, so existing brushes
paint unchanged.
- paint: ResponseCurve, the global pen curve's monotone cubic moved down
from the engine as a fixed-size Copy type (no allocation per dab);
serialises as its point list. dynamics::dynamic_value applies it.
- engine: prefs::PressureCurve is now that type.
- ui-egui: each Control row gets a curve button (lit when the curve isn't
linear) that shows the same editor as Preferences > Tools.
- scorecard: TOOL-213-11 done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n: Response curve for hu, tr
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Fix i18n after merge: Serbian Response curve, drop duplicate hu row; regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Brushes: import an image file as a brush tip (#3279)
`brush.presets.importImage` reads an image (path, or base64 data with
an optional fileName) through the normal importer, so any format and
bit depth File > Open reads works. Its composite becomes a sampled tip
the way Define Brush Preset reads a selection: darkness x alpha,
cropped to the painted extent, at most 2500 px a side. The bounds are
found band by band first, so a large mostly-white image never needs a
full-size buffer. The preset is named after the file (never replacing
an existing one) and becomes the current brush unless select is false.
Import Brushes... in the Brushes panel and the brush preset picker now
opens a picker for .abr and image files; images go to the new command,
.abr files to brush.presets.importAbr as before. Automation refuses an
ambient `path` for it like the other import commands.
Define Brush Preset and the new command share `sampled_brush`.
Scorecard TOOL-213-12 -> done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(ui): remember the selected tool between launches
* fix(ui): preserve explicit Eraser selection during temporary tool overrides
Clear temporary pen and spring state for explicit toolbar, menu, preset and control selections. Keep spring-loaded key behavior; only permanent key choices clear the pen marker. Add exit/relaunch coverage for explicit Eraser with pen and spring overrides.
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Batch: Include All Subfolders (AUTO-219-5, #3753)
File > Automate > Batch and File > Scripts > Image Processor take an
optional "includeSubfolders" (default false): a folder input also yields
the images in its subfolders, walked depth first in name order, bounded at
32 levels and 100000 files, with a seen-set of canonical folders against
symlink loops and the output folder left out. Each result keeps its
relative subfolder under the output folder, so same-named files in
different subfolders no longer collide. A non-boolean value is BadParams.
Both dialogs gain an "Include All Subfolders" checkbox (translated in
every complete catalog). Scorecard AUTO-219-5 stays partial (naming
template, progress/cancel, error log file); gaps.md G11 updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Batch: translate Include All Subfolders for Hungarian and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The filter row's "Kind" was painted text. It is now a dropdown, as in
Photoshop: Kind keeps the five layer-kind toggles, Name lists layers whose
name contains the typed text (ignoring case), and Mode lists layers with
one blend mode (Pass Through included), filtering by Normal at first.
The criteria live in UiState (layer_filter_by, layer_filter_name,
layer_filter_mode; serde defaults, so saved settings still load) and are
drivable with ui.set {layerFilter: {by, kinds, name, mode}}, validated
before anything changes. ui-parity.md gets a partial row: Effect,
Attribute, Color, Smart Object, Selected and Artboard are still missing.
Co-authored-by: Brandon Thomas <[email protected]>
With a selection, ⌘T and the Move tool's Show Transform Controls framed the
layer's content clipped to the selection, so the box was smaller than the
marching ants wherever the layer did not fill the selection. The ants then
stayed where the selection started until the transform was applied.
- transform_frame / target_frame (engine): the box is the selection's own
bounds when a selection limits the transform. transform_bounds is unchanged,
so snapping and the command defaults are as before.
- The selection outline is drawn through the open box (homography, or the warp
in Warp mode), so it follows move, scale, rotate, skew, distort, perspective
and warp before the transform is applied. The whole outline is traced while
such a box is open, not only the part in view where it started.
- marching_ants_lines: ants along screen-space lines; non-finite lines are
skipped and very long ones are clipped to the view.
Part of #3402.
Claude-Session: https://claude.ai/code/session_015vKPoMXFhSvWmX5D9XM646
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped
In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
255/512 of the constant and is exact on pure red, green, blue, black
and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
as the blend mode is there; white tints to (1.05, 1.00, 0.87).
We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.
Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Masks: feathers blur with exactly the feather's variance
A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).
Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).
psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Threshold: split on Photoshop's luminosity of whole samples
Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:
- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
(round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.
Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.
psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Groups: a pass-through clipping base with an adjustment renders isolated
A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).
CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.
Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.
The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).
psd-tools 239 -> 241; floor 236 -> 241.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The embedded and working profile names now sit on the choices they belong
to ("Use the embedded profile (Display P3)", "Convert to the working space
(sRGB IEC61966-2.1)") instead of two lines above them; the sentence naming
the file stays. The missing-profile prompt is unchanged.
"Remember this choice for future images" saves the answer as the Color
Settings policy for the file's mode (preserve, convert or off) and turns
Ask On Mismatch off, so files read from then on open without asking. Files
already waiting in the queue with a mismatch in the same mode get the same
answer (open_prompt::answer_remembering); a different question still asks.
The checkbox's tooltip says where to turn the prompt back on.
The catalogs drop the rows nothing uses any more ("Embedded: {embedded}" and
the two old choice labels) and add the four new strings in every language.
Co-authored-by: Brandon Thomas <[email protected]>
An action can take one of Photoshop's button colours, and the panel can switch to Button Mode so a click plays that action. The colour is stored on the action and is empty until it is set. The new labels are in every complete catalog.
Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* feat(ui): Darken and Lighten Interface (Ctrl/Cmd+Shift+F1, F2)
Photoshop's Shift+Ctrl+F1 (⇧⌘F1 on macOS) darkens the interface by one of
its four brightnesses and Shift+Ctrl+F2 lightens it, stopping at the
darkest and lightest. PhotoCraft has the same four as themes (Pro, Pro
Medium Gray, Pro Light Gray, Pro Light), so view.darkenInterface and
view.lightenInterface step through them from the theme on screen and save
the choice like picking it in Preferences › Interface (setting the dark or
light theme and the matching appearance). From another theme they step
from the nearest brightness: Medium Gray for a dark one, Light Gray for a
light one. They work with no document open. Both labels are in every
catalogue.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(i18n): Vietnamese strings for Darken and Lighten Interface
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(i18n): Hungarian and Turkish strings for Darken and Lighten Interface
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(sr): translate Darken and Lighten Interface
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The engine, renderer and PSD mapping already supported Smooth, Chisel Hard
and Chisel Soft, but the dialog's Bevel & Emboss page had no control for it.
Add the Technique dropdown under Style (Photoshop's order), a smooth default
for new bevels, and translations.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* ACEScg: File Handling option opens colour-less EXR renders in the ACES working space
Our render pipelines write ACEScg (AP1 primaries, ACES white ~D60,
linear) and — like most — state no colour space in the EXR header, so
io's linear-sRGB default misrepresented them (saturated hues shifted).
- cms: AcesCg joins the built-in profiles, generated from the published
AP1 primaries and ACES white point with a linear TRC (id "aces-cg",
aliases acescg).
- engine: File Handling gets "Open color-less EXR as ACEScg". When on,
an EXR that io tagged with the linear-sRGB default opens as ACEScg
instead; embedded or EXIF-declared profiles stay untouched.
Translated in every catalog. ACES view transforms (OCIO-based display)
remain future work; this covers the document's source colours.
* scorecard: 26 of 158 (main's #3472 preference added one too)
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Brandon Thomas <[email protected]>
Clicking a snapshot now selects it (highlighted in place of the current
state) until the history moves on. With a snapshot selected the footer's
trash runs history.deleteSnapshot instead of history.deleteState, as in
Photoshop. Snapshot rows and past state rows can also be dragged onto
the trash to delete them.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* raster: a shifted surface copies in one pass per row without pre-filled tiles
Surface::translated's misaligned path allocated every target tile through
Tile::filled (a per-pixel default fill when the default is nonzero)
before overwriting nearly all of it, and addressed spans through
per-row closures. The path now writes each stored row directly: content
spans copy as single memcpys crossing source tiles as needed, vacated
edges fill from one prebuilt default row, and filled() builds its
default rows in bulk. Same output (the read-back oracle covers every
depth, sign and tile crossing), cheaper for nonzero defaults and one
less pass over each tile.
* raster: a missing source tile in the shifted content reads the default pixel
The misaligned path skipped absent tiles inside the content rect,
leaving zeros where the surface's default pixel belongs (a white mask
with sparse tiles turned black after a move). The span now fills from
the prebuilt default row. echelon's snippet is a regression test, and a
randomized oracle compares the whole path against a per-pixel reference
gather across formats, default pixels, sparse tiles and tile-boundary
crossing shifts.
* ci: retrigger wasm32 (the full workspace wasm check is clean locally; the job failed before writing a log and the run is stuck queued)
* fix(windows): respect top and side taskbars in custom-frame maximize
* Keep work-area correction stable for narrow taskbar reservations
* style: apply rustfmt to taskbar window correction
* fix(window): repaint after custom-frame work-area correction
* Undo and redo Camera Raw adjustments from the keyboard.
Changing a slider and pressing Ctrl+Z did nothing, because the dialog blocked application shortcuts and kept no history of its own. Each finished edit, including one slider drag, is now one step. The control channel accepts ui.undo and ui.redo.
Refs #4100
* Format camera_raw_ui.rs (rustfmt)
---------
Co-authored-by: Brandon Thomas <[email protected]>
The canvas drew guides before transform_tool::draw_overlay, so the moving
pixels of a layer under Free Transform covered them. Split the overlay:
draw_content paints the preview (plain, styled or warped) and the layers
above it right after the document, under the grid and guides; draw_overlay
keeps the box, handles, reference point, warp grid and split guides over
them, as in Photoshop. The hand-pan drag path does the same.
Test: guides_draw_over_the_transform_preview_and_under_its_box checks the
painted order preview < guide < box; it failed before the fix.
* Layer styles: editing effects no longer re-enables a disabled effect list (#3648)
* Layer styles: follow #3648's exact dialog steps in a test
Preview off shows the document as saved (the in-dialog stroke disable is
a pending edit), so the state the issue reports is Photoshop's preview
semantics, not a resurrected effect: the live preview honours the
disable, and OK applies it.
* style: cargo fmt
On top of #5001. Two UI-thread stalls it leaves on a 16000 × 10000, 30-layer PSB
whose layers exceed the GPU memory budget:
- GpuCanvas::composite checked the memory budget for full refreshes only. Hiding
or showing a layer that covers most but not all of the canvas took the
synchronous GPU path, paging every layer in on the UI thread. Damage of
DEFER_PX or more now gets the same check, so it goes to the background redraw.
Small edits keep the GPU path without the check.
- The first draw after a background open scanned every layer's content bounds
on the UI thread (GPU planning). The open job now warms the shared tile bounds
cache before handing the document over (warm_layer_bounds, cancellable between
layers).
Co-authored-by: syncroot <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Interface: a preference to hide the Home screen's recent files (#4382)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n: drop the hu/tr 'Enable animations' lines, #4362 adds them
Both PRs added the same two lines; whichever merged second would leave a duplicate key (bundled_catalogs_are_consistent rejects it).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Merge main; fix i18n (duplicate ja row, missing sr row) and regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Drop the duplicate hu row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The Swatches panel used the shared preset browser grid, with a 4 px gap
between cells and a rounded, bordered card for each. Photoshop's panel
packs flat squares edge to edge. Place gets a `flush` flag: no gap, no
per-cell border (only the selected and hovered cells are outlined) and
square corners. Swatches sets it; gradients, patterns, styles and shapes
keep the gap, now the named GRID_GAP and GRID_INSET constants.
Test: swatches_touch_without_a_gap clicks 1 px past the first swatch and
expects the second one selected; with the gap that point was empty.
Quick Selection reused the Ellipse Marquee's dashed circle. It now has a
distinct glyph: Lucide's brush with a dashed selection arc around the
bristles (Photoshop's Quick Selection mark). A rasterized test asserts
the two icons read as distinct and that the arc actually renders.
Co-authored-by: highperfocused <[email protected]>
While the Info tab was visible, every frame ran
`view.colorSamplers.list` through the command dispatcher, compositing
each sampler point (up to 10) through the whole layer stack, with layer
effects building a 1-pixel effect-map entry each time.
sampler_readouts keeps the readouts for (document, revision). Adding,
moving or deleting a sampler and every pixel edit bump the revision,
so the readouts refresh exactly when they can change and are shared
between frames otherwise.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Hiding Edit › Menus (or Keyboard Shortcuts) left no way back into the
dialog, and Save, Save As, Exit and Preferences could disappear too.
edit.menus now refuses to hide them, the dialog greys their Visible box,
and the menus keep them even when a preferences file lists them.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The hex field's six-character limit dropped any paste into a full field
(every paste after the first) and any paste with a leading #. A pasted hex
colour now replaces the whole code.
Co-authored-by: kwesi <[email protected]>
#1322 lifted the number's slot 1 pt above the box's centre because digits
have no descenders. Measured on today's mono font at 4x, the digits' ink
already sits within 0.2 pt of centre without the lift; with it the ink is
0.9 pt high, the focus outline has 1 pt of gap above and 3 pt below, and the
digits start 1 pt above the unit. The slot is centred again.
Tests: value_field_digits_ink_is_centred_and_level_with_the_unit renders the
field (Pro and Studio) and checks the digits' ink is centred and starts level
with the "%"; value_field_number_slot_is_centred_in_its_box replaces the
1 pt-lift test, and the #2823 slim-box test now expects centring. Both new
assertions fail with the lift restored.
Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The options bar and the Properties panel's Character section both draw the
"type-font" family picker, and the Character one is drawn later. With its
menu closed it removed the shared hover key every frame, so the options bar's
hover never reached the canvas. Only the picker whose menu is open now clears
the key; follow_hover already drops it each frame once nothing writes it.
Test: options_bar_hover_previews_with_the_character_picker_also_shown runs
the whole app (Pro theme, both pickers on screen), hovers a font in the
options bar and checks the canvas shows it, then that leaving the row
restores it. It fails without the fix.
Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Two monitors without ICC profiles (a common Linux desk, e.g. DP-4 and DP-5)
showed two identical "Display profile not used" cards at every launch. A
reading now posts one notice: one reason line per display, then the advice
line naming them all. Displays added by a later reading still get a card of
their own, and a reading with nothing new stays quiet, as before.
Claude-Session: https://claude.ai/code/session_01SvxNXnS4tcg6BHT9VG67t7
Co-authored-by: Claude Opus 5.5 <[email protected]>
The separate mip render targets work around reduced-preview corruption on Intel DX12 drivers (#892, verified on Intel UHD 620), but every DX12 adapter took them. Creating a scratch texture per level on every refresh costs about 1 ms of CPU time per brush dab on an NVIDIA DX12 adapter. Gate them to Intel under DX12; PHOTOCRAFT_SEPARATE_MIPS=1/0 forces either way. Part of #4515.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Canvas: themes without a dot pattern draw no dots texture (#4542)
Switching the dark theme to Pro filled the empty workspace with red and
black blocks on the reporter's machine. The blocks repeat every 22 px,
the tile of the canvas-surround dots texture, so it is that texture
showing wrong contents.
Pro and ten other themes set canvas_dot to their canvas colour: they
have no dot pattern, yet still uploaded a texture for it and tiled it
over the whole pasteboard. dots() now returns None for them, so nothing
is uploaded or drawn. Studio, Studio Light and Solarized Dark, whose
dots differ from the canvas, are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Contributors: credit Higgs32584 by GitHub profile name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The eyedroppers (the points/subtractPoints params, and clicks on the
canvas while the dialog is open) refused points outside the active
selection. Photoshop samples anywhere and only limits the result to the
selection, which Color Range already does; the refusal is gone.
On Windows with the automatic backend, GL adapters now rank after every
other adapter, WARP included. VMware SVGA3D over GL and GL-only old GPUs
drew a black window (#1788, #1647); WARP renders.
a_system_arabic_face_renders_both_samples took the first family whose
character map claims Latin and Arabic and expected its Arabic sample to
render. A font can map those characters to empty glyphs (for example
Black Mountain), so cargo test failed on machines that have one
installed. The test now renders every candidate and checks the first one
that actually draws an Arabic sample, and skips only if none does.
effect_maps hashed the whole layer (Debug of effects and vector mask, a walk
over every pixel and mask tile, every child of a group) on every call, i.e.
once per render tile per effect layer. The identity depends on the layer
only; memoise it in the render's Ctx for layers that are part of the
document being rendered (their addresses cannot be reused during the render).
Co-authored-by: Claude Opus 5.5 <[email protected]>
source_size_probe decoded every embedded file other than PSD/TIFF/PNG/JPEG
in full just to learn its size. photocraft_codecs::probe_size reads the
size from the same decoders' headers (WebP chunks, the image crate's
GIF/BMP/TGA/ICO/QOI/HDR headers, flat EXR part headers), with the same
limit checks and the EXIF quarter-turn swap, so it agrees with decode
wherever decode succeeds. Deep EXR, AVIF and anything else still decode.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop doesn't apply the shadows, midtones and highlights curves one after
another: per channel it builds a single Levels (black point from shadows,
white point from highlights, one midtone gamma 2^((midtones + (shadows +
highlights) / 2) / 100)), and a gamma > 1 starts with slope 2^gamma at black
instead of a pure power curve. Applying the tones in sequence let a lowered
shadows and a raised highlights slider compound their gammas (up to 85/255 off
on mixed settings), and the pure power curve over-brightened the shadows of
the issue's red +100 / magenta -100 repro by up to 16/255.
Measured against Photoshop on generated 16-bit ramps and pure colours:
worst error 0.8/255 (was 85/255), the issue's repro 0.02/255 (was 16.5).
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* Record action layer selections by name and show their targets
* Use document pixel format in grouped-layer regression
* Document the named layer action UI with a synthetic capture
* PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202)
The flattened Bitmap/Indexed/Duotone path retried every composite_rgba8
error, including LimitExceeded, by allocating the refused RGBA buffer
(OOM abort on e.g. a 50000x50000 1-bit PSB). LimitExceeded now
propagates; the Multichannel fallback checks its output against the cap,
reserves fallibly, borrows the merged image, uses get for planes and
converts in TILE_SIZE bands. A failed composite now warns.
Checked usize arithmetic for alpha channel names, merged plane sizes and
the gradient map name length (overflowed on 32-bit targets).
Also: EXR part import drops a cache that never hit but cloned each
image; raw KRA tiles are borrowed; flat import detects the format once.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* PSD import: flat Background opacity check reads a band at a time (#5200)
flat_background read the whole surface into floats to see whether it is
opaque, undoing the banded conversion. Read TILE_SIZE row bands, stop at
the first transparent pixel, and drop the RGBA8 buffer before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The layers were only in document.inspect, together with history,
channels and much more, so agents looked for a layer.* command that
didn't exist.
layer.list returns the layers top-down with id, name, kind, visibility,
bounds, selected and active; groups list their children, or with
"flat": true every layer is one row with its depth and parent group.
It takes the same optional "document" index as document.inspect, is not
journaled and bad params are errors.
Stretching a 1 px row or column faded out along the stretch: bicubic and
bilinear sampling read transparent pixels past the source edge, and with a
selection the edge was the whole layer's bounds (lifted pixels kept their
colour at zero alpha). Samples past the source rect now repeat the edge and
the outline is antialiased by coverage, and the lifted pixels' bounds are the
selected pixels'.
Fixes#3773
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Smart Objects: inspect and Replace Contents show shared instances (#4075)
Placed layers that share a source have imported as one set of contents
since #2222, and Replace Contents changes every instance, but nothing
reported it: `linkGroup` is Link Layers, and `smartSource` can't tell
copies of one contents from independent copies of the same file.
document.inspect now gives each smart object `smartContents`: the
contents `id` its copies share and how many `instances` there are.
Replace Contents, Relink to File and Convert to Embedded/Linked list the
`instances` they changed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Test rendered caches after shared Smart Object replacement
---------
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Repeating a duplicate or paste of a 12 MP layer cost as much as the first time because the
GPU compositor's lower-stack cache stayed at the checkpoint of the first edit after it was
filled: an edit higher up only checked that the layers below that checkpoint were unchanged,
so every refresh re-ran all passes above it (in the #209 layered document, 87 of 110 passes
over the 12 MP damage rect; the duplicate itself takes 0.3 ms, its tiles are already shared).
Each cached chunk now remembers its own checkpoint. A render captures at the highest
checkpoint below the edit, also where it resumed from a lower cached one, so the next
refresh of that edit (its undo, redo or repetition) resumes just below it. Chunks a refresh
doesn't reach keep their lower prefix; a chunk moved by a partial capture holds only that
rectangle afterwards. Validity is unchanged: a chunk survives only while the layers below its
checkpoint are untouched.
Release perf_scenarios, layered group, Windows 11, RTX 5070 Ti, 7 interleaved runs:
P9 p50 22-29 -> 12-17 ms, P11 p50 37-41 -> 11-18 ms; P8 and P10 fall too; P1-P7, P12 unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The compositor kept every pattern texture it ever uploaded (RGBA32F,
16 bytes per pixel) until the device was lost: `forget_doc` and
`retain_docs` skipped them and no budget applied, so a long session
that opened and closed documents with pattern fills or pattern effects
only ever grew.
Each cached pattern now records the document that last painted with it
and the frame it last painted in:
- closing a document (`forget_doc`, `retain_docs`) frees its patterns;
- a pattern no frame has painted with for 240 frames is dropped;
- over PATTERN_BUDGET (256 MiB) the least recently painted go first,
never one the current frame paints with.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Offsets and counts stored in a profile reach u32::MAX, which is
usize::MAX on 32-bit targets such as wasm32. Several places added to or
multiplied them before any bounds check, so a malformed profile could
panic (overflow checks on) or read from a wrapped offset (checks off).
- curv: reject an over-long count before computing the element length.
- mAB/mBA: the matrix and CLUT read through one bounds-checked slice at
the stored offset instead of adding to the offset.
- mluc: the record offset is computed with checked arithmetic.
- XYZ: one bounds-checked slice instead of off + 4 and off + 8.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The archive limits bound what one import extracts (256 MiB an entry, 1 GiB in
total), and one pixel layer is skipped past 64 megapixels, but nothing bounded
what the layers materialized out of that cost: each bitmap's declared
`BmpW`/`BmpH` sizes its RGBA output and its decode planes, with or without tile
data, so a small document with many layers could still allocate without end.
`Limits` gains `max_pixel_bytes` (1 GiB by default, the budget the other
importers allow decoded pixels). `Reader` admits a layer before `decode`
allocates it: the layer's RGBA joins the layers already materialized, and its
working planes are counted with them, since every earlier layer is still live
while this one decodes. Past the budget the import fails with `Error::Limit`,
which `photocraft-io` reports and falls back from to the embedded preview.
Tests: a synthetic 300-byte document declaring layers with no tiles at all - one
layer inside the budget imports, the second past it is rejected before
allocation, and a declared 2048x2048 layer is rejected under a 1 MiB budget.
The fuzz targets get a small pixel budget so mutations reach the new rejection
path.
Co-authored-by: DeepSeek <[email protected]>
* Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284)
On Wayland the app opens its window through Xwayland when a pen is attached
(#639), and it took any input device whose key bitmap has BTN_TOOL_PEN for a
pen. A virtual keyboard made with uinput (RustDesk's, for one) claims every
key code, that one included, so the window went through Xwayland on machines
with no pen at all. On a fractionally scaled desktop (GNOME 46 at 125%) the
X11 window is drawn at the logical size and upscaled by the compositor, so
the UI came out blurred even though Auto UI Scale already followed the
system's 1.25 (#1237): native Wayland, where winit negotiates
wp_fractional_scale_v1 and renders at the physical 3840 px, was never used.
A pen now also needs ABS_X and ABS_Y in its absolute-axis bitmap, as
libinput asks of a tablet tool; a keyboard has no axes. Regression tests
with the virtual keyboard's own bitmaps, a pen beside it, and a pen display.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Docs: on a fractionally scaled Wayland desktop, Xwayland is blurred and native Wayland has no file drops (#5284)
Say where the trade-off is: a native Wayland window is sharp at 125%,
150% and 175% but takes no dropped files (#386); an Xwayland window takes
drops and pen input (#639) but is enlarged by the compositor. List the ways
to open through Xwayland and what counts as a pen.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
* feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5060)
The Elliptical and Single Row/Column Marquee are options of select.rect
("ellipse":true, "line"), but the command-list filters only looked at
the id and the label ("Rectangular Selection"), so 'ellipse' and
'marquee' found nothing.
Commands can now carry search keywords. command.list / engine.commands
rows include them as "keywords", and the three filters (CLI commands
--filter, the engine.commands RPC and the MCP command_list) share one
matcher over id, label and keywords. select.rect gets the Photoshop
marquee names as keywords.
* Command list replies: document the optional keywords key
The reply-shape test from #3684 pins the exact keys of each row; select.rect
now also carries keywords, so the test and docs/control-protocol.md allow
that one optional string.
* Map .atn duplicate-into-new-document (Mk) to layer.duplicate
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.
* Fix Rust formatting in ATN command tests
* Fix rustfmt formatting in ATN command tests
Photoshop's Gradient Map picks each pixel's place on the gradient by
0.3 R + 0.59 G + 0.11 B, the luminosity of the blend modes. We used the
Rec. 601 grey (0.299, 0.587, 0.114). That is a few thousandths off, but
it is enough to land two levels away on steep stretches of a gradient.
The photoshop corpus's rgb8 and rgb16 gradient-map.psd now pass (they
were 2 levels off on 0.8-1.7% of pixels). Sampling the gradient through
a 256- or 1024-entry table didn't help; the weights did.
CPU (compose::adjust) and GPU (the shader's lum) both change. Lab
documents keep their Gradient Map driven by L, and grey pixels map as
before.
Photoshop oracles 145 -> 147 of 258; floor 145 -> 147.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Since ed7df56a, list_recovery also lists a committed bundle whose sidecar
is not yet published (revision 0). The worker commits the manifest before
it writes the sidecar, so the retry test could catch the bundle between
the two and read revision 0 (seen on windows-latest; 215/300 local runs
on Linux under load). Wait until the entry carries the expected revision.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Discover committed autosave bundles whose JSON sidecar was never published. Rebuild recovery metadata from the readable manifest and retain these bundles during orphan cleanup.
Fixes#4967
* Tests: bad-layer-id checks use an id that can't exist (#3343)
Layer ids come from one process-wide counter (shared with document and
smart-object ids, and bumped by ensure_ids_above on file loads). A full
`cargo test` run passes ids like 9999, so a layer a test just created can
get the "bad" id, the command succeeds and the assertion fails at random
(seen on CI in type_extra_cmds::tests::vertical_orientation_renders_columns_inside_the_canvas).
Use u64::MAX, which the counter never reaches, for every "no such layer"
sentinel in the engine and ui-egui tests. Test-only change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Tests: use u64::MAX for bad-layer-id sentinels added on main since (#3343)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
fit() computed (y + 1) * h in u32, which passes u32::MAX for a plane
over 2^32 / max rows; the ABR and pattern parsers' edge caps keep real
inputs below that, but the guard lived in another crate. Spans are now
computed in u64. gradient_bake::eval indexed stops[0] and stops[i + 1]
relying on bake's two-stop check; it now handles an empty or lone stop
itself. Same results for every input that worked before.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The merged image carries its transparency in the first extra channel
when the layer count is stored negative, or when a "Saving Merged
Transparency" global block (Mtrn, Mt16, Mt32) is present. Photoshop
saves 32-bit documents the second way: a positive layer count, Mt32,
and a fourth channel named "Transparency" (psd-tools transparentbg).
merged_has_alpha only knew the first, so opening such a file added a
"Transparency" alpha channel to the document, and the oracle compared
it as if it were opaque white (83.7 % of pixels off; now 0.9 %, all on
the shape's edge).
Co-authored-by: Claude Opus 5.5 <[email protected]>
A mask made from the selection takes the selection with it in Photoshop: after
Layer > Layer Mask > Reveal Selection (the Layers panel's mask button with a
selection) the marching ants are gone. PhotoCraft kept the selection, so the
usual next step, Invert (Cmd/Ctrl+I) on the new mask, inverted only the part
inside the old selection and left the whole mask black, instead of flipping
the mask. Hide Selection (the Alt-click variant) already dropped it.
Reveal Selection now clears the selection in the same history step as the
mask: one undo brings the selection back and removes the mask.
Tests: adjust_cmds::tests::reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask
(8/16/32-bit). Tests that deselected right after Reveal Selection no longer
need to (Deselect is disabled without a selection); layer_mask_depth and the
automation agent tasks now expect the selection to be gone.
Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Layer Knocks Out Drop Shadow only knocked the shadow out through see-through fill: at
100 % fill the layer was taken to cover it. That holds in Normal mode, but a layer in
Exclusion, Difference, Multiply, Screen and the rest blends with what's beneath it,
shadow and all: a white Exclusion bar with a drop shadow over black type came out as a
blank white bar instead of white type on black (Photoshop's merged image of the same
file shows the shadow kept out from under the bar), and a Multiply layer was darkened by
its own shadow. shadow_see_through() now knocks it out wholly in those modes, for the CPU
compositor and the GPU plan alike.
Co-authored-by: Daniel van Lith <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Session::execute catches a command's panic and returns an ordinary Err (the
last-resort guard in jobs.rs), so the unwind never reached panic_hunt's own
catch_unwind and a panicking command counted as a pass.
The harness now installs a panic hook that counts every panic in the process,
including on rayon workers, and a run fails if the count moved. Runs are
serialised so a panic is charged to the run that caused it. No production
code changes.
Test: contained_panics_count_and_graceful_errors_do_not. A contained panic
fails the run and a graceful Err passes; the first assertion fails without
the hook. The full --ignored fuzz stays green, so no command panics today.
Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* test(engine): add Color Range fuzziness 0 exact-match test (#214)
Adds an explicit test for Sampled Colors at Fuzziness 0 (point sampling
and hex colors) asserting that exact matches are fully selected and
adjacent shades are rejected. Updates scorecard TOOL-214-3 to done.
* docs: regenerate scorecard (TOOL-214-3 done)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017WeVmjnnPPKdxVTpr2Vjtw
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Opacity and Fill are fractions in layer.setProps but percentages in the
Layer Style commands, and a value above 1 was clamped silently: 55 gave
an opaque layer. Out-of-range or non-numeric values now fail with an
error that names the range, and the layer is left alone.
select.object samples the active layer by default. On an empty layer
(scripts often add one above the photo) it found nothing and returned
without an error; the failure only surfaced at the next command that
needed a selection. It now fails with an error that names the cause and
the sampleAllLayers option, also when the layer's pixels lie outside the
rectangle or point.
Fibers renders from the foreground and background colours without
reading the layer, but its output area was the layer's content grown by
a zero halo: on an empty layer that is empty, so nothing was drawn (and
a following transform found nothing to transform). On a partly filled
layer only the content's bounding box was filled. It now writes the
reference bounds (the selection's, else the canvas) like the global
filters, without reading the whole layer as they do.
The Background has none of them. layer.setProps applied them to it as it
was, and Select and Mask's Layer Mask output gave it a mask. Like
Photoshop (and like PhotoCraft already does for moving, transforming,
erasing, styles and Layer > Layer Mask), it now becomes a normal layer,
"Layer 0", first. Values that leave its look alone (opacity and fill
100 %, Normal) keep it the Background.
Fixes#4836
* Reject clipboard origins that cannot be moved into a new document.
* Fill Clip.resolution in the unrepresentable-origin clipboard test.
main added resolution to Clip; the synthetic overflow fixture omitted it and failed engine lib tests.
* Format the clipboard origin test (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Reds/Yellows/Greens/Cyans/Blues/Magentas selected hue closeness times
chroma. Measured against Photoshop 2025 on 126 colours per preset
(Reds, Yellows, Blues; hue +-40 degrees, saturation 5..100 %,
brightness 33..100 %), Photoshop also multiplies by (2 - max): bright
colours were already exact, darker ones came out up to 61 levels short.
With the factor every sample matches within one level. The muted navy
#3d3f54 from the issue now gets 35/255, as in Photoshop (it was 21):
Photoshop also leaves it below 50 %.
hRes and vRes are stored as pixels per inch. The unit that follows only
controls how that number is shown, so a 300 ppi file with unit cm opened
at 762 ppi. Export already writes the document ppi and does not divide
by 2.54.
Fixes#5201
Co-authored-by: Cursor Agent <[email protected]>
- ROADMAP.md: the oracle floor is 145 of 258 (crates/io/tests/corpus.rs,
gaps.md G1), not 133 of 256; 953 open issues, not 579.
- docs/architecture.md: ~539,000 lines in ~1,310 files and ~8,040
#[test] functions, recounted 2026-10-13.
- README.md: "a layer kind filter" instead of "layer filters"; only the
Kind filter exists.
- AGENTS.md: drop an empty bullet at the end of section 7.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Testers report ~50 ms input-to-pixel while painting on Linux and macOS, and asked for a
per-stage breakdown. With PHOTOCRAFT_STROKE_TRACE=1 the app prints, per stroke, p50/p95 of
the frame interval, pointer moves per frame, the live stroke's render, the canvas refresh
(composite + upload), the UI build, eframe's CPU time (UI, tessellation, submission) and the
remaining vsync/swap-chain wait, with the GPU backend; =<file.csv> also appends every frame.
The last summary is in the control channel's perf.strokeTrace.
RTX 5090, Vulkan, 120 Hz: interval 8.33, cpu 2.2 (ui 0.9, stroke 0.8, canvas 0.4), wait 6.0 ms.
* fix(compose): a wide stroke effect keeps a round outer edge (#3646)
A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a
245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact
Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer
edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is
fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px
of a circle (was 2.5 px).
Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance
(a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764).
Full photocraft-compose suite: 206 pass; fmt and clippy clean.
* fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646)
1080008a fixed the CPU compositor's stroke outer edge to use the exact
Euclidean distance instead of the 5x5 chamfer, whose level sets are
polygons and facet a wide stroke. The GPU compositor builds the outer
stroke field from FieldKind::StrokeOutside, which still maps to the
chamfer, and the canvas is drawn on the GPU, so the fix did not reach the
app.
Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by
the stroke. The spread of drop shadows and softer outer glows keeps the
chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its
meaning. StrokeOutsideVector is exact too, matching the CPU vector outline
path.
Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field
(stroke uses the exact field, shadow spread keeps the chamfer), and the
banded/whole field test covers the new kind.
* fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646)
The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but
regressed Photoshop's own composites against the PSD flatten oracle: the
fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large
outside width do its octagon facets show. Pick the metric by the stroke's
outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and
its sub-pixel edge seeding, at or above it use the exact Euclidean transform.
The measured chamfer deviation from a point source is 0.027 · r px — 0.48 px at
r = 18, the widest outside stroke any Photoshop oracle matches with the chamfer,
and 1.6 px at r = 60, where the boundary visibly facets. Half a pixel is reached
at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer.
The CPU and GPU compositors pick the same field by the same width
(`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a
narrow shape-layer outline, while the shadow / glow spread keeps its chamfer.
io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back
to main's counts with no file newly differing; a 60 px outside stroke stays
round (`a_wide_outside_stroke_is_round_not_a_polygon`).
A renderer's data window is its crop of the frame; the composition lives
on the whole display window (our MY27 renders: data 1782x1267 at
(1109,1100) inside a 4000x3000 stage). decode_deep now validates the
display window against the limits and reports it as stage + origin on
DeepImage; flatten pads the composed region to the stage at its offset
(transparent elsewhere) and tags the image with a DisplayWindowStage
warning. Files without a usable display window open as before.
Includes a cropped-deep generator and a stage/offset oracle test.
* fix(gpu): contain errors in unwatched compositor work
* test(gpu): gate recovery fixtures on format capabilities
---------
Co-authored-by: haranishi <[email protected]>
A flat TIFF dropped EXIF on export ("EXIF not supported") and ignored it
on import. TIFF is EXIF's own container: IFD0 points to the Exif (34665)
and GPS (34853) directories and holds the descriptive tags.
- Export: the EXIF block's Exif and GPS directories are written as
directories outside the page chain, IFD0 points to them, and IFD0's
descriptive tags (ImageDescription, Make, Model, Software, DateTime,
Artist, Copyright) are added unless the text metadata already wrote
them. Values are copied as bytes, reordered to the file's byte order,
so unknown tags survive.
- Import: a classic TIFF with an Exif or GPS directory gets an EXIF
block built from them and IFD0's descriptive tags.
- TIFF's format caps now include EXIF. Layered TIFFs keep EXIF as
resource 1058 as before (read back unchanged); the layered importer
already preferred the TIFF's own EXIF when there is one.
exiftool and Photoshop 2025 read the Exif and GPS tags of such a TIFF.
Fixes#4875
Drag out of the rulers' upper-left corner to move the zero point: cross hairs mark where it
lands, Shift snaps it to the ruler ticks and View > Snap To targets apply otherwise; dropping it
back on the corner cancels. Double-clicking the corner puts it back at the image's top-left.
The rulers then read 0 at the zero point (negative left of and above it) and the Info panel's
X/Y measure from it.
The zero point is a document field (`ruler_origin`, document pixels), saved in .pcraft with
`#[serde(default)]` so older files open at the top-left. It is set by two undoable engine
commands, `view.setRulerOrigin {x, y}` and `view.resetRulerOrigin`, so agents can drive it.
It is not written to PSD: the public PSD spec documents no resource for it.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The compressed RW2 layouts of earlier Lumix bodies (e.g. the DMC-GX7)
decode at full sensor resolution instead of falling back to the camera's
embedded JPEG preview. The layout was established by observation of
sample files (fixed 16 bytes per 14 pixels, predictor groups with
per-parity predictors, page-permuted downward bit order); the black
level of these files reads 15 counts low.
Adds round-trip tests through a mirrored synthetic encoder, cursor and
predictor-group unit vectors, and updates the support matrix.
Co-authored-by: Gotya-OFFCIAL <[email protected]>
Saving a 36 MP document spent most of its time packing the merged image's
RLE rows one after another on one thread (packbits over ~144 MB), and
importing it decoded each layer's channels sequentially. The merged-image
row packer now runs in parallel chunks that concatenate in order (rows are
independent; errors surface like the sequential path's), the layer gather
runs in row bands on all cores before the per-channel RLE, the layer's
channels decode side by side, and the full-surface clone per layer is
gone.
Measured on a 6000×4000 8-bit document with a photo, a paint layer and a
Levels adjustment: save 2.86 s -> 2.55-2.64 s, open's import stage
1.12 s -> 1.07 s (psd_to_document 745 -> 564 ms). The remaining save time
is dominated by the merged composite render itself (~0.7 s).
* Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur
`edit.autoBlendLayers` with `method: stack` and `depthMap: true` adds a hidden gray Depth layer, the layer each pixel is sharpest in (black = the bottom layer, white = the top), from `photocraft_algo::dff`: the ring difference filter as focus measure, guided-filter aggregation with the merged picture as guide, a streaming peak search with Gaussian interpolation and a confidence, edge-aware weighted least squares solved by multigrid-preconditioned conjugate gradients, and guided upsampling. Once on a layer mask it drives Lens Blur's depth map.
Closes#2067.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Depth from focus: a guide or plane of the wrong length is the empty map
Review follow-up on #2073: `depth_from_focus` is public and indexed
`guide[i]` without checking that `guide` and every `luma` plane are
`w × h` long. It now returns the empty map (depth 0, confidence 0) for a
mismatch, before anything is read, and the test covers a short guide, a
short plane and a size that matches neither.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
* i18n(de): translate the labels that were still English
A walk through the German UI with a lookup recorder (every menu item, every
tool's options bar, every dialog it opens) found 344 texts that fell through
to English. They were invisible to the existing i18n test, which only scans
tl!("literal") calls: the labels of the schema-driven filter dialogs are built
from the engine's parameter names ("centerX" -> "Center X"), and status and
system-info templates are looked up at run time.
- de.tsv: 344 new entries. Filter, Render (Flame, Picture Frame, Tree, Lighting
Effects), Blur Gallery, Lens Correction, Color Lookup, Shadows/Highlights,
Apply Image / Calculations, Layer Style blending options, Find and Replace,
Document Info, the keyboard shortcut editor's commands, system info and the
tool names that were missing (Color Sampler, Vertical Type, Patch).
Written from the plain meaning of each label, in the vocabulary the catalog
already uses (see its header: no proprietary localisation resource consulted).
- "Light" was translated as "Light": the appearance mode in Preferences and the
toolbar tooltip read "Light" next to "Dunkel". It is "Hell" now (Camera Raw's
Light section and the font weight have their own contexts).
- Export As: the dialog title was built with format!() and could not be
translated; it goes through the catalog now.
- Names such as "iPhone 14 Pro Max" (the artboard dialog's preset choices) were
split like camelCase keys and read "I Phone 14 Pro Max"; a name with a
space is shown as it is. Test: display_names_are_not_split_like_camel_case_keys.
Left English on purpose: product and file-format names, units, document and
layer names the engine creates ("Untitled", "Background": other code compares
the latter), brush and swatch preset names (data), and words that are the same
in German (Filter, Info, Zoom, Median...).
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* i18n(de): drop the process note and give the new sections English headings
The first heading described how the entries were found (and when), which is not
catalog content, and the other headings were German while the rest of the file
uses short English section comments. Only comment lines change.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* i18n(de): US spelling in a section heading
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
* Blur Gallery: Field Blur pins on the image (#3373)
Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.
The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(vi): translate the Field Blur pin strings
main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* Blur Gallery: Alt+Cancel Reset starts again from one pin
main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Field Blur pin strings
main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(sr): translate the Field Blur pin strings
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Profile prompts on open: one file at a time, before it opens, like Photoshop
With Ask When Opening on, files opened together put up every Embedded
Profile Mismatch prompt at once, each answer went to the active document
rather than its own file, and no prompt named its file. Checked in
Photoshop 27.11: one prompt at a time, naming the file, before that file
opens; OK opens it and asks about the next; Cancel opens neither it nor the
files still to come.
A file that asks is now held back until answered: the engine's background
open parks the decoded document (ColorState::open_question,
Session::take_held_open) and the shell queues it (open_prompt). The answer
is applied to that document (ColorState::resolve_open) before it opens.
Files that finish reading meanwhile wait their turn. A developed raw has no
embedded profile, so it doesn't ask.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Profile prompts on open: the prompt's text is localized in every catalog
The two messages are templates now, one line each so the catalog check covers
them: the sentence naming the file and the mode, "Embedded: {embedded}" and
"Working: {working}". The mode reads as the catalogs name it (French "RVB"),
and the file's name goes in last, so braces in it are never taken for a
placeholder. Rows added to all 17 catalogs; main's two "colour profile"
templates (Czech and Italian only) are removed, nothing uses them.
The Camera Raw control test from main expected a mismatch prompt when a raw
opens. With this change a developed raw is not asked about (it has no embedded
profile), so the test now checks that no prompt stands before Camera Raw.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Profile prompts: rows for the hu, sr and tr catalogs; tests hold for any read order
The prompt's four templates join the Hungarian, Serbian and Turkish catalogs
that landed on main.
Files read in the background ask in the order their reads finish, which is the
order given unless a later read overtakes an earlier one. Three tests assumed
the order given and one failed once under a loaded test run; they now follow
whichever file the prompt names (and still require the order given for files
read on the spot).
New test: a file held for its prompt still reports type in a font that isn't
installed, once.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Select Subject ran every graph cut (the GrabCut iterations and the
full-resolution band re-cut in `refine_band`, tile by tile on rayon)
through the non-cancellable `grid_cut`, so the Remove Background job only
saw Cancel/Esc before and after the whole "Finding subject" step.
Add `select_subject_with`, `grabcut_with` and `finish_region_with`, which
take an `Interrupt` and return `Err(Cancelled)`: each band tile checks it
before reading pixels and its cut runs through `grid_cut_cancellable`, so
in-flight tiles stop at the max-flow's next poll and unstarted ones never
start. Remove Background runs the step under `JobCtx::stage`. The old
functions keep their signatures and results (they pass `Interrupt::NONE`),
so `select.subject`, Object Selection and the layer menu are unchanged.
Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928)
In 16-bit and 32-bit PSD files saved by Photoshop, the Layer Info section
either contains 0 layers or 8-bit compatibility layers, while the actual
16-bit/32-bit layers are stored in the global Lr16/Lr32 tagged blocks.
Previously, candidate_seen was initialized to layer_info.is_some(), which
caused PsdFile::from_bytes to skip the Lr16/Lr32 block whenever the section
was non-empty. This left the document with empty layer lists or 8-bit records
that failed channel decoding at depth 16/32, causing all layers to be empty
and invisible.
Prioritize the depth-specific tagged block (Lr16 for 16-bit, Lr32 for 32-bit)
over the compatibility section.
* test(psd): improve 16-bit multi-layer compatibility regression tests
* PSD: Backgrounds survive a round trip through PhotoCraft
Photoshop opens a PSD's bottom layer as its Background when that layer's
record has no transparency (-1) channel. PhotoCraft always wrote one, so
every file with a Background came back from PhotoCraft with an ordinary
"Layer 0"-style bottom layer instead. Measured by opening PhotoCraft's
re-exports of the 427 corpus files in Photoshop 27.11: 317 lost their
Background this way.
- Export: the bottom top-level layer is written without the -1 channel
when it is a locked (transparency and position), unclipped, unmasked
raster that is opaque over the whole canvas at full opacity and Normal,
as a Background always is; flags as Photoshop writes them (bit 4 marked
meaningful, and "pixel data irrelevant" on adjustment layers).
- Import: like Photoshop, a bottom raster layer whose record has no -1
channel becomes the Background (locked, named "Background") whatever
the file names it: psd-tools' "Фон" and ag-psd's "bg" or "Layer 1" open
as Backgrounds in Photoshop.
Of the 227 corpus files whose bottom layer is a Background, PhotoCraft's
export now writes all 227 the way the original does (it was 0), and no
other bottom layer turns into one. Corpus oracles and round trips are
unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258 on this
commit; every round trip renders the same).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* PSD: a flat Bitmap / Indexed file's image opens as the locked Background
Probe 7 (PhotoCraft's re-exports opened in Photoshop 27.11): with the
Background fix, 314 of 318 Backgrounds survived; the other four were
psd-tools' flat 1-bit Bitmap and Indexed files. Photoshop opens a flat
file's image as its Background, but PhotoCraft's import of these modes
left the "Background" layer unlocked, so the export wrote it as an
ordinary layer. An opaque flat image now opens locked (as flat PNG/JPEG
and layered-but-flattened PSDs already did) and saves as a Background.
Corpus unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258;
every round trip renders the same).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
When the wgpu compositor can't draw a refresh (layers over its memory
budget, or a feature it doesn't cover), the CPU compositor redrew the
whole document inside the draw call: minutes of Not Responding on a
multi-gigabyte PSB. With background jobs, a refresh of 4 MP or more is
now composited by a worker in 1024 px blocks, the visible ones first;
each frame uploads the finished blocks and the canvas keeps its previous
contents until they land. An edit while it runs draws its own damage and
restarts the worker on the edited document with the blocks still to
draw. The status bar shows Redrawing N%. Web and tests keep the
synchronous path.
* Brush: remember the last selected brush across restarts (split from #2516)
The persistence half of #2516 on its own, so it can merge and be maintained
apart from the brush-outline half. The tool's brush (settings, tip
references, and the name of the preset it was picked from) is saved once the
pointer is up and restored at launch: options bar, painting cursor and
Brushes panel highlight all start where the session left off.
Review fix from #2516 -- no per-frame work:
- `ToolState::brush_rev` is bumped by every brush/preset setter
(`tools.setBrush`, preset save/delete/rename/setCurrent/deleteGroup,
`brush.defineFromSelection`, `brush.presets.importAbr`, the store's tip
re-linking, and the shell's tool-switch/reset paths); `remember_brush`
serialises only when it moved since `brush_saved_rev`, so an unchanged
brush costs a counter compare per frame, not a clone and a JSON dump of
the whole brush (sampled tip previews included).
- Restoring counts as saved: the launch value is never rewritten.
Bitmaps stay out of the preferences document: remembered tips and patterns
are references (a loaded stored tile drops its full bitmap), and a sampled
bitmap too big to embed is skipped, keeping the previous value so a preview
never enters the prefs file. A malformed remembered brush falls back to the
default.
docs/scorecard.md regenerated with `cargo xtask scorecard` (34 of 154).
Tests: engine 1267 lib (5 new: revision gating, rejected changes, picked
preset, restore round trip, no embedded bitmaps), ui-egui 1982 lib; clippy,
layers, wasm green.
* rustfmt the StoredTile line and regenerate the scorecard after the main merge
* engine tests: destructive Levels keep the signed float overshoot at 32 bits (#2592)
* Scorecard: regenerate after merging upstream/main
* Export As: compare batch paths without the Windows separator
* font fix ?
* fix(ui): only Filter menu dialogs get the in-dialog preview window
#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* cargo fmt --check max_width = 160 fix
---------
Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Clicking a History panel state ran Edit > Undo / Redo once per state. Each
step bumped the document revision, so the canvas could not tell what the
jump changed and recomposited the whole document (re-uploading every
changed layer on the GPU path).
history.selectState {state} returns the document to a state (numbered as
the panel lists them, redo states included) in one command: the history
moves by pointer swaps, the document becomes current once, with one
revision and the damage rect between start and target, and the targeted
layers restored as Undo restores them. The panel runs it once per click;
an open Free Transform refuses it (panel guard kept, and the transform
intercept greys and refuses it for every caller). A floating piece goes
back as with Undo instead of being dropped as a new step.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The Duplicate Layer dialog gains Photoshop's Document: New destination
with a document-Name field (As stays the copy's name, multi-select keeps
names, single group gets the Duplicate Group title). layer.duplicate
takes document index-or-new plus optional documentName, mirroring
channel.duplicate: the new document matches the source canvas, mode,
depth, resolution and profile, holds just the selected layers at their
positions, and becomes active. Smart-object contents are remapped within
the batch so copies are independent of the source. The dialog is fixed to
its content width so no dead strip remains.
* fix(text): split hyphenless PostScript style suffixes
A PostScript name without a hyphen kept its style in the guessed family:
`GmarketSansBold` became the family "Gmarket Sans Bold" at weight 400.
The guess now drops a trailing capitalised style word (Gmarket Sans, 700).
A lowercase ending ("Daylight") is not a style, and a style word after the
vendor tag MT stays in the legacy family ("Arial Rounded MT Bold").
Hyphenated names keep the last-hyphen parsing from #3878.
PSD import stores this guess when the exact face is absent. A legacy family
that ends in a style word without MT before it ("CooperBlack") is stored as
the shorter family (Cooper, 900) until the face is installed.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(type): missing-font checks follow layout's PostScript resolution
When a run's stored family is not installed, layout draws it with its exact
PostScript face, or with the family guessed from that name if installed.
Missing-font detection compared only the stored family label, so such a run
was reported missing, and Replace Missing Fonts rewrote it whenever an
unresolved run shared the label. missing_fonts, missing_fonts_in_layer and
the replacement now use layout's decision.
#1406 already corrects the label when a PSD or Affinity file is opened with
the face installed, and #1844 finds exact faces outside the family prefix.
The label still stays on a run reopened from .pcraft after its font was
installed, on fonts that arrive after opening, and on a run created with
both "font" and "postscriptName". The tests take the .pcraft and command
paths.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: kijoong <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Edit > Puppet Warp warped only a layer's pixels and left a linked mask
where it was, so the mask no longer lined up with the content. Free
Transform, Warp and Perspective Warp (#3083) already move a linked mask.
`puppet_warp_with_mask` solves the mesh once and moves the mask on the
same mesh: the mask under the mesh is lifted, deformed like the pixels
and laid over the rest; the area it leaves takes the mask's default and
the mask away from the mesh keeps its values. With a selection the mesh
covers only the lifted pixels, so only that part of the mask moves. An
unlinked mask stays put.
Tests: the moved stripe at 8/16/32-bit, undo, an unlinked mask, a
selection, and a hide-all mask in the algo crate. The engine tests fail
before the fix.
Claude-Session: https://claude.ai/code/session_01NPyyabA5efedMqVWMu3fiq
Co-authored-by: Claude Opus 5.5 <[email protected]>
* perf(display): encode linear documents for the screen in parallel
CanvasDisplay::texture_buffer (every CPU-path canvas upload of a linear
document, which every 32-bit document is since #3792) and to_rgba8's sRGB
encode ran one pixel at a time on one thread. Both now split the pixels into
16K-pixel pieces across rayon off wasm; results are bit-identical.
24 MP linear document, median of 5 (22-thread laptop, dev profile):
- texture_buffer: 348.5 ms -> 29.6 ms
- to_rgba8: 107.8 ms -> 10.6 ms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
* perf(compose): quantize Buffer::to_rgba8 in parallel
The plain 8-bit quantize behind the CPU canvas, previews, thumbnails and
8-bit exports ran on one thread. Split it into 16K-pixel pieces across
rayon off wasm; results are bit-identical.
24 MP buffer, median of 5 (22-thread laptop, dev profile): 122.3 ms -> 11.9 ms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
* perf(canvas): build canvas images in parallel, fused with the quantize
display_image (CPU canvas updates, filter previews, the navigator, the crop
pasteboard) quantized to RGBA8 and then ran egui's single-threaded
ColorImage::from_rgba_unmultiplied over every pixel. The identity path now
quantizes straight to Color32 in one parallel pass; the colour-managed path
converts in parallel. Results are identical to egui's conversion.
24 MP buffer, median of 5 (22-thread laptop, dev profile): 50.9 ms -> 12.7 ms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Replace Color: Localized Color Clusters (points, localized, range)
image.adjustments.replaceColor takes the Localized Color Clusters of
Select › Color Range: "points" (where the colour was picked),
"localized" and "range" (% of the canvas's longer side). With clusters
on, the colour only matches near the points, through the same
color_range_samples as Color Range, so the same colour elsewhere in the
photo (a near-white background next to a cream garment) is left alone.
Without "localized" nothing changes. localized without points, and
malformed points, are refused.
The dialog part follows once the canvas eyedropper of #4247 is in.
Refs #4457
* Replace Color dialog: Localized Color Clusters and Range
The dialog gets Photoshop's Localized Color Clusters checkbox and its
Range slider (greyed while the box is off), and the canvas eyedropper
(#4247) now also records where the colour was picked (points), so the
localized match centres on that spot. The command's params list
localized, range and points as real keys in Photoshop's order.
Refs #4457
polygon_selection rasterised every lasso over the whole canvas (a
w×h f32 mask) and combined it over the whole canvas, whatever the
polygon's size.
Without feather it now rasterises the full-width band of rows the
polygon spans and combines over that band; Add and Subtract keep the
old selection outside it, Replace and Intersect leave it empty there,
exactly as the whole-canvas combine does. Full-width rows keep
`polygon`'s coverage bit-identical (its x arithmetic is relative to the
area's left edge, its y samples whole rows plus the same fractions);
a feathered lasso keeps the whole-canvas path, whose blur reaches past
the polygon. A test compares 24 random polygons, partly off the canvas,
in every mode, with and without anti-aliasing, against the old path.
Measured, a 2000-point lasso of a 400 px circle on 5472×3648: Replace
15–16 ms → 5 ms; Add to a selection unchanged (about 30 ms).
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Docs: document the engine.commands reply shape (#3684)
engine.commands, the engine command command.list and the MCP tool
command_list reply with a bare JSON array of command objects, which the
docs never said. Document the shape (keys, types, an example element,
how filter/enabled_only narrow it) in control-protocol.md and mcp.md,
state it in the MCP tool description, and pin it with an rpc test so
the docs cannot drift. The shape itself is unchanged, so existing
agents and scripts keep working.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Command list docs/test: include main's disabledReason and needsDocument keys
Main added `disabledReason` and `needsDocument` to every command list row
(#3664), so the documented shape and the pinning test now list all eight keys.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Like Photoshop, Cmd/Ctrl-clicking the Layers panel's New Layer button adds the
layer below the selected layer instead of above it. The Background stays at the
bottom: below it, the layer lands above it.
swatches.import named each imported group with unique_group, which
compares a candidate name with the library's groups one by one and
tries "Name", "Name 1", "Name 2" and so on until one is free. Groups
without a name all take the import's group name, so a file of n
unnamed groups cost about n^3/6 string comparisons. In a test build,
importing a file of 9,994 unnamed groups took about 4.5 minutes.
The import now builds the set of the library's group names once and
keeps the next number to try for each repeated name, as it already
does for swatch names. Group numbers still start at 1, and groups get
the same names as before.
unnamed_groups_are_numbered_in_linear_time imports such a file on a
thread of its own and fails when the import takes more than 20
seconds. repeated_group_names_take_the_first_free_number checks the
names when a group in the file has a name that the import already gave
to an earlier group.
PSD import keeps every placed layer as SmartSource::Linked { path:
<Idnt uuid> } so duplicates share the file and export stays
byte-stable, and info reported that as a link whose path is the uuid.
When the uuid has an embedded file (a liFD item in the preserved
lnk2/lnk3/lnkD blocks), the document's info now says
{"kind": "embedded", "fileName", "uuid"}; the new
linked::embedded_file_name finds it without copying the file.
Fixes#4842
A pattern fill or layer style copies its pattern into the document, but
the tile visitor behind History's byte budget skipped Document::patterns.
The pattern's pixels were left out of the budget, of trimming and of
Edit > Purge > Histories, including after an undo left them only to the
redo states.
Related to #4972.
Co-authored-by: Claude Opus 5.5 <[email protected]>
eframe applies `NativeOptions::centered` after restoring the saved window
geometry, so the window moved back to the centre of the primary monitor on
every launch. Centre only when ui.ron holds no saved position.
Claude-Session: https://claude.ai/code/session_01KrteAUDjpXgQZJ7659DUro
Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Export Contents wrote the smart object's file as it was whatever the
path, so out.png held .pcraft or .psb data. With the contents' own
extension (or none) it still copies the file; with another one it opens
the contents and exports them in that format, as File > Export does
("converted": true, with the export's warnings). jpg/jpeg and tif/tiff
count as the same; an unknown extension is an error.
Fixes#4846
A stroke clones its brush settings on every pointer event
(StrokeRenderer::tail_preview clones the dab generator and the context,
each holding a BrushSettings), and a sampled tip's GrayTile carried its
samples in a Vec<u16>. With a large sampled tip, common in imported ABR
brushes, every pointer event copied the tip: measured 1.1 ms per event
for a 1500² tip and 3.8 ms for 2500², at 120+ events a second.
GrayTile's samples are now an Arc<[u16]>: a clone copies a pointer.
Nothing mutates a tile's samples after building it, so only
construction changes (`.into()`), and equality, serde and every read
are unchanged. Measured the same way: under 0.01 ms per event at any
tip size. Textures and patterns stored as GrayTile share alike.
Co-authored-by: Claude Opus 5.5 <[email protected]>
- Preserve existing layer mask metadata (density, feather, linked, enabled) during select.refineEdge when outputting to layerMask.
- Honor explicit mask targeting (target: "mask") in refine_edge when both selection and mask exist.
- Add tests for mask metadata preservation.
document.inspect dumped an adjustment's internal model (enum name as
key, snake_case fields, 0..1 values), which no adjustment command takes.
It is now {"kind": "levels", "params": ...}: the kind and parameters
from adjust_params::to_params, which from_params reads back.
Fixes#4865
* Liquify: simplify the brush-resize gesture condition
Same logic; clears clippy's nonminimal_bool on 1.96.
* Channels: keep the cached composite across view-only changes
Channel eye toggles bump the revision the cache was keyed on, so it was always dropped. The test only passed when the freed buffer came back at the same address, and failed under load.
A point outside 0..255 was moved onto the edge without a word, while the
error for bad points says they must be in 0..255; Photoshop refuses
them too. The error also named the command twice-quoted (`adjustment
`curves``); it is now `curves adjustment`.
Fixes#4867
When the browser denies IndexedDB from the start (a sandboxed iframe without
allow-same-origin, blocked site data), the banner "Brush preset changes are not
saved: SecurityError … Browser storage is unavailable" stays above the app for
the whole session, before any preset was touched and with nothing to dismiss.
Show it only while a preset change is unsaved. Nothing is lost before the first
change; that change brings the same banner with the reason. Write errors and
rejected records keep showing it, as both leave changes unsaved.
Co-authored-by: kuvshin888 <[email protected]>
* Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532)
Replace the ~300 px theme preview cards with Color Theme swatches (a light
and a dark row, since we follow the system appearance) and group the page
like Photoshop: Appearance (mode, themes, canvas colour and border),
Presentation (UI language, font size, scaling) and Options (checkboxes).
At 1000×750 the language now shows without scrolling, in Photoshop's
place rather than moved to the top.
The swatch rows come from LIGHT_THEME_OPTIONS / DARK_THEME_OPTIONS, so
Breeze Light and Breeze Dark show up. Each swatch keeps the favourite star
(#2445) as a badge on its corner, shown on hover and kept for a favourite,
with the same "Add to favorites: <theme>" / "Remove from favorites: <theme>"
labels. Preferences search filters the grouped page: rows that don't match
are hidden and a group left empty is skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Preferences: rustfmt the grouped Interface page
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Tests: 32-bit destructive Levels keep the HDR range (main is red since #2592)
#2592 made float Levels keep values outside 0..1 and updated compose's
levels_dont_clip_in_32_bit, but the engine-side copy still expected 0 and 255
at 32 bits, so test (ubuntu/windows) and corpus tests fail on main.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 1ebaf7612e)
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Ctrl/Cmd-pressing an anchor, a shown handle or a segment of the Pen (or
Curvature Pen) path in progress now edits it like Direct Selection does,
and drawing carries on from its last anchor once Ctrl/Cmd is released,
as in Photoshop. A Ctrl/Cmd-press anywhere else still ends the path.
The path in progress is a new Direct Selection candidate, PathRef::Pen,
built from ui.pen; its edits apply to ui.pen directly (no command, no
history step) since the path isn't in the document until it's finished.
The Add/Delete Anchor Point tools handle it the same way.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Give Image Statistics a source-file dialog and stack mode selector
* Keep stack sources in vertical rows and translate the mode label
* Place the new label alongside stack-mode translations
---------
Co-authored-by: jonathnwxh <[email protected]>
select.colorRange takes {"select": "skinTones"}: a soft skin-tone model
in the YCbCr chroma plane (an ellipse along the skin run of Chai & Ngan,
1999) with a luminance gate, so near-black and near-white pixels drop
out. Fuzziness widens or narrows it as it does for Sampled Colors; the
selection modes, Invert and Sample All Layers work as for the other
presets. The dialog lists Skin Tones with Fuzziness enabled.
Detect Faces needs a face detector, which there is none of yet:
detectFaces: true is refused with a message instead of being ignored.
Fixes#4455
shape_split caches a stroked shape's fill and stroke rendered over the
canvas, for shapes with clipped layers: up to 32 entries of two
canvas-sized surfaces each, with no byte cap (several GB at 24 MP) and
nothing in Edit › Purge › All to clear it.
It now evicts least recently used entries until a new one fits both
the 32-entry cap and a 256 MiB pixel budget (an entry larger than the
whole budget is used and not kept), and Purge › All clears it as
"shape stroke cache", reporting the bytes like the other caches.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Measured against Photoshop 27.11 on a test chart: Blur More uses the
[1 2 1; 2 2 2; 1 2 1]/14 kernel, Find Edges the full Sobel magnitude, and
Polar Coordinates turns counter-clockwise from 12 o'clock with repeated
edges and Photoshop's sampling grid. All four now match within 2 levels.
Co-authored-by: Claude Opus 5.5 <[email protected]>
With the GPU canvas, the Navigator still composited the document on
the CPU on the UI thread after every edit: render_reduced_damage over
the full-resolution damage, or a full thumbnail_buffer composite when
the document has effects. On the #209 layered 20 MP document that was
126–137 ms after a small opacity edit and about 700 ms for a
full-canvas layer, against 3.4 ms for the canvas itself.
While the main canvas shows the plain document on the GPU canvas, the
Navigator now paints that same texture through the canvas's own
shader (GpuCanvas::paint) at its size: no composite, no readback, with
the display transform the canvas uses. Everywhere else (a CPU canvas,
a flipped view, an adjustment proxy or preview) it keeps the CPU
thumbnail. Until the canvas has drawn a new document it waits up to
three frames rather than build a CPU thumbnail it would use once.
The preview is resampled through the display mips instead of the CPU
thumbnail's area sampling: thin lines average to even grey instead of
aliasing, and edges are a little softer.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Swatches, Gradients and Patterns browsers capped their list with a
fixed allowance for the footer (30 or 40 px) and kept a minimum list
height (60 or 90 px, and egui's 64 px minimum for a scroll area). In a
short dock group the list took that room anyway and pushed the footer
(size slider, New Group, New, Delete) out of view: Swatches' slider was
cut in half, Patterns' footer was hidden entirely.
The shared browser now measures its footer and gives the list only the
height left after it, so the list scrolls and the footer stays visible,
as in Photoshop.
Fixes#4790
Co-authored-by: Claude Opus 5.5 <[email protected]>
winit maps CursorIcon::ZoomIn/ZoomOut to the plain arrow on Windows, so
the Zoom tool showed no cursor feedback there. Draw the magnifier (plus,
or minus while Alt zooms out) as an OS cursor bitmap, like the Hand
tool's #2196 fix; macOS and Linux keep their stock zoom cursors. Camera
Raw's preview zoom uses it too.
Co-authored-by: Claude Opus 5.5 <[email protected]>
When a document with unsaved changes was closed, reverted, or the app was
asked to quit, the Save / Don't Save / Cancel prompt showed in the window
but not in ui.inspect, and a control client had no way to answer it: the
app stayed open until someone clicked.
ui.inspect now lists the prompt among its dialogs with the id "discard":
the waiting command, the document it asks about, how many are left and
the choices. ui.dialog.confirm {"dialog": "discard", "choice":
"save"|"dontSave"|"cancel"} answers it through the same code as the
buttons (one document at a time when quitting), and ui.dialog.cancel
cancels it. The automation policy sees the parked command before a save
or don't-save answer runs it.
Fixes#4920
Co-authored-by: Claude Opus 5.5 <[email protected]>
The filter preview runs the command in a fresh session holding only a proxy
of the edited document, so a document param such as Match Color's Source
(or Displace's map) pointed at nothing there: with another document as the
Source the preview failed and the canvas kept showing the unchanged image,
and with the first open document it matched the image to itself. OK then
applied a different result from what the dialog showed.
The preview now adds each referenced open document (as a proxy at the same
factor) to its session and points the param at it, so it shows what OK
applies.
Claude-Session: https://claude.ai/code/session_01J5ysoK8nmS7grqCY1wGvhy
Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Follow-up to #4021, which drops the polygon when another tool is selected. The polygon now also remembers the document it was started on (UiState::polygon_doc) and is dropped when another document becomes active, as the Lasso and the Magnetic Lasso already do; before, its outline showed on the other document. Adds tests that every selection tool and Move/Brush drop the polygon (#4021 tests Quick Selection only), that a document switch drops it, and that a held Space or Cmd temporary tool keeps it.
Co-authored-by: ZulejhaAvduli <[email protected]>
* New Document: a custom background colour, from Custom… or the swatch beside the menu (#4789)
* New Document: test the swatch's colour for each Background Contents choice (#4789)
Co-authored-by: persi-man <[email protected]>
---------
Co-authored-by: persi-man <[email protected]>
In Photoshop the order of smart filters matters (the bottom one is applied
first: Unsharp Mask over a Gaussian Blur sharpens the blurred image, under it
the sharpening gets blurred), and a filter row is dragged up or down the stack
to change it. The engine already had `layer.smartFilter.move` and re-renders in
the new order, but the Layers panel had no way to reach it: a filter row could
only be dragged onto another smart object or onto Delete (#4271).
Dragging a filter row over another row of its own stack now draws an insertion
line above or below that row (the Smart Filters row: on top of the stack), and
the release runs `layer.smartFilter.move`, one undo step. The moved filter ends
up selected with its smart object active, as after a click. Drops next to the
filter's current place, or on itself, do nothing.
Tests: `smart_ui::tests::a_filter_dropped_above_or_below_a_row_lands_next_to_it`
(the index math) and `smart_filter_rows_tests::dragging_a_filter_up_or_down_its_stack_reorders_it`
(whole app: above, below, onto the Smart Filters row, selection, undo).
Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
On systems with only Noto Sans CJK's variable collection (Fedora's
google-noto-sans-cjk-vf-fonts), the UI's CJK fallback was registered at
the font's default instance, which is Thin (wght 100), so CJK labels and
layer names came out hairline next to Latin text.
A fallback face with a weight axis whose default isn't Regular is now
registered with wght 400 (clamped into the axis) through egui's
FontTweak::coords. Static faces and variable faces that already default to
Regular are unchanged.
Fixes#4820
Co-authored-by: Claude Opus 5.5 <[email protected]>
Cmd/Ctrl+Alt+G only ever ran Create Clipping Mask, so pressing it on a
clipped layer did nothing. In Photoshop the Layer menu has one item that
reads Create or Release Clipping Mask with the same shortcut, so the key
toggles the clip.
The key now runs Release Clipping Mask when the layers Create would clip
are all clipped already (the selected layer, or every selected layer above
the lowest), next to the Delete key's redirect in shortcut_dispatch. The
engine commands are unchanged, so agents still get exactly what they call.
The Layer menu follows: its Create Clipping Mask slot reads Release
Clipping Mask (and runs it) with the same shortcut while the key would
release, and the separate shortcut-less Release item that sat at the
bottom of the menu is gone.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Transforming the painting symmetry could only be applied with Enter or
dropped with Esc, which a pen or touch user may have no key for, and the
canvas kept the brush outline although a press there doesn't paint.
While the transform is open, the options bar now shows "Transform
Symmetry" with commit and cancel buttons, like Free Transform's bar. The
canvas shows the move cursor over the centre, the rotate cursor over the
rotation handle and the arrow elsewhere.
Test: transform_has_commit_and_cancel_buttons_and_its_own_cursors.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The profile menu offered every built-in and installed profile, and picking
one of another colour space only produced "profile is Rgb; the document is
Cmyk" in the status bar. The menu now lists the profiles the command accepts
for the document's mode, and a remembered choice that doesn't fit falls back
to the working space.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Use the shared percentage row so the jitter amount does not look like a hue selection. Cover the rendered themed track, numeric and pointer edits, neighboring settings and engine journal replay.
Addresses the Hue Jitter appearance item in #3825; other assessment items remain open.
Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
⇧⌘N opens the New Layer dialog, as Layer › New › Layer… does in Photoshop.
Photoshop's companion shortcut, the same keys with ⌥ (Ctrl+Alt+Shift+N on
Windows and Linux), makes the layer at once; PhotoCraft didn't handle it,
so there was no way to get 0.7.0's dialog-free behaviour from the keyboard.
New Layer's shortcut with ⌥ added now runs layer.new.layer directly. It
follows a rebound New Layer shortcut, and a command bound to that key
itself keeps it.
Fixes#4794
Co-authored-by: Claude Opus 5.5 <[email protected]>
In the Brush Preset picker's cards the tip thumbnail grew to 44.8 pt in a
42 pt cell while the size number was anchored to the cell's bottom, so
the number of any medium or large brush (Hard Round's 30, Soft Round's
45) was drawn under the tip and couldn't be read.
card_tip_rect now keeps a band for the number at the bottom of the cell
and fits the tip above it, still scaled by the preset's thumb_scale so
big brushes read bigger than small ones. Compact cards (no number) are
unchanged.
Test: card_tips_never_cover_their_size_number checks every footer scale
that shows numbers, in all three card kinds.
Co-authored-by: Claude Opus 5.5 <[email protected]>
GpuCanvas::retain called forget_doc only for documents still in its
texture map. A document whose entry had been dropped earlier (texture
creation failed, a fresh render failed after uploading, or the canvas
outgrew its budget after Canvas Size) kept its resident pages, effect
maps and lower-stack composite after it was closed, until another
document pushed the budgets over; with no other GPU document, for good.
Compositor::retain_docs forgets every document the caller doesn't list
as open, whatever the texture map says, and retain calls it each time.
The open documents' ids, their preview ids and the adjustment proxies
are already in that list, so nothing in use is dropped.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Surface::translated, under every layer move and the Move drag preview, allocated
every target tile that a moved source tile reached. For a move that isn't a whole
number of tiles, the transparent margins of a layer's edge tiles landed on target
tiles of their own, and those were stored though they hold only the default pixel
(256 KB each at 8-bit RGBA). #4414's shared uniform tiles were also stored when
their value was the default. Moving two 300 px blocks by (100, 56) left 10 of 18
stored tiles empty; nudging back and forth kept adding more.
A target that ends up holding only the default pixel is no longer stored, as
prune() would drop it. A copied tile is compared row by row with a row of the
default pixel after the copy (one memcmp per row; a tile with content stops at
its first row), and a shared uniform tile of the default value is skipped. Moves
by whole tiles still re-share the existing tiles unchanged.
Release, 24 cores, cargo xtask perf --quick (perf_scenarios, p50, HEAD vs this,
two runs each): move layer on 16-bit 15000x10000 + refresh 5.3 -> 3.9 ms; move
layer on 32-bit 20 layers + refresh 11.9 -> 5.9 ms; the other Move rows within
noise. translated() alone on 36 MP: full layer unchanged (27.0 -> 27.6 ms RGBA8);
40 sparse blocks 11.8 -> 10.6 ms RGBA8, 44.0 -> 39 ms RGBA16, 308 -> 190 tiles.
The new test covers RGBA8, RGBA16, RGBA32F and GRAY8 over a zero and a non-zero
default pixel, and both paths; it fails on the old code.
Co-authored-by: Claude Opus 5.5 <[email protected]>
merged_composite showed a CMYK merged image through the built-in coated
profile, while the compositor reads the file's layers through the
embedded one. Every oracle comparison of a tagged CMYK file therefore
measured two different profiles. psd-tools' CMYK files were 88-100% off
even with nothing to adjust: colormodes/4x4_8bit_cmyk.psd, a plain
image, was 100% off.
It now converts inside the document's CMYK space (compose::cmyk_space).
The test reads the merged inks back and checks them against the embedded
and built-in conversions.
psd-tools, bad pixels (pass count unchanged at 213/309):
- invert_cmyk: 98% -> 15%
- levels_cmyk: 95% -> 14%
- curves_cmyk: 98% -> 37%
- threshold_cmyk: 65% -> 7%
- posterize_16bits_cmyk: 88% -> 13%
- 4x4_8bit_cmyk: 100% -> 6%
With #4945's ink compositing, invert_cmyk and
brightnesscontrast_legacy_cmyk pass (215/309).
Co-authored-by: Claude Opus 5.5 <[email protected]>
pHYs holds whole pixels per metre, so 300 ppi is stored as 11811 and
read back as 299.9994 (72 as 72.009). The ppi read is now the whole
number that rounds to the stored value when there is one, as Photoshop
reads it; other values stay as they are.
Fixes#4855
Photoshop CC brush presets store the Dual Brush spacing the panel shows in
the dual tip (dualBrush > Brsh > Spcn), and also write a Spcn of 100 % on
the dualBrush object beside it. The importer read that outer value last,
so every dual brush came in at 100 % spacing (the reporter's 10 %, 18 %
and 24 % presets all did), which changes the stroke a lot.
The tip's spacing now wins; the outer value only stands in for a tip
without one.
Fixes part 1 of #4809
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(ui): show uppercase RGB hex values in shared color picker
* test(text): expect uppercase hex in text style color picker
* fix(ui): uppercase displayed hex without changing color storage
* test(text): keep color serialization independent of picker display casing
When appearance mode was toggled between dark and light with default
settings, PhotoCraft switched from Pro Medium (Pro layout) to Studio Light
(Studio layout), altering the UI and displaying a Fit button in the status
bar.
Now that the Photoshop Pro light brightness levels exist (#2661), default
the saved light theme to Pro Light so the Pro interface layout and status
bar are preserved when toggling appearance. Also map the "light" alias to
Pro Light and list the Pro options first under Light Theme preferences.
Fixes#5016.
A right-click (or long press) on a tool button opens the flyout of the
tools grouped with it. Any press outside the flyout closed it, but the
press-outside check leaves out the tool button itself, so clicking that
button again, with either mouse button, left the flyout open.
A click on the button of its open flyout now closes it, as in Photoshop:
a right-click just closes it, a left click closes it and picks the tool
as any click does. The release that ends the long press that opened the
flyout still keeps it open.
Fixes#4932
Co-authored-by: Claude Opus 5.5 <[email protected]>
In v0.7.0 the options bar's Fill swatch only previewed the foreground
colour, so a drawn shape's fill could not be changed from it while Stroke
could. Main fixed that in #3019 and #3370; this test covers the reported
steps (draw a rectangle, pick a colour from the Fill swatch): the fill
changes, the stroke does not, and it is one undo step.
* Brush Settings: no section title above the options pane (#3477)
The options pane repeated the selected section's name as a title line.
The selected row in the section list already names it, as in Photoshop,
so the title is gone and the options start at the top of the pane.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n test: the brush section name is drawn once, without a heading (#3477)
dynamic_brush_sections_have_translations_and_draw_in_the_selected_language
still expected the translated section name twice: the list row and the
options pane heading this branch removes. It now expects the row alone.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
fit_view capped the fit at 1.0 in egui points and then multiplied by
pixels-per-point, so on a 150% display a small image opened at 150%. Since
View::zoom became physical pixels per document pixel (27263bb16, #1978) the
cap has to apply after the conversion: scale by ppp first, then min(1.0).
The test for fit_view asserted the old value (zoom 2.0 at ppp 2.0); it now
checks a small image opens at 1.0 for ppp 1.0, 1.5 and 2.0 and that a large
image still shrinks to fit.
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Photoshop CS2 stores the phfl version 3 colour as XYZ divided by the D50
white point (X / 0.9642, Y, Z / 0.8249), so white is (32768, 32768, 32768).
We read those values as absolute D50 XYZ, giving every CS2 Photo Filter a
slightly wrong colour: the Yellow filter preset (24191, 25002, 3827) decoded
to #FFE03A instead of #F9E31C, and Warming Filter (85) (15548, 12029, 0) to
#F18600 instead of #EC8A00. Multiply by the white point before converting;
decoded this way, every version 3 colour in the reporter's CS2 files is
exactly a Photoshop preset (#4940).
Fixes#4940
Co-authored-by: highperfocused <[email protected]>
Remove Background (and Select Subject's full-resolution refinement) could
hang at "Finding subject" on some photos. One refinement tile's max-flow
never finished, and the job ignores Cancel there.
Push/relabel's admissibility test was `dist[i] == dist[j].saturating_add(1)`.
A global relabel gives nodes reachable from neither terminal the height
u32::MAX, and MAX.saturating_add(1) == MAX, so two such neighbours counted
as admissible for each other. When f32 rounding strands excess on them
(source capacities near 3e8 have an ulp of 32), they pushed it back and
forth forever. Use checked_add so an unreachable node is never a push
target; its excess stays put and the loop ends.
The regression test is a 6-node graph that looped before and now matches
the Boykov-Kolmogorov cut.
Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Lab skips the native single-layer path (the codecs have no Lab layout)
and goes through the sRGB composite, which always warned that the
layers were flattened. With a single plain layer nothing is flattened:
only the sRGB conversion is reported now.
Fixes#4852
Save for Web subsampled the chroma (4:2:0) up to quality 89. Photoshop
2025's Save for Web writes 4:2:0 up to quality 50 and 4:4:4 from 51
(measured on its output; its Save As keeps 4:2:0 at every quality, as
PhotoCraft's Save As already does).
Fixes#4822
#426 gave smart objects a projective placement (`SmartObject.perspective`,
with `transform` as its affine approximation): Distort and Perspective set
it, the layer re-renders from its source through the homography, Warp
composes with it, and the PSD placement reads and writes
`nonAffineTransform` / `Trnf`. The scorecard row still said "missing" with
the pre-#426 note, as did the ui-parity row and the #579 mention in gaps.md.
arboard 3.6 asks the clipboard for image/png only, so a screenshot tool or
clipboard manager that offers just image/jpeg, image/bmp or image/webp pasted
nothing ("the clipboard holds no image"). When arboard finds no image, read
the other image/* types through wl-clipboard-rs (already in Cargo.lock via
arboard's wayland-data-control) and decode them with photocraft-codecs.
Reads are capped at 1 GiB; TGA is trusted only when its type says so.
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(channels): Channel Options dialog to make alpha channels spot colours (#3761)
Double-clicking an alpha channel's thumbnail in the Channels panel now opens
Photoshop's Channel Options dialog (double-clicking the name still renames it):
Name, Color Indicates Masked Areas / Selected Areas / Spot Color, the overlay
or ink colour (Color Picker) and Opacity, which becomes Solidity for spot
channels. A saved selection can now be turned into a spot channel with its own
ink for screen-print separations. The row context menu gains Channel Options…
and Quick Mask Options…, and `channel.options` without params from a menu
opens the dialog on the targeted channel. OK runs the existing engine command,
so agents can still call it directly with params.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* style: rustfmt module order in ui-egui lib.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(channels): New Spot Channel dialog (#3761)
Channels › New Spot Channel… now opens Photoshop's dialog instead of adding
a channel straight away: Name (prefilled with the next free "Spot Color N"),
and Ink Characteristics: Color (opens the Color Picker) and Solidity. OK
creates the spot channel from the selection, as before, and remembers the ink
and solidity for next time. `channel.newSpot` with params still runs the
engine directly for agents, the CLI and MCP. The engine's `next_alpha_name`
helper is now public so the dialog's name matches the engine's default.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n: keep main's Czech rows, add Vietnamese strings (#3761 review)
- cs.tsv: main has removed its duplicate copies of Tool, Stroke Path…,
Clone Stamp, History Brush, Dodge, Burn and Sponge, so this branch keeps
main's single rows; the only Czech change is the Channel Options / New Spot
Channel block.
- vi.tsv: the Vietnamese catalog is complete since #2636, so it gets rows for
Channel Color, Color Indicates:, Solidity: and Ink Characteristics.
- it.tsv: main now translates Masked Areas and Selected Areas, so this
branch's duplicate rows are dropped.
Every catalog now only adds lines relative to main.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Channel Options: Serbian, Hungarian and Turkish rows for the new strings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Brush: the Mode dropdown shows in every theme, not only Pro (fixes#4512)
* Options bar scroll test: wheel over Studio's bar clear of the Brush Mode dropdown
The Brush's new Mode dropdown takes the wheel to step through blend modes, so
the test's pointer at the bar's centre landed on it at 800 px in Studio.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(input): translate Linux precision Ctrl-wheel pinch gestures
* fix(input): decode Wayland high-resolution point pinch events
* docs(input): describe precision pinch support on Windows and Linux
* rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Print dialog's Printer field was free text, so you had to type the
spooler's exact queue name (e.g. HP_OfficeJet_3830_series). It's now a
dropdown, like Photoshop's: "Default printer (<name>)" first (value "",
as before), then every printer craft_print::printers() reports (CUPS
queues and driverless destinations, or Win32_Printer on Windows).
- engine: new query command `file.printers` → {printers:[{name, default}],
default}, no document needed, not journaled (same shape as DesignCraft's).
- ui: the dialog asks once per opening and keeps the list in `__printers`,
which never reaches file.print's params. Labels show `_` as spaces; the
exact queue name is what's sent. A remembered printer the spooler no
longer lists stays selectable.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(tests): bound print fuzzing and serialize Windows crop fixtures
* Crop tests: hold the shared GPU lock in every WARP-backed test; drop the print fuzzing change main already has (#4467)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(export): offer ICO in Export As with format-size guidance
* fix(export): use theme warning token for oversized icons
* i18n: translate the Export As ICO size warning
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(type): leave keyboard input to focused font search
* test(type): focused font search cannot alter the edited type layer
---------
Co-authored-by: Brandon Thomas <[email protected]>
* fix(batch): ignore recorded Quick Export and use batch output
* fix(batch): ignore recorded Quick Export and use batch output
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
mix_selection looked up the selection one pixel at a time
(`sel.pixel(x, y)`, which allocates a Vec and walks the tile map) for
every output pixel of every tile of a filter applied through a
selection: about 24 million allocations and map lookups at 24 MP.
It now reads the selection over the tile once (`read_region`) and
indexes it. The result is bit-identical: the old per-pixel version is
the test oracle, with fully selected, unselected and partial pixels,
with and without alpha, on a tile crossing the selection's tiles.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* perf(camera-raw): convert the refined preview in parallel bands, not per row (#4682)
#3427 colour-managed the refined (zoomed-in) Camera Raw preview by calling
the display transform once per row. A row is below photocraft-cms's 16K-pixel
parallel threshold, so every edit ran the whole conversion on one core of
the UI thread, plus two allocations per row. Read and convert 64K-pixel bands
instead, in parallel off wasm (as channel_view does); output is identical.
2560x1600 visible crop, median of 15 runs (22-thread laptop, dev profile):
- sRGB document: 31.5 ms -> 3.9 ms
- Adobe RGB document: 125.0 ms -> 12.5 ms
- ProPhoto document: 117.5 ms -> 12.7 ms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
* perf(camera-raw): the main preview converts in parallel bands too, without a full copy
CameraRawDialog::image copied the whole proxy into a Buffer and colour-converted
it on one thread on every edit. It now goes through the same banded, parallel
display_image as the refined preview, reading bands straight from the slice.
Median of 21 runs (22-thread laptop, dev profile):
- 858x572 sRGB: 4.72 ms -> 1.00 ms
- 858x572 Adobe RGB: 7.39 ms -> 2.06 ms
- 1716x1144 sRGB: 18.58 ms -> 2.48 ms
- 1716x1144 Adobe RGB: 28.78 ms -> 6.88 ms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The GPU ramp is 4096 entries, so stops at 0.4999 and 0.5 interpolated to gray while the CPU kept the step. Stops that share an entry now use the CPU composite. Fixes#3053.
Co-authored-by: Brandon Thomas <[email protected]>
Filter Gallery, Adaptive Wide Angle, Vanishing Point and every parameter dialog (Lens Correction, Image › Adjustments, …) now keep a history of their own settings: ⌘Z / ⇧⌘Z step through it, one drag is one step, and the document's History beneath is left alone. Camera Raw is left to #4136.
Co-authored-by: Brandon Thomas <[email protected]>
Surface::convert / try_convert and map_color converted tile by
coordinate, and the ICC path (color_cmds::convert_surface) staged and
converted every coordinate's tile. A tile shared across the grid, such
as a filled background (one Arc for all of it), therefore came out as
one fresh tile per coordinate: a white 2048×1024 background went from
1 to 32 tile allocations, a 24 MP one to 384.
Each distinct tile is now converted once and the result shared by
every coordinate that shared the original. Pixels are unchanged; tests
compare against a per-pixel conversion and check the sharing for
convert, try_convert, map_color and Image › Mode › CMYK.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
canvas::display_doc runs 3–4 times a frame (canvas, Navigator, beyond-
canvas and texture paths). For a Duotone document it cloned the whole
document on every call to add the ink display layer, before any cache
check; while an adjustment slider is dragged it cloned it again on every
call to swap in the previewed settings.
memo_display keeps the built document per document id, keyed by the
snapshot (a Weak) and the preview key, so each is built once per edit
or setting and shared after that. The Channels panel borrows the masked
layer instead of cloning it every frame, and the adjustment Properties
copy the two flags they read instead of the whole layer.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Layers: Add vector mask makes the mask from the selected path and targets it (fixes#4728)
* Pen menu test: the vector mask made from a path becomes the active path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Select > Sky: a white or grey backdrop that reaches the bottom is not sky
The colour prior counts bright, nearly neutral colour as overcast sky,
so a seamless studio backdrop starting at the top edge flooded the
whole background (79 % of a product shot). A sky never reaches the
bottom of a photo; a backdrop does. A region that also covers most of
the bottom rows and is nearly neutral (mean saturation under 0.1) is
now taken for a backdrop and nothing is selected; blue sky reaching
the bottom, as in a photo taken upward, still is.
Fixes#4634
* Sky: read the bottom rows of the mask with get()
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
lift read the whole copied area into one f32 buffer (16 bytes per RGBA
pixel, about 366 MiB at 24 MP), multiplied in the selection and wrote
everything back into new tiles, so the clip shared nothing with the
layer: 273 ms for a plain Copy of a 24 MP layer and 341 ms after Select
All.
It now goes tile by tile. A source tile the copy takes whole (inside the
canvas, no selection or a fully selected tile, already in the clip's
format) is shared copy-on-write; the other tiles go through the old
per-pixel path one at a time. Measured on the same layer: 17 ms and
42 ms. The clip is bit-identical: the old whole-area version is kept as
the test oracle across depths, with and without alpha, no selection,
Select All, a partial feathered selection and one off the tile grid,
and content past the canvas.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* feat(color): Dot Gain 20%-compatible default working gray, as in Photoshop
Photoshop's default working gray is Dot Gain 20%, and its RGB -> Grayscale
measured in Photoshop 27.11 (default Color Settings) is D50 luminance
encoded with gamma 1.732: sRGB 64 / 128 / 192 grey and pure red become
46 / 105 / 177 / 107. PhotoCraft defaulted to sGray, which kept the sRGB
value (64 / 128 / 192 / 130), up to 24 levels off.
Adds the built-in "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"
gray profile (id dot-gain-20-compat; our own fitted curve, not Adobe's
profile, the same gamma PhotoCraft already uses for gray adjustments) and
makes it the default working gray and the fallback for untagged gray
documents. sGray and Gray Gamma 2.2 stay selectable.
This changes a default: untagged gray documents now convert and display
as Photoshop shows them (midtones lighter than under sGray); documents with
an embedded profile are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Status bar test: the default working gray is now Dot Gain 20%-compatible
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* .atn test: #202020 in the Dot Gain 20%-compatible working gray
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Pen: a close-path pointer over the first anchor (#4596)
* Canvas: the close-path pointer arm before the pen tools' own cursors
The pen tools' icon cursors (#4650) matched first and made this arm unreachable.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
A layer with Blending Options › Blend If made the planner refuse the
whole document, so every refresh of it ran the CPU compositor: on the
#209 layered document an opacity edit took 140 ms instead of 6 ms.
Blend If is compose::apply_blend_if: the composite with the layer mixed
back towards the backdrop by a per-pixel weight from the layer's own
content with its masks ("This Layer"; an adjustment's result) and the
backdrop ("Underlying Layer"). The planner now keeps the backdrop,
composites the layer as before (channel restrictions included, as on
the CPU), renders its own content and emits one Lerp pass with the new
F_BLEND_IF flag and the ranges packed exactly in its parameters. Base
and clipped layers both go through it; Advanced Blending still falls
back to the CPU.
Hard points: compositing can leave an 8-bit level a few millionths
below itself (a level-20 backdrop at partial alpha reads 19.999998),
which put it on the wrong side of a hard point at that level on the
CPU, and the GPU's arithmetic differs there. Both now treat a value
within 1e-4 of a level as that level; 8/16-bit values off a level are
at least 1/257 away. On 16/32-bit documents the GPU reads layers as
RGBA16F, so a value within about a tenth of a level of a hard point can
still land on the other side, as with the GPU Threshold adjustment.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
#4651 re-added Japanese rows that #3893 and earlier work already had, so
bundled_catalogs_are_consistent failed on duplicates. #4605's mask thumbnail
menu landed without its three Serbian strings.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* OpenRaster: never overwrite an .ora with less than the document has (#3806)
File › Save wrote any document back to its .ora, though OpenRaster
drops adjustment layers, layer styles, vector masks and clipping,
applies masks and fill opacity to the pixels and rasterises type and
shape layers; the save then marked the document saved, so the edits
were lost without asking.
An OpenRaster file now counts as the document's own file only while
it holds everything in it. photocraft_io::ora_losses lists what a save
would leave out, from the same checks the writer warns with but
without encoding anything, and file_cmds::holds says whether a format
keeps the whole document (PSD, PSB and .pcraft always; OpenRaster when
nothing is lost). When it doesn't:
- File › Save opens Save As on a PSD of the same name, with a notice
listing what OpenRaster would drop, instead of overwriting the .ora;
- a save to an .ora path writes a copy, as Photoshop does for formats
that can't keep everything: the document keeps its file and stays
unsaved;
- automation (app.save, the headless doc.save) refuses a pathless save
with the reasons and treats an .ora save as a copy.
An sRGB profile is no longer reported as lost, as OpenRaster readers
assume sRGB. A plain document still saves back to its .ora.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(vi): translate the OpenRaster save notice
vi.tsv is now a complete catalog, so the two new strings need
Vietnamese rows. Terms follow the catalog's own: Không thể for
can't, như for such as, Lưu bản sao for Save a Copy.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the OpenRaster save notice
main added complete Hungarian and Turkish catalogs after this branch
was cut, so every_tl_literal_is_translated needs their rows for the
two new strings. Wording follows each catalog: informal Hungarian
with "{name}" after a colon; formal Turkish with Biçim for format.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(sr): translate the OpenRaster save notice
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated
needs the notice's two strings there too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Lab and CMYK: Invert, Posterize and Threshold work on the document's channels
Photoshop applies these to each stored channel, not to RGB:
- Lab: Invert and Posterize work on L, a and b, and Threshold cuts on L
to a neutral black or white.
- CMYK: Invert and Posterize work on each ink, black included, so
paper inverts to 400% ink. Threshold gives paper or 100% black ink.
Adjustment layers now do the same:
- compose::adjust::apply_mode goes through the document's Lab or CMYK
channels.
- The GPU falls back to the CPU for these, as it already does for
Lab/CMYK Levels and Curves.
- The destructive Image > Adjustments path works on the stored samples
directly, so it is exact.
Photoshop oracles (lab/cmyk 8 and 16 bit), bad pixels before -> after:
- lab threshold: PASS (was 12%)
- lab invert: 72-99% -> 1%
- lab posterize: 89% -> 0.1%
- cmyk invert: 77% -> 10%
- cmyk posterize: 71% -> 9%
- cmyk threshold: 37% -> 1.6%
Floor 133 -> 139 (137 already passed on main).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral
Builds on main's adjust::apply_in (#4038):
- apply_in's mode dispatch gains CMYK. Invert and Posterize work on each
ink, black included, so paper inverts to 400% ink. Threshold gives
paper or 100% black ink alone. cmyk_channel_adjustment says which
adjustments do this.
- Lab Invert mirrors a and b around neutral at 16 bits; 8-bit bytes
still become 255 - v. stored_channels holds both rules, and
apply_lab uses it.
- Destructive Image > Adjustments on a Lab or CMYK layer works on its
stored channels directly, so it is exact; a selection mixes the
stored values.
- The GPU leaves CMYK Invert, Posterize and Threshold to the CPU.
Photoshop oracles, bad pixels:
- cmyk invert: 77% -> 10%
- cmyk posterize: 71% -> 9%
- cmyk threshold: 37% -> 1.6%
- lab16 invert: 52% -> 1.2%
The count stays at 145/258, which main already passes, so the floor
goes 133 -> 145.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Carry only the linked-source records referenced by copied layers through Copy/Paste and save/reopen. Preserve raw records, avoid duplicate payloads on same-document paste, and reject ambiguous or incomplete selected identities before replacing the clipboard.
Co-authored-by: skylar taylor-barrick <[email protected]>
Dabs read the hotspot from pixels earlier dabs had already erased, so
Continuous skipped most dabs and Contiguous erased nothing from an
erased hotspot, leaving patches after one pass.
Fixes#1280
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Photoshop 27.11's round tip, measured on single dabs (hardness 0-100 in
steps of 10 at 40 px, checked at 100, 9, 7, 5, 3, 2 and 1 px): a solid
core of radius r0*R, then a Gaussian shoulder exp(-((d - r0) / s)^2) of
width s*R, both from a table by hardness. A soft tip reaches well past its
nominal radius (hardness 0 is a Gaussian of sigma 0.47 R: 8 % at 1.05 R);
PhotoCraft's (1 - t^2)^4 ended at R and kept far less coverage (a 40 px
hardness-0 dab held 251 coverage units against Photoshop's 542). The edge
stays at least ~1 px wide (0.3 R on small tips, never under 1/4 px).
exp is (1 - u/64)^64 by squaring, so it is the same on every platform.
Dab rectangles, reaches and the retouch footprint grow to the tip's reach
(tip_reach); the brush cursor's 50 % ring (half_coverage_radius) follows the
new curve. Tests that hard-coded the old curve now check measured values;
tests that bounded a stroke by the nominal radius use the reach.
Measured against Photoshop (max levels / mean): 40 px dabs, hardness 0-70:
70-156 / 12-15 -> 2-6.5 / <0.7; 100 px dabs 70-177 -> 2-27; strokes
(probe 13) soft and medium 20-60 px 133-178 / up to 17 -> 4-26 / <=0.5;
spacing sweeps 134-221 -> 7-9; soft Flow build-up 85 / 7.5 -> 5 / 0.16;
small 1-7 px total error lower in 21 of 30 cases (medium hardness at
2-5 px a little higher: a follow-up).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
A shape layer's thumbnail shows its pixels like any other layer, so nothing
in the row said it was a vector shape. It now carries a badge in the
bottom-right corner, in the same frame as the smart-object badge: a path
with an anchor point on each corner.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Free Transform and Warp sampled with Catmull-Rom (Keys a = -0.5).
Photoshop's Bicubic is the Keys cubic at a = -0.75 (Image Size already
uses it): a 200 % step edge matches within 0.5 levels (Catmull-Rom was 10
off). When a Bicubic transform reduces, the source is now resized first by
the whole local scale with Image Size's widened Bicubic, and the warp
samples it at about 1:1; the old pre-reduce only kicked in below 50 % and
by powers of two, so 50-99 % reductions aliased. Bilinear still samples
directly (it matched Photoshop exactly at 50 %).
Measured against Photoshop 27.11 Free Transform (max levels / mean):
Bicubic 200 % 10 / 0.36 -> 1 / 0.01; 50 % 48-59 / 1.0-1.2 -> 6-8 /
0.02-0.05; 150 % mean 0.59 -> 0.17. Bilinear unchanged. Bicubic
Smoother / Sharper / Automatic (not offered by Transform yet) and rotated
edges are follow-ups.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Cursors: the pen tools show their icon, and Caps Lock swaps it for the crosshair (#4639)
The pen tools fell through to the canvas's default crosshair whatever
Preferences > Cursors > Other Cursors said, and Caps Lock did nothing.
In Photoshop the Standard cursor is the tool's icon, Precise is the
crosshair, and Caps Lock swaps between them.
The Pen, Freeform Pen, Curvature Pen, Add/Delete Anchor Point and
Convert Point tools now draw their toolbar icon with the nib (Convert
Point: its anchor) on the point. precise_other_cursor (the preference,
flipped while Caps Lock is on) decides it, and the Eyedropper, Color
Sampler and Paint Bucket, which already followed the preference, now
swap with Caps Lock too.
Test: caps_lock_swaps_the_other_cursors_between_their_icon_and_the_crosshair.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Canvas: keep pipette_cursor's doc comment on pipette_cursor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Update Japanese translations in ja.tsv
I have added items that appear to have been omitted from the registration.
* i18n(ja): keep the rows this update dropped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The in-dialog preview clamped the texture UV to the image but still drew
it over the whole preview region. Whenever the region overhung the image
on one axis (a 100% view of a small document in the wide preview), that
axis was stretched to fill it. The preview now draws only the part of the
source rectangle that lies on the image, at its own place and scale in the
region, with the checkerboard under the image only.
Co-authored-by: Brandon Thomas <[email protected]>
The picker covers every screen and stays on top, and a report on #4291
describes one that ignored Esc, right-click and left-click, so the
computer could only be freed from another device. In the picker's frame:
- Esc only worked once Windows gave the picker keyboard focus, which
nothing asked for. The picker now takes focus on its first shown frame.
- A right-click did nothing; it now cancels.
- A left click was dropped when no snapshot pixel mapped to the pointer,
and a press that moved before its release never counted as a click.
Any left release over the picker now ends it: it picks the pixel under
the pointer, or cancels when there's none.
The frame moved into screen_picker::frame so it can be tested.
Test: every_way_out_of_the_picker_ends_it.
Co-authored-by: Claude Opus 5.5 <[email protected]>
blend_into takes every tile in the selection's bounds and blended each
one into a fresh copy. History holds the previous document, so
Arc::try_unwrap always failed and a tile with nothing selected was
deep-copied too, unchanged: a 4 px Edit › Stroke on Select All of a
20 MP layer stored +82.5 MB per step for a ring of edge tiles.
blend_tile skips every pixel whose weight (selection × opacity) is
zero, so such a tile comes out byte-identical: keep the tile itself.
Mask::empty sits beside Mask::full. Measured on the same layer: the
stroke step now stores +17.5 MB and a 4000×3000 ellipse fill +14.2 MB
instead of +26.0 MB. The new test fails on the old code.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
channel_thumbs was keyed by the document snapshot, so every step,
including a marquee or Deselect, recomposited the whole document for the
56 px thumbnails (`compose::thumbnail`, about 0.7 s on the #209 layered
20 MP document with effects) and loaded new textures for each.
A new snapshot that differs from the previous one only in its selection
keeps the thumbnails (`same_but_selection`: the old document with the
new selection compared to the new one; tiles compare by pointer first).
A real rebuild updates the existing textures with `set` instead of
allocating new ones. The new test fails on the old code: a marquee
and Deselect no longer upload anything, and a fill rebuilds into the
same textures.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Session::apply_prefs set the shared text engine's missing-glyph
protection through photocraft_text::shared(), which blocks until the
system font scan that launch starts in the background is done. It runs
on the UI thread at launch and on every preferences change, so the first
frame waited for the rest of the scan: 248–257 ms on this machine
(release, launch order replayed), more with many fonts installed.
set_shared_missing_glyph_protection applies it at once when the engine
is built, and otherwise from one helper thread once the scan finishes.
Both read the newest setting under the engine's lock, so a late change
is never overwritten by an older one. Measured the same way: 0.1 ms.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Checkboxes: a pressed box shows it, as in Photoshop (#4476)
Pressing a checkbox, its label or a Brush Settings section row gave no
feedback until the release. While pressed, an unchecked box now darkens
a little and a checked one turns a little less opaque.
widgets::check_box paints the box for both the shared checkbox and the
Brush Settings section list (which drew its own). The section boxes now
get the field fill like every other checkbox.
Tests: section_boxes_show_a_pressed_state,
checkboxes_show_a_pressed_state_from_their_label.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Checkboxes: checkbox-only Brush Settings rows show the pressed state too
main made the rows without settings (Wet Edges, Build-up, ...) one
checkbox across the row; a press anywhere on such a row presses its box.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Third-party action sets that load since 3204231 now play. Four TrueGrit
Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine,
Transparency) go from 223 to 3 unmapped steps of 533 and play end to end.
- .atn import, 108 -> 112 events: Select and Mask (refineSelectionEdge),
Filter Gallery Spatter (GEfc), Ripple, Crystallize; targeting the layer
mask or composite channel, loading the targeted channel, Step
Backward/Forward, removeFromSelection (it silently imported as a plain
select), Pattern Fill layers (by name), nudges in points, Layer From
Background, Grayscale/Bitmap mode objects, Image Size by resolution.
- Engine: layer.translate units pt; layer.select mode remove;
select.loadSelection channel targeted; image.imageSize keepPrintSize;
layer.new.layerFromBackground ifPresent/select; pattern.import data.
New from Clipboard keeps the copied document's resolution (was 72 ppi).
- Filters: Spatter's Smoothness smooths the displacement (torn but
attached edges, as Photoshop's); Crystallize fills a cell from its point
instead of the average, so masks keep hard edges.
- UI: .pat opens/drops into the Patterns panel (no UI import existed).
- Docs: gaps G11, file-format and target-app parity.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(ui): expose custom paper dimensions in Print Settings
* fix(i18n): keep custom paper labels away from catalog tails
* Custom print paper: Serbian rows; drop the env-gated capture test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Brush presets: remember what the New Brush Preset dialog captured (#4490)
* Brushes: a New Brush Preset dialog for every way of making a preset (#4490)
* Brushes: the New Brush Preset strings in the Serbian catalog (#4490)
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Fix: Preview pixelation in Sharpen filters by using full resolution
Sharpen filters (Sharpen, Sharpen More, Sharpen Edges, Unsharp Mask, Smart Sharpen) now preview at full resolution instead of using a downsampled proxy. This fixes the pixelated appearance when enabling preview.
Sharpen algorithms work by detecting and enhancing edges. On a downsampled proxy image with fewer edges and details, the result appears pixelated or blocky. By previewing at full resolution (k=1), users now see an accurate preview that matches the final result.
Fixes#3929
* Fix: Preview issues in edge-detection filters (High Pass, Find Edges, Emboss)
High Pass, Find Edges, and Emboss filters now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews when enabling preview.
These filters work by detecting and enhancing edges:
- High Pass: extracts detail/edge information
- Find Edges: performs edge detection
- Emboss: creates 3D effect by detecting edges
On a downsampled proxy image with fewer edges and details, the result appears pixelated or blocky. By previewing at full resolution (k=1), users now see an accurate preview.
Similar to the Sharpen filter fix (#3929).
* Fix: Preview issues in morphological and motion blur filters
Median, Maximum, Minimum, and Motion Blur filters now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews.
**Median** - Similar to Dust & Scratches, removes noise by replacing pixels with median of neighborhood. Downsampling averages away the speckles it's designed to remove.
**Maximum/Minimum** - Morphological operations (dilate/erode) that expand or shrink features by one pixel. On a proxy, these features become fractional pixels, distorting the preview.
**Motion Blur** - Creates directional blur streaks. Pixel-sized distance parameter (1..2000 px) needs full resolution for accurate preview.
Like sharpen and edge-detection filters, these algorithms depend on pixel-level detail and need full resolution to preview accurately.
* Fix: Preview issues in despeckle and pixel-based blur filters
Despeckle, Box Blur, and Surface Blur now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews.
**Despeckle** - Removes one-pixel speckles/noise. Like Dust & Scratches, it works on per-pixel features that downsampling smooths away.
**Box Blur** - Creates rectangular blur artifacts with fixed pixel-sized radius (1..2000 px). On downsampled images, these rectangular patterns distort because the radius becomes a fraction of a pixel.
**Surface Blur** - Selective blur that preserves edges while smoothing flat areas. On downsampled proxy, edge preservation algorithm may not work correctly since edges have been blurred together.
All three algorithms depend on precise pixel-level operations and produce inaccurate results on downsampled images.
* Fix: Correct preview_factor for fixed-kernel sharpen and edge-detection filters
Remove parameterized filters (Unsharp Mask, Smart Sharpen, High Pass, Median, Maximum, Minimum, Motion Blur, Box Blur, Surface Blur) from FULL_RESOLUTION_PREVIEW.
These filters have pixel-sized parameters (radius, distance) that preview_factor already scales correctly. Forcing them to k=1 makes previews of large blurs on 24-36 MP documents unnecessarily slow without changing the result.
Keep only fixed-kernel filters that have no size parameter:
- Sharpen, Sharpen More, Sharpen Edges (one-pixel unsharp mask kernel)
- Find Edges (one-pixel edge detection kernel)
- Emboss (one-pixel edge-based emboss kernel)
- Despeckle (one-pixel speckle removal kernel)
These use fixed one-pixel kernels with no size parameter, so a downsampled proxy changes their look: edges average away, making sharpening and edge detection invisible. These must preview at full resolution (#3929).
Add tests to verify preview_factor returns 1 for these 6 filters.
Update doc comment to explain why each filter is in FULL_RESOLUTION_PREVIEW.
---------
Co-authored-by: AvrazAkraye <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* feat(vector): add shape from selection with simplify tolerance (#214)
- Add tolerance and simplifyTolerance parameter (clamped 0.5..10.0 px) to select.convertToShape.
- Wire select.convertToShape in filter_dialog::has_dialog to prompt for tolerance.
- Add tests for knot count tolerance scaling and selection requirement.
* rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The engine already took a source layer and Add/Subtract scale and offset,
but the dialog only offered the merged image and no way to set Scale or
Offset, so the frequency separation recipe (Subtract, Scale 2, Offset 128)
couldn't be done from the menu.
Fixes#3965
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Windows and Linux already marked Yes and No. Cancel had no letter, so the prompt now shows (C)ancel and C cancels. macOS still hides the letters and keeps the shortcut. (#4582)
Co-authored-by: Brandon Thomas <[email protected]>
Context menus inherited a 6 pt item gap that belonged to neither button, so a drag across Merge Down or an adjustment list fell into dead space. Popup menus now stack their rows, and Pro rows use a square hover. (#4602)
Co-authored-by: Brandon Thomas <[email protected]>
Effect eyes sat in the same column as layer eyes, so a long list read as extra layers. The eye, its hit target and the label now sit to the right of that column. (#4572)
Co-authored-by: Brandon Thomas <[email protected]>
The contrast glyph put a smaller semicircle inside the ring, which read as concentric rings on a layer thumbnail. It is now one circle with a filled half. (#4561)
Co-authored-by: Brandon Thomas <[email protected]>
Tutar is a sum of money. Filter and adjustment Amount strings now use Miktar, the word for a quantity. (#4350)
Co-authored-by: Brandon Thomas <[email protected]>
The thin scrollbar faded its handle to the same opacity as the track, so scrolling a menu showed a bar with no position. The handle stays opaque and the resting bar is slightly wider. (#4611)
Co-authored-by: Brandon Thomas <[email protected]>
A negative Parent, font or normal-sheet index was cast to a huge usize, which skipped the name fallback and could hide the text. Those indexes now have to fit in a usize, and an infinite dpi uses 72. (#4429)
Co-authored-by: Brandon Thomas <[email protected]>
Feather r50 on 24MP was at ~81ms kernel / ~130ms scenario against the
100ms P27 budget. The cosine-series line() allocated a fresh
n-usize run table per row (thousands of ~48KB Vecs on a 24MP mask).
Reuse one buffer per worker thread via a thread-local; no arithmetic
touched, oracle tests (Photoshop-mapped + direct-Gaussian <=1/255)
prove identical output.
bench_24mp (24MP ellipse, best of 5, x86-64 release):
feather r50: 80.8 -> 70.7ms (-12.5%)
feather r250: 144.1 -> 133.6ms (-7.3%)
feather r1000: 301.7 -> 287.7ms (-4.6%)
Full perf_scenarios P27 with patch: p50 119.3ms (was over by ~30ms,
now over by ~19ms); remaining gap is session/snapshot overhead,
follow-up.
Co-authored-by: Brandon Thomas <[email protected]>
As in Photoshop, a Channels panel row can be dragged onto the footer's
buttons: New Channel duplicates it (the layer mask's row as
"<layer> Mask copy"), Delete deletes an alpha or spot channel, and Load
Channel as Selection loads it. The layer mask's row on Delete asks
"Apply mask to layer before removing?", like its Layers thumbnail.
Colour channels can't be deleted, so Delete ignores them. The dragged
name follows the pointer and the button under it is highlighted.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Free Transform: H and V skew fields in the options bar
Photoshop's Free Transform options bar has H and V skew fields after the
angle; ours stopped at the angle (ui-parity: "Options bar H/V skew
fields | missing", gaps G5).
The bar now reads the box as Photoshop does: the frame scaled (W, H),
skewed (H, V) and rotated about the reference point (`Parts`). Typing a
skew leans the box about the reference point and keeps W, H and the
angle. A box can be read with any V skew, so the session keeps the typed
one (`TransformSession::skew_v`, serde default). Skews stop at ±89°.
A sheared box used to read as taller (H = 100/cos h %); it now reads as
an H skew with H at 100 %. Readouts without skew are unchanged, flips
included.
Docs: ui-parity row done, G5 and target-app-parity notes updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* i18n(sr): translate the Free Transform skew field tooltips
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(picker): the screen color picker no longer crashes on DX12 or traps the overlay on Windows (#4443)
Two Windows-only faults in the Pick screen color window:
- Created always-on-top, the monitor-sized picker window makes the DX12
backend fail swapchain creation (0x887A0001, DXGI_ERROR_INVALID_CALL);
wgpu then panics in Surface::get_current_texture_view. The window is now
created at the normal level and raised once its first frame is painted.
- As a deferred viewport the picker was painted on its own, and the main
window stopped being redrawn while it was up on Windows (OpenGL). A click
or Esc only stored an answer that the main window's tick() never took, so
the overlay could not be left. It is now an immediate viewport, painted
inside the main window's frame.
Verified on Windows 11 / Intel UHD 630: DX12 (default) opens, picks and
cancels; OpenGL picks and cancels; macOS still picks and cancels. Adds a
test that the picker viewport is not built always-on-top.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* Screen picker: raise the window level once per picker window, not every frame
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3344)
* Status bar: preserve keyboard editing and handle fullscreen edge crossings
* widgets: initialise the new FieldOpts field after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Opening a file whose type uses a font that isn't installed said nothing
in the desktop app, so the text silently rendered in a fallback face.
The CLI and automation already reported it; Photoshop warns on open.
The open notice now lists each missing family, with the engine's
existing warning text (type_extra_cmds::missing_font_warnings), for both
the synchronous open and the background open the desktop app uses.
Tests: opening_type_in_a_missing_font_warns,
background_open_warns_about_missing_fonts.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Make Edit Transform Rotate and Skew use their own handle drags.
Rotate and Scale both started Free Transform, so a corner drag scaled the box. Skew only remapped edge handles, so corners still scaled. Give Rotate its own mode that turns the box from handles, and skew a corner along its adjacent edge (#3941).
* Rotate mode: fix the Skew doc comment and rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Serbian catalog (#4436) and the Soft themes (#4378) landed together, so
sr.tsv lacked their four strings and the ui-egui i18n completeness tests failed.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* perf: reuse sampled composite across channel visibility toggles
* test: borrow full composite for sampled-grid oracle
* test: compare the entire channel view after invalidation
* style: keep channel cache construction readable
* fix: format channel test and share band renderer
* fix: invalidate sampled channel composite after edits and previews
* refactor: keep channel rendering optimization isolated from compositor
* test: cover both partial-damage and full channel redraw paths
---------
Co-authored-by: Brandon Thomas <[email protected]>
The preview treated every drag, including Alt plus the right button, as a stroke. That gesture now sets the diameter and density, and a plain left drag still paints. Fixes#4702.
Co-authored-by: Brandon Thomas <[email protected]>
* Sample every visible layer in Content-Aware Fill by default.
An empty layer above the photo used to report a successful fill and stay empty, because only the active layer was sampled. The result is still written to the active layer. sampleAllLayers can be set to false to keep the old behaviour.
Fixes#4026
* Sample other layers in Content-Aware Fill only when asked or the layer is empty.
Reuse the Remove tool's composite, which writes the active layer's format, and leave Photoshop's Sample All Layers checkbox off unless the window has no pixels on this layer. An explicit false still reads only this layer.
---------
Co-authored-by: Brandon Thomas <[email protected]>
A CMYK document with several layers, masks or effects is flattened through the
RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the
document's mode and ICC profile. The composite is now converted back through the
document's CMYK profile (relative colorimetric with black point compensation)
inside each parallel worker, written as four ink channels plus straight alpha at
the document depth, and tagged with that profile (the built-in coated CMYK
profile when the document is untagged). Invalid, non-CMYK or unusable profiles
are rejected. RGB-only formats keep the direct sRGB composite, and a single
plain raster layer keeps its native samples.
This fixes the output mode and tagging only: the RGB roundtrip does not
preserve original ink separations, black generation or CMYK-native blending.
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
inpaint::fit fits each channel as gain · source + bias, the gain the
ratio of the patches' deviations within the level's range and the bias
clamped after it. On nearly flat patches that ratio is noise, so the
gain often sat at its limit; scaling about zero, it moved a bright mean
further than Default's (or High's) bias could bring back, each channel
by a different amount: a tinted blotch in a sky gradient (up to 14
levels at Default, 27 at High).
For nearly flat patches (deviations under about 5 levels) whose means
are already within the bias of each other, the gain is now kept where
the bias can still match the means. Textured patches, and flat ones of
clearly different colours (Content-Aware Move's Color, measured against
Photoshop), are fitted as before.
Fixes#4706
Co-authored-by: Brandon Thomas <[email protected]>
#4217 used rayon's par_iter in ui-egui unconditionally, but rayon is a
native-only dependency there, so cargo xtask wasm failed on main.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* panic_hunt: don't fuzz the print commands into a real printer
file.print and file.printOneCopy spool to CUPS, so every run of the adversarial-params test
printed a blank page on the default printer. The hunt now skips those two ids (their params
are validated like everyone else's; spooling is not what the test is for).
* Guard the print spooler itself and the CI environment
- print_cmds: disable_printing() (a process-wide flag the panic hunt sets) and the
PHOTOCRAFT_NO_PRINT environment variable make spool_pdf refuse before running lp; dryRun
still renders and reports the command. Test.
- CI: PHOTOCRAFT_NO_PRINT=1 and LPDEST=photocraft-no-such-printer, so a regression fails
loudly instead of printing. Documented in docs/development.md › Environment variables.
* Type: type on a path, glyphs follow the curve (#4365)
Add TextShape::Path (path, start offset, flip). The text is laid out as one
point-text line and each glyph is placed rigidly on the flattened path by arc
length, turned to the tangent at its centre; glyphs past the end are hidden and
a degenerate path falls back to straight text. New commands type.attachToPath
(work path or shape layer subpath) and type.detachFromPath. TextShape is no
longer Copy. .pcraft round-trips the new variant.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Automation policy: type.attachToPath/detachFromPath take document vector paths, not host files (#4365)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Themes: Soft Dark and Soft Light (#4368)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* feat: linux caption buttons follow the desktop: layout, style and icon theme
The title bar's minimize/maximize/close buttons now match the desktop:
- Layout and side: GNOME button-layout via the settings portal (live),
KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini.
- Style family (GNOME round, Breeze chevrons, Windows) follows the
desktop, then the GTK theme.
- Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop
lookup with Inherits and hicolor; unlisted theme directories such as
symbolic/ui are searched lazily), rasterized with resvg at the real
pixel size, recoloured and cached; missing icons fall back to the
built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none.
- Theme and portal work runs on a worker thread (10-22 ms per theme).
* feat: linux caption buttons follow the desktop: layout, style and icon theme
The title bar's minimize/maximize/close buttons now match the desktop:
- Layout and side: GNOME button-layout via the settings portal (live),
KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini.
- Style family (GNOME round, Breeze chevrons, Windows) follows the
desktop, then the GTK theme.
- Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop
lookup with Inherits and hicolor; unlisted theme directories such as
symbolic/ui are searched lazily), rasterized with resvg at the real
pixel size, recoloured and cached; missing icons fall back to the
built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none.
- Theme and portal work runs on a worker thread (10-22 ms per theme).
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* i18n(sr): add a Serbian (Latin, Ekavian) UI catalog
Adds sr.tsv with 4,022 rows: every key the i18n-coverage tool measures
plus the union of the dynamic labels the other catalogs translate. The
catalog is written in Latin script, Ekavian; every Serbian locale
(sr-RS, sr-Latn-*, sr_RS@latin and Cyrillic sr-Cyrl-*) resolves to it.
Plurals use the one/few/many rule (plural_russian) with three forms per
@plural entry.
Original translations from the plain meaning of the English labels; no
proprietary localisation resources were used (ATTRIBUTION.md and
LICENSE-translations.txt updated). Tests cover locale resolution,
contextual and id overrides, placeholders, and plurals across teens and
compound counts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Info.plist: declare the Serbian localization
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Dejan Port <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Open .kra and .krz with paint layers (8/16-bit RGBA and gray, any offset), nested
and pass-through groups, names, visibility, opacity, locks, blend modes and
transparency masks, plus the image's ICC profile and resolution. Vector, filter,
fill, file and clone layers and the other mask kinds are left out with a warning;
float, CMYK and Lab documents open as Krita's merged image (preview). Import only:
Save suggests .ora. Checked against 35 Krita 5.2.9-authored files.
Co-authored-by: Scott Campbell <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
RenderVectorMasks keyed compiled masks by the address of the VectorMask it
was given. hiding_mask/effects_source pass temporary clones, whose addresses
get reused, so a later layer received an earlier layer's compiled mask.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The built-in "Pure" group's hues are vivid but not pure (its green is
#28C850), so picking it for text looked dull next to Photoshop's #00FF00.
Add a Photoshop-style "RGB" group (#FF0000, #FFFF00, #00FF00, #00FFFF,
#0000FF, #FF00FF) right after Grays. Pure is kept unchanged so existing
documents, actions and saved swatches that use it keep their colours.
* Content-Aware Move: live preview of the result while the pointer rests (#4385)
A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Some DNG writers state a white level the sensor never reaches (an iPhone
15 Pro Max Bayer DNG states 4095 and clips at 3567). Development clips
each channel at 1 after white balance, so red and blue reached 1 while
green stayed at 0.85 and blown highlights turned magenta. On decode, a
per-colour pile-up clearly below the white level is now raised to it, as
in storytold/lightcraft#586.
* Type: fall back to installed Indic fonts for document text (#4383)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Fix macOS system dialog localization
* Info.plist: declare the Hungarian and Turkish localizations too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The dialog had no colour control at all: the engine defaulted the From
colour to the foreground and the spec's `color` param rendered nothing.
It now shows a Color row (swatch opens the Color Picker, seeded from the
foreground) plus a pipette that arms a canvas eyedropper: the next click
on the image samples the merged composite into the field, like the
Curves picker and Photoshop's Replace Color.
Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
An open tool flyout closed only on a click outside it. Dragging on the
canvas (cropping, painting) isn't a click, so the flyout stayed open
over the work.
Any press that starts outside the flyout and its button now closes it,
as in Photoshop. A press on the flyout itself still keeps it open for
the row it picks on release, and Escape still closes it.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Committing Free Transform on a 300-550 MP layer ran the bicubic warp on the UI
thread: several seconds of a frozen window with no feedback. On a 20000x16000
layer the warp alone took about 6.6 s.
edit.transform is now a background job (edit_job): the warp reports progress per
tile and stops when cancelled. The transform session stays open while it runs,
showing the moving pixels without the box, so the layer doesn't jump back to its
old place until the result lands. The modal progress dialog shows after the
usual delay, and Esc or Cancel cancels the job. A copy or placed layer the
session made is folded into the step, or taken back, in its own document.
Undo and Redo are refused while the commit runs.
Tests and scripts run inline as before.
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Holding a tool button to open its flyout showed that button's tooltip on
top of the flyout, and other toolbar buttons kept showing theirs while it
was open. The flyout is our own Area, so egui's rule that hides tooltips
while a popup is open didn't apply.
The toolbar's buttons (the tools, Quick Mask, Change Screen Mode) now skip
their tooltips while a tool flyout is open or the mouse button is held.
icons::button_with_icon_size_tip keeps the tooltip text as the button's
label when the tooltip is hidden.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
With File name extensions shown in Explorer, the Windows save dialog keeps
a visible extension when the user picks another type: image.psd saved as
PhotoCraft stayed image.psd. Given a bare name, the dialog adds the chosen
type's own extension (rfd sets the default extension, which Windows
updates on every type change).
On Windows the dialog now starts with the stem, and the suggested type
leads the type list with the suggested extension first (so .psb, .fit and
.jpeg stay what they were). Names with a second dot or an upper-case
extension keep the whole name. macOS and Linux are unchanged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Pen: Auto Add/Delete anchors on the active path (#4144)
With the Pen's new Auto Add/Delete option (options bar, on by default as
in Photoshop), a click on a segment of the active path adds an anchor
there and a click on one of its anchors deletes it, one History step
each, instead of starting a new path. The active path is the targeted
vector mask or the active shape layer's path, then the work path.
The end points of an open subpath, a path being drawn, and Shift, Alt
or Cmd/Ctrl held leave the click to the Pen. Hovering highlights the
segment or anchor and the cursor shows + or -, like the Add/Delete
Anchor Point tools, which share the same code (anchor_tools::apply).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Pen: keep the close-drag test off Auto Add/Delete when it redraws over a finished path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The options bar's Mode dropdowns now use widgets::dropdown_wheel_hovered, the wheel stepping the
Layers panel's Blend Mode already has (#1747): down picks the next mode, up the previous one, one
per notch, stopping at the ends, over the button or the open list. The Brush and Pencil list keeps
Photoshop's Clear after Dissolve. Adds my contributors/people.toml entry.
Claude-Session: https://claude.ai/code/session_01RXND3Ywis6CuB5mv6fLYU7
Co-authored-by: megaDon101 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Clone Source: clip the overlay to a shaped brush tip (#4372)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The button draws the flyout triangle, but its list was an egui context
menu, which opens on right-click (or a touch long-press) only. Clicking
and holding with the mouse did nothing until release, which then cycled
the screen mode.
A hold of 0.35 s, the same as the tool flyouts, now opens the list, and
the release that ends the hold neither cycles the mode nor closes the
list. A plain click still cycles (F), and right-click still opens it.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The path overlays sampled every Bezier segment at a fixed 16 points (24
for the Curvature Pen's preview) in document space, so zoomed in a curve
showed as visible straight chords with corners.
curve_points maps a segment's four points to the screen (the view is
affine, so the curve maps exactly) and picks the number of chords from
how much it bends there: the chord error stays within 0.3 px (the bound
the vector crate's flattening uses), capped at 1024 chords. A straight
segment, with handles retracted or along the line, is one chord. Used by
path_lines (work path, shape and vector mask outlines, Direct Selection,
the shape preview, Paths thumbnails), the Pen's path in progress and the
Curvature Pen's preview.
Co-authored-by: Claude Opus 5.5 <[email protected]>
On macOS the brush outline fell back to the app-drawn outline past 255 points, and that
outline trails the pointer by 26 ms on a 120 Hz Mac (syncroot's measurement in #1343): a
1000 px brush at fit, or 300 px at 200 %. AppKit sets no size limit on an NSCursor image, so
macOS now takes the same 2048 cap as Windows. Linux keeps 255 for X servers and hardware
cursor planes.
A circle's bitmap evaluated every pixel of its square, though only a thin ring is painted:
a 1000 px radius took 41.6 ms per bitmap, a hitch on each zoom step or [ / ] resize (on
Windows too). Only the ring's two arcs per row, and the centre mark, are evaluated now, on
a transparent buffer: 6.2 ms (radius 500: 10.6 -> 1.7 ms), identical pixels. App-drawn
tip outlines likewise only write the pixels their segments splat.
A read-only DirectDraw Surface codec on the model of PCX: the top mip
level of a 2D texture, uncompressed (channel masks, RGB or luminance,
8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10
header. Cubemaps, arrays, volumes and other formats are refused with
an Unsupported error naming what was found; every header field is
treated as hostile (checked sizes, limits before allocating, no
indexing on file offsets).
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
On X11 a window manager that doesn't reserve a bottom panel's space maximizes
PhotoCraft to the whole monitor, and the panel covers its bottom. The work-area
check (#315) trusted every maximized window to be inside the work area, so long
menus (Layer, View, Window, Edit at larger UI scales) ran under the panel with
their scroll arrow hidden. A maximized window whose position is known is now
checked like any other; only full screen, or maximized with no position
(Wayland, #3739), uses the whole window.
* Type tool: spelling suggestions in the edit menu (#4370)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Type spelling menu: cap suggestions at 3 so the menu stays short (#4370)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop CS2 predates SoLd: its smart objects have a PlLd (plcL) block
alone, with the embedded file in a global lnkD block. Import only read
SoLd/SoLE, so such layers got an empty source id and Edit Contents said
"the smart object's contents are unavailable".
Parse PlLd from the Adobe PSD spec (unique id and the 8 corner doubles).
PlLd stores no source size, so the transform takes it from the embedded
file's header (linked::source_size_probe: PSD, TIFF, PNG and JPEG
headers, any other image decoded flat by photocraft-codecs). It never
imports the file as a document: a layered TIFF can carry PSD layer data
whose lnkD holds another file, so that would nest without bound. Without
the file, 100 % scale is assumed. source_geometry moves to linked.rs. Non-parallelogram corners give the projective placement as
for SoLd. An unreadable PlLd is reported as a warning.
The PlLd corners are the same placed quad as SoLd's Trnf, so they frame
the same source-space box (#3677, blocks::placed_frame): the bounding
box of a single-patch custom warp's 16 control points, which may spill
past the source, else the source rect. corners_affine now maps such a
frame onto the corners and is shared by parse_smart and plld_transform,
which is the inverse of smart_map::quad_points (what plld_bytes writes):
affine corners frame placed_frame(warp), projective ones the source
rect. A custom warp's box needs no source size, so a spilled mesh is
placed right even without the embedded file. corners_affine refuses
non-finite corners or an empty frame (identity, as before).
CS2 can warp a smart object, and PlLd keeps that warp after the corners
(u32 warp version, then a versioned warp descriptor). It is read with
the same code as the SoLd warp (warp_of), so a re-render from the now
available source (Free Transform, Image Size, mode changes) stays
warped. A stored warp that can't be read leaves the contents
unavailable, keeping the warped pixels, with a warning.
An edited PlLd-only layer (moved, transformed, warped, filtered or given
other contents) is saved like any other smart object, SoLd plus a
regenerated PlLd, with its current placement; an unedited one keeps its
record verbatim, as does one whose file isn't embedded (its id is never
read as a disk path). plld_edited compares against plld_transform with the
record's own warp, so it uses the same frame as import and as the
writer. A layer with both SoLE and PlLd is read from SoLE.
Tests: PlLd round trip and malformed input (truncation, bad magic,
oversized Pascal length, NaN) in blocks.rs; PlLd warps round trip
through plld_bytes (preset, custom mesh, identity, none) and unreadable
warps are reported as such; a PlLd whose custom mesh spills past the
source places like the equivalent SoLd, with and without the source
size; PlLd-only layers with a missing file, a broken record, a warp or
an unreadable warp; a spilled-mesh PlLd-only layer imports with the
SoLd layer's placement and an unedited one saves its PlLd verbatim (no
SoLd) and reopens in place; and engine tests that rewrite an export
into the CS2 layout (no SoLd, lnkD with version-2 liFD items) and check
transform, contents, re-render, Edit Contents, and that a warped smart
object re-renders warped.
source_size_probe has header tests for each format and malformed JPEG
marker walks.
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(engine): relative leading, baseline shift and tracking steps in type.setStyle
`leadingBy`, `baselineShiftBy` and `trackingBy` move every character in the
range from its own value, so mixed values keep their differences. Auto
leading starts from the value layout uses (the paragraph's auto-leading
percentage of the character's size), results clamp to Photoshop's ranges,
and non-finite or oversized steps are refused as bad params. `leadingBy`
follows `metricsAsShown` like `leading`.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(ui): Photoshop's type formatting keys while editing text
While a Type tool edit is active, Shift+Cmd+K/U/B/I and Shift+Cmd+/ toggle
All Caps, Underline, Faux Bold, Faux Italic and Strikethrough on the
selection (off when every selected character has it, else on);
Alt+Up/Down step leading by 2 pt and Shift+Alt+Up/Down baseline shift by
2 pt (10 with Cmd); Alt+Left/Right over a selection track it by 20 (100
with Cmd), a collapsed caret still kerning; Cmd+Alt+Shift+H toggles
Hyphenate for the paragraphs touched. Each press is one type.setStyle
history step, and the keys no longer fall through to Color Settings,
Desaturate, Auto Color or Inverse while typing; outside an edit those
shortcuts work as before.
A collapsed caret in non-empty text changes nothing: Photoshop styles the
next characters typed, which the document model can't hold apart from the
text. In empty text the style becomes the typing style.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Camera Raw Filter: save and load settings as .xmp (#4274) [WIP]
* Camera Raw Filter: Save Settings… and Load Settings… (.xmp) (#4274)
Adds Save Settings… and Load Settings… to the Camera Raw Filter dialog's
footer. They write and read Adobe Camera Raw settings files: the crs:
namespace on x:xmpmeta/rdf:RDF/rdf:Description, as attributes or elements.
photocraft_io::camera_raw_xmp maps every control the dialog has:
- Basic (Exposure2012 … Saturation, IncrementalTemperature/Tint)
- the parametric and point tone curves (ToneCurvePV2012[Red|Green|Blue])
- the HSL mixer
- Color Grading (including the SplitToning* names for the shadow and
highlight wheels)
- sharpening and noise reduction
- grain
- post-crop vignetting
Loading applies only what the file holds over the current settings, as
Camera Raw does. Values are clamped to the slider ranges. Unsupported
settings (lens, calibration, geometry, defringe) are ignored, and the
status bar counts them when they aren't neutral. Malformed files fail
with a message and leave the settings unchanged. The control channel
takes {"ui":{"saveSettings"|"loadSettings":true}}.
Translations are added for all 17 catalogs.
Fixes#4274
* Add Turkish and Hungarian rows for the Camera Raw settings strings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Draw the active layer's storage tiles over the canvas: a faint
TILE_SIZE grid over the document, every stored tile filled and
labelled tx,ty, tiles with identical pixels in one colour per group
and unique tiles in blue. It shows how sparse a layer is, which tiles
copy-on-write shares, and which ones .pcraft saves once.
It has no menu item and isn't a View > Show item. The control channel
and MCP turn it on with ui.set {"tileGrid": true}; ui.inspect reports
it as view.tile_grid, and View > Extras doesn't hide it.
Grouping hashes each distinct tile allocation once, in parallel (or
serially while jobs hold the pool, #1554), cached per document
revision and active layer. A 36 MP layer of all-different tiles groups
in 3.9 ms (8-bit) and 7.5 ms (16-bit) on 24 cores in release; the
ignored tile_grouping_36mp test reproduces this.
Also documents tileGrid in docs/control-protocol.md and
docs/development.md, and adds my contributors/people.toml entry.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
A large scale (e.g. 200x on a thin column) made the warp fill every tile of
a result up to 2^21 px on a side, enough to exhaust memory. Free Transform
now prices each warp before building anything and refuses with an
allocation-style error ("...; the document was not changed") once a result
would exceed MAX_TRANSFORM_PIXELS (2^30, the same bound as Puppet Warp's
MAX_WARP_PIXELS). Only the result's size counts, so moving content far off
the canvas still works, and nothing is clipped.
The check covers the layer, a smart object's cache and filter mask, linked
masks, a lone mask/channel target and the selection outline, and runs over
the whole layer tree before any layer is changed. Transform Selection gets
the same check. The transform box now stays open when a commit is refused.
Part of #3773
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Type: faux bold keeps antialiased edges at small sizes (#4130)
Faux bold drew each glyph at 8 offsets into one signed-area coverage buffer, so edge pixels from the copies summed and saturated to full coverage (hard, pixelated stems, about 2.2x the ink at 12 px). Each offset now gets its own buffer, merged with max.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Type: faux bold ring radius tuned so stems keep their weight (#4130)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
layer.layerStyle.gradientOverlay, layer.layerStyle.stroke (gradient) and
paint.gradient take "method" (classic|perceptual|linear|smooth) and
"smoothness" (0..100, default 100). The stops are baked with the same
model PSD import uses (io::gradient_bake, now public as bake_gradient and
GradientMethod), so the compositors' linear interpolation reproduces
Photoshop's curve. Without a method the stops stay as they were.
Measured in Photoshop 27.11 (max level error vs main -> this change):
Gradient Overlay linear/reflected styles, any angle/scale/reverse 12 -> 0-1,
radial/diamond 16 -> 4-5; gradient Stroke 12 -> 2-4; Gradient tool
black -> white Classic 13-14 -> 2-3; red -> blue Classic 12 -> 1,
Perceptual 83 -> 2, Linear 64 -> 4, Smooth 83 -> 3. A 3-stop Classic
gradient improves only 14 -> 10 (the multi-stop smoothness spline is a
separate follow-up).
Co-authored-by: Claude Opus 5.5 <[email protected]>
* engine: remember the last Save for Web settings in the preferences
* engine: test that a complete export remembers its settings for the next launch
* ui: seed the Save for Web dialog from the remembered preferences
* ui: Export As opens with the settings it was used with last
* scorecard: FILE-215-18 done, the export dialogs remember their settings
* docs: scorecard, File compatibility 7 done of 27
* Regenerate the scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Clippy 1.96 flags the `retain` predicate that strips stale content blocks
from pixel layers on PSD export (`clippy::nonminimal_bool`), which breaks
`cargo clippy -- -D warnings` on current stable.
Rewrite it as a single negated disjunction. The four terms, their order and
the short-circuiting are unchanged, so the same blocks are kept and dropped.
CMYK samples are ink (1 = full ink), but both filters assumed light
polarity. Find Edges wrote 1 - edge, so flat CMYK areas became 100% ink
(black) instead of white; Solarize inverted channels above 50%, i.e. the
shadows. Both now use fxutil::subtractive(ctx), like Trace Contour: Find
Edges writes the edge strength as ink, Solarize inverts channels below
50% ink. Alpha stays untouched. RGB, Grayscale, Lab and other modes are
unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Image > Mode > Bitmap (halftone) passed its angle through unchecked: 1e39
became inf as f32, the screen rotation turned it into NaN and an almost
white bitmap was recorded in history; 500 was accepted although the
params doc (and Photoshop's Halftone Screen dialog) says -180..180.
The angle must now be a finite number in -180..=180 and the frequency a
finite number (still clamped to 1..999 lines/inch); otherwise the command
returns BadParams before touching the document.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
file.exrParts lists a part without a name attribute as "part <index>", but
file.openExrParts only matched parts whose name attribute equalled the
requested name, so "part/part 0" failed with "no flat part named part 0".
One helper now gives the display name for the listing, the lookup and the
layer name; a named part still wins over an unnamed one with the same
fallback name.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Each Cryptomatte RGBA set stores two (ID, coverage) ranks, (red, green)
and (blue, alpha), but only red/green were read, so every second rank
was lost and mattes got holes at edges and overlaps. Suffixes were also
matched case-sensitively, missing Blender's .R/.G/.B/.A.
Stream channels now carry a rank (0 red/green, 1 blue/alpha), suffixes
match in any case, and decode stores each channel at slot*2+rank
(bounds-checked), still sorted by coverage. The allocation check counts
four channels per set.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
rounded_rect took the absolute width/height without moving the origin, so
shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an
ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and
(y, h) for negative sizes instead. The Shape tool drag already normalises its
rect, so interactive drawing is unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became
inf (hard edge, null in info, .pcraft save refused) and any huge value was
accepted. It now takes the same finite 0..=1000 px range and error as
layer.layerMask.edit, checked before the edit so a bad value leaves the mask
and history untouched. The params doc says so.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The font menu's preview samples, family list and localized names were
cached on fonts::generation(), which any FontDb moves, and the previews
also on the served catalog's generation. Other engines registering fonts
(tests running in parallel) re-rendered every visible sample each pass,
and families covering Arabic asked for a repaint, so Harness::run never
settled: options_font_family_preserves_peer_faces_and_batches_layer_flags
and the font picker tests failed intermittently with "exceeded max_steps".
- photocraft-text: fonts::shared_generation(), moved only by the shared
engine's FontDb (FontDb::mark_shared from new_shared).
- ui-egui: families(), localized_families() and the preview cache key on
it; previews no longer key on the served catalog (a served family's
sample appears when its fonts register, which moves the generation).
Co-authored-by: Claude Opus 5.5 <[email protected]>
The warning triangle on a comp row sent layerComp.updateWarnings
{"clear":true}, which dropped the stale states of every comp. The
command now takes an optional "comp" (id or name) that limits both the
report and the clear to that comp; an unknown comp is an error and
changes nothing. Without "comp" it still acts on every comp (Clear All
Layer Comp Warnings). The row icon sends its own comp id.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
32-bit samples can lie outside 0..1. The RGB readout clamped them, but
device_cmyk did not, so a pixel at 1.5 read K:-50% and negative values
gave C/M/Y above 100%. Clamp each channel to 0..1 (NaN reads as 0) before
the conversion; this fixes both the Color Sampler and the Info panel.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
#3132 refused type.edit on a Lock All layer, but type.insertText,
edit.checkSpelling "change", Paste Lorem Ipsum (and the other
type_extra_cmds::with_text edits) and Find and Replace Text "change"
on a layer locked after the find still changed its text and recorded
history. The check is now one pub(crate) helper,
type_cmds::check_text_unlocked, called by every one of these paths
with the same "...is locked" error as type.edit.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* feat(ui): add GRD import option to gradient preset picker
* Translate the GRD import strings into Vietnamese, Turkish and Hungarian
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* WIP: Object Selection hover preview (#4087)
* Object Selection: Object Finder hover preview and click-to-select (#4087)
Hovering with Object Selection highlights the object a click selects,
computed off the UI thread after a short rest and cached per document
pixels; a click commits it via select.object {point} honouring
add/subtract/intersect; dragging keeps the rectangle mode. Adds an
'Object Finder' options-bar toggle (default on, persisted) with
translations in every catalog.
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298)
An adjustment layer has no pixels, but once its icon thumbnail was clicked (or it was
reached with Alt+[ / Alt+]) the Layers panel no longer targeted its mask, so Edit > Fill
(Shift+F5) and the fill keys were greyed out, and the Paint Bucket and brushes failed with
"not a pixel layer". As in Photoshop, painting and filling an adjustment layer now always
edit its mask; the panel's target (brackets, trash, Delete) is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Keep the web read_dropped doc comment on its function
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(ui): keep brush settings section lock icons permanently visible (#4278)
* test(ui): verify brush settings locks stay visible and toggle on click (#4278)
* Format the lock-icon test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(ui): add Brushes button at top of Brush Settings control list (#4265)
* test(ui): verify Brushes button at top of Brush Settings opens Brushes panel (#4265)
* fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172)
* test(ui): verify Gradient tool icon rasterizes a left-to-right ramp (#4172)
* fix(ui): make clickable tab and radio labels non-selectable with pointing hand cursor (#3972)
* test(ui): verify New Document preset tabs show pointing hand cursor instead of text caret (#3972)
The options bar's Maintain Aspect Ratio link only affected typed W/H values; dragging a
corner or edge handle still scaled proportionally, so unlinking seemed to do nothing. As
in Photoshop CC 2019 and later, the link now also sets the drag default: unlinked, corner
and edge drags stretch freely and Shift keeps the proportions. Linked (the default) and
the legacy preference are unchanged. The link state moves from egui temp data into
UiState (transform_unlinked), next to the transform session.
Co-authored-by: Claude Opus 5.5 <[email protected]>
In the Studio-layout themes (Studio, Studio Light, Breeze, Adwaita,
Solarized) Properties is a floating card over the canvas. The card
returned early unless the active layer was an adjustment or fill layer
or a mask was targeted, so for a pixel layer, the Background, type or a
group nothing was drawn, although Properties was on (the rail button
showed it). The Pro theme's docked panel shows all of those.
The card now renders the docked panel's body (properties_body), so both
show the same content: the Document properties for the Background or no
selection, layer, adjustment and mask properties. Its title bar, hide
button, position and drag are unchanged.
Tests: floating_properties_card_shows_document_properties_for_the_background
and floating_properties_card_shows_pixel_layer_properties (Studio and
Breeze Dark) failed before the fix.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The section list indented every row without an enable box by the box's
width, except the first, so Brush Tip Shape started in the checkbox
column while Smoothing, the other box-less row, lined up with the names.
Photoshop indents Brush Tip Shape too. Every name now starts in the
same column.
Test: section_names_line_up_whether_or_not_they_have_a_box checks the
painted x of every section name (Brush Tip Shape was 20 px left before).
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Canvas: a tool drag past the edge auto-scrolls the view (#4183)
Dragging a marquee, lasso, stroke or crop off the visible part of a zoomed
image now scrolls toward the pointer, as in Photoshop, and stops at the
scroll limits. An axis where the image fits does not scroll, and an
overscrolled view is not pulled back.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VFAjTZQw6MTyhfpQqjAfoW
* Fix rotated canvas auto-scroll limits
---------
Co-authored-by: Claude <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
* Control: a timeout says whether the command ran (#3662)
A request still queued when its deadline passes is dropped before
dispatch, but it answered with the same text as the transport giving up
on a command that may still apply. The two now read differently: "timeout:
not run ... safe to re-send" and "timeout: no reply in 60s ... may still
apply", both keeping the `timeout` prefix machines match on.
* Control: a transport that gives up knows whether the command was dispatched (#3662)
The transport and the UI thread share the reply deadline, and the
transport's wait runs out first, so the UI thread's "not run" reply
almost never reached a client. A request now carries a dispatch flag
settled once between them: the UI thread claims it before running, the
transport abandons it when it stops waiting. The transport then replies
"not run" for a request that will never run, and "may still apply"
only for one that was dispatched; a request given up on is never run.
returned_non_graphics_errors_do_not_trigger_backend_recovery failed at
random in full `cargo test` runs: "gpu-error: ...", left: false,
right: true. Its gpu-error case drops the sentinel, which leaves the
marker file in place, and calls `Sentinel::begin` again for the same
directory. Tests in monitor_profile.rs spawn processes in the same
binary. A child process started while the marker is open gets a copy
of its file descriptor, and the marker stays locked through that copy
until the child's exec closes it. When `begin` runs in that window, it
returns `Previous::Busy` instead of the crashed marker, and the
assertion fails.
sentinel_detects_a_crash_and_clears_on_success has retried `begin` for
this since 3b7870be (#289). The retry moves into a test helper,
`begin_when_unlocked`, which also runs where
returned_non_graphics_errors_do_not_trigger_backend_recovery and
a_failed_cpu_start_does_not_pin_later_starts start again after
dropping or finishing their sentinel. In
a_failed_cpu_start_does_not_pin_later_starts the CPU plan finishes the
sentinel, which removes the marker, so the helper's first `begin`
finds the lock free; the helper covers that test's branch for a plan
that keeps the marker.
A new test, begin_when_unlocked_waits_for_a_briefly_held_lock, takes
the marker's lock through a second handle after the sentinel is
dropped, releases it from a thread after 50 ms, and checks that the
helper returns the crashed marker. It fails when the helper calls
`begin` only once.
* feat: resize Save for Web previews and added palette editing for Gif/PNG8
* Save for Web: add Turkish and Hungarian rows for the preview navigation hint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
#4093 changed the Auto-Select default to Group, as in Photoshop. The two
layout_perf tests that click layers inside groups with Auto-Select on
expected the layer under the pointer, so they now select the group and
fail on main. They set the target to Layer explicitly, which is what
they test.
layer.layerStyle.globalLight cast its params straight to f32, so an angle
of 1e39 became inf and rem_euclid turned it into NaN, which was stored,
recorded as a history step and fed to every effect using global light.
Angle and altitude are now read as f64 and rejected with BadParams when
non-finite or beyond f32 range, before the edit. In-range wrapping and the
altitude clamp are unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The writer capped names with encode_utf16().take(1024), which could end
on a lone high surrogate; it now uses io::utf16_units, like .aco/.ase,
so the cap falls on a character boundary.
The reader's "count left out the null" check looked at the last unit
kept, not the last unit read. For a name of 1024+ units the counted null
is read but not kept, so a following 0x0000 (an empty contour's point
count) was swallowed and the stream shifted. It now checks the last unit
actually read.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
With a selection, Edit > Transform > Warp warped only the selected pixels
but the whole linked layer mask, so the unselected mask no longer lined up
with the unselected pixels. Warp now limits the linked mask to the selection
through transform_cmds::move_gray_selected, as Free Transform and
Perspective Warp already do (#3083); layers that move whole still take their
whole mask along. Adds a regression test driven by edit.transform.warp.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Every video-layer edit ran `work_end = work_end.max(duration)`, so
Show Altered Video, Restore Frame, Interpret Footage and the other frame
edits reset a narrowed work area to the full duration. A new
`grow_duration` helper, used by every video edit and by New Video Layer
from File, grows the timeline and moves the work-area end only when it
reached the old end; a narrowed work area stays where the user put it.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
paint.bucket and paint.gradient read `opacity` as f(p,"opacity",100)/100
with no range check, so 200 became a coverage of 2 and overshot the blend
(#808080 on white painted near-black). Both now use the Magic Eraser's
param reader: the value is clamped to 1..100, and a non-numeric opacity is
BadParams before any edit or history step.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: the JSON-lines server's `index_of` read `index` with `as_u64`, so a
string, negative, fractional or boolean index became None, which means "the
active document". `doc.close {"index":"0"}` therefore closed the active
(possibly unsaved) document instead of failing. The MCP server types the param
as Option<usize> and already rejects these values.
Fix: `index_of` returns Result<Option<usize>, AutomationError>. A missing or
null index still means the active document; a non-negative integer is used;
anything else is a bad request ("`index` must be a non-negative integer").
All doc.* call sites propagate the error with `?`.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: the layer row's reserved width and indicators, and the Studio
themes' lock button, treated a layer as locked only for transparency,
position or all. A "Lock image pixels" or artboard lock left the row
without its lock icon, and the Studio button drew "unlocked" although
clicking it unlocked the layer (simple_lock_toggle checked all five).
Fix: add Locks::any() in photocraft-doc and use it in all four places,
so every lock shows the icon, as in Photoshop. Tests cover each lock on
the row (reserved width and Indicator::Lock) and the Studio button state.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: the options bar sent markerSize/labelSize as f32 JSON floats
(8.0) and count.setGroup read them with as_u64, which is None for floats,
so the command returned Ok and changed nothing.
Fix: count.setGroup accepts whole numbers sent as floats (UIs, agents and
actions send them), rejects fractions, negatives and non-numbers with
BadParams, and clamps in u64/f64 so huge values no longer wrap through
`as u32`. The options bar now sends integers.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: run_on_layer in distort_cmds.rs added the smart filter and
returned for a smart object before it called check_locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took
the distortion and a history step.
Fix: check the pixel and full locks (not position) before recording the
smart filter, as regular filters do, and use the filters' error wording.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
After Replace Text the find cursor collapsed to the end of the replacement.
A backward search takes the last match ending at or before the cursor, so
when the replacement still matched (e.g. "cat" -> "Cat", case-insensitive)
it found the replaced text again and never reached earlier matches.
Collapse the cursor at the start of the replacement when searching backward
(the end when forward), mirroring how forward search skips it.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
layer.pickAt (Auto-Select, the canvas right-click layer list and the Move
tool's rollover highlight) read the raw layer-mask pixel, while the
compositor uses LayerMask::value, which applies density (1 - d*(1 - v)).
A hide-all mask at density 0 shows the layer in full, yet the click went
through to the layer below.
Read the mask through LayerMask::value, as the compositor does (it also
returns 1 for a disabled mask). Regression test covers the click and the
list form.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: layer_identity, the effect-map cache key, hashed pixels, masks,
fill opacity and effects but not layer.advanced or layer.blend_if. Turning
Transparency Shapes Layer off (which makes the whole layer the effect shape)
or changing a styled group child's Blend If (which changes the composite the
group's shape comes from) left the key and region unchanged, so stale maps
were reused: a stroke kept rimming a hole until the cache was purged.
Fix: hash layer.advanced and layer.blend_if in layer_identity (recursing into
group children). Regression tests compare a warm render after each edit with
a cold render after purge_effect_cache.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: SwatchColor::from_param checked that each value was finite as
an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue
then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the
prefs file wrote it as null, and on the next launch load_presets_json
failed to parse the whole presets blob, so every user gradient, style,
shape, tool/document preset and swatch fell back to the defaults.
Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for
every colour model.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: parse_entries sets the cursor to start + len after each Anno
entry. An entry length below 8 (its own u32 plus the type) left the cursor
on the same entry, so a count of 0xFFFFFFFF re-read it about 4.3e9 times,
pushing an entry each time until allocation failed and the process
aborted (not a panic, so the import guard could not catch it).
Fix: reject an entry length below 8, so the block is reported unreadable
and kept verbatim (the #944 behaviour); every entry now advances at least
8 bytes. Regression test covers lengths 0/4/7 for sndA and txtA entries
plus the hostile 16-byte block.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Releasing a Quick Selection stroke on a 24 MP photo froze the canvas for
0.2-0.5 s: the growing-window loop cut a window, found the selection
touching its side, doubled the margin and started over, and on real photos
every stroke needed 2-4 sizes.
The window sizes are independent, so the next 4 are now cut at once (rayon)
and the first in growing order that stays clear of its sides is kept: the
same result as one after another. Once one is kept, larger windows of the
batch stop through the cancel hook grid_cut_impl / maxflow_with already
have, now also polled before the graph is built, before the max-flow and
between the coarse and fine levels of cut_levels. wasm32 keeps the
sequential loop.
36 strokes on a 6000x4000 photo: masks byte-identical to main, 21.9 s ->
13.9 s (-37%); small 30 px clicks +8 ms (setup of larger windows).
Co-authored-by: Mohammed <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
In Photoshop, holding a tool's letter gives a temporary tool: releasing the
key returns to the tool you had. A tool letter still switches tools when
pressed; if the key then stays down for 300 ms or more, or the canvas is
used while it's down, the switch was temporary and the release gives the
previous tool back with its options. A quick tap stays a permanent switch.
- Shift+letter (cycling the group) and Alt+letter never spring; nor do
letters while typing in a field, editing type or under a dialog.
- The current group's own letter cycles the group, as before.
- A drag in progress when the key comes up finishes with the temporary
tool; the previous tool returns once the button is up.
- The newest held letter wins; the original tool returns when all are up.
- A tool picked another way while a letter is down (the toolbar, a menu,
automation) stands.
- Key repeats of tool letters are dropped instead of cycling the group.
- Z is one case: hold Z to zoom, add Alt to zoom out.
The state is UiState::spring, readable over the control channel and never
restored from settings. Tests that pressed tool letters without releasing
them now release them, as a keyboard does. docs/ui-parity.md and
docs/gaps.md mark #2672 done.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Unlocked rectangle corners now each use their own radius and type (top left,
top right, bottom right, bottom left) instead of all following the first one,
and straight corners are cut across instead of rounded. Same fix as
VectorCraft's copy of the reader (storytold/vectorcraft#1362).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Affinity import: pie shapes with huge angles no longer hang the reader
The reader raised the end angle of a pie shape (`ShPi`) by `TAU` in a
loop until the end angle reached the start angle. Both angles come from
the file. The loop ran about `(AngS - AngE) / TAU` times, and it never
ended once adding `TAU` no longer changed the end angle: with
`AngS` = 1e308 and `AngE` = -1e308, reading the document did not return.
The reader now computes the pie's angles in constant time. The start
angle is `AngS` modulo `TAU`. The sweep is `AngE - AngS` capped at one
turn, or its remainder modulo `TAU` when `AngE` is below `AngS`. When
`AngE` is below `AngS` and that difference overflows to negative
infinity, the sweep comes from the remainders of the two angles.
The change and its two tests come from Riccardo Balloch's commit
79c5d673 in storytold/vectorcraft, which fixed the same loop in
VectorCraft's copy of this reader. Here the tests read the document on
a thread of their own and fail when reading takes more than 20 seconds.
Co-authored-by: Riccardo Balloch <[email protected]>
* Contributors: add dotCooCoo (Robert Lee)
---------
Co-authored-by: Riccardo Balloch <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
#3467 traces a shaped tip's outline but scales it by the screen radius
alone, so on a rotated or flipped canvas (R, View › Flip Horizontal) the
cursor kept the document's orientation while the stroke did not.
The outline now goes on screen through the view's own axes:
ViewXform::map_vec maps a document vector (pixel aspect, rotation, flip,
zoom), and brush_cursor::tip_axes gives one tip radius along the
document's x and y. A plain view gives the same points as before.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(algo): reject oversized Magic Eraser masks before multiplying dimensions
* Format the oversized Magic Eraser mask test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Type: Space inserts a space while editing type
Space was grabbing the Hand tool during canvas type edit even though
Photoshop keeps Space typing a space. Skip temporary-tool holds when
ui.text_edit is active.
* Credit @Hevarh1 with sync_github_name in About
* Format
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Use checked mask indexing so incomplete or extreme regions return zero coverage instead of panicking or wrapping.
Validated with the full algo suite, independent native and Wasm regressions, and green PR checks.
* Refresh Liquify's zoomed preview when a stroke is undone.
Above 100% zoom the dialog shows a source-resolution crop. Undo rebuilt the proxy and left that crop untouched, so the stroke stayed on screen until the next zoom.
Refs #4100
* Format
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Stylus: a pen held still is not a press-and-hold right click
On Windows a pen reaches egui as touch events, and egui takes any touch
contact held still past max_click_duration (0.8 s) for a touch screen's
press-and-hold: a secondary click (context menu) that also drops the drag.
Holding the pen still for a second opened a context menu.
PenContacts tells a pen contact from a finger in raw_input_hook (only a
pen hovers, and a pen reports pressure) and turns the press-and-hold
timeout off while one is down, through the frame it lifts in, so a long
still press releases as a tap. Fingers keep press-and-hold.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Format
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Releasing a Clone Stamp or History Brush stroke rendered the whole
stroke again, although the live stroke had already drawn exactly those
pixels. The canvas now offers the live stroke to its commit as the Brush
does (#2131), through the same LiveCommit: a commit with the same
command, document revision, params and points takes the live surface
and reports fromLive; anything else renders as before.
LiveRetouch renders only the position and pressure of its points, which
is all the commands parse, and the offer is refused when the brush, layer
or clone source changed during the stroke (the revision doesn't cover
them). The Healing Brush, which blends on release, is left out.
* feat(codecs): import XPM3 images with transparency (#3692)
* Correct XPM test expectation for green palette name
* feat(codecs): decode XPM2 plain-text image files
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(codecs): import XPM3 images with transparency (#3692)
* Correct XPM test expectation for green palette name
* feat(codecs): resolve full X11 rgb.txt color names in XPM
* XPM: fix Format::ALL length after DPX, reject '+' in hex colours, fix non-ASCII byte-string test, fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Control+Alt / Alt+right-drag HUD measured the drag in document axes and
only corrected for View > Flip Horizontal, so at 90 degrees a horizontal drag
changed the hardness and at 180 degrees dragging right shrank the brush.
Map the anchor and pointer to screen with the active ViewXform and divide by
its zoom: identical numbers at rotation 0 (flipped or not), screen-relative
when rotated, as in Photoshop. Without a view the old behaviour remains.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
ViewXform::to_screen mirrors x after rotating, so with View > Flip
Horizontal on a larger rotation turns the image counter-clockwise on
screen. The Rotate View drag and the trackpad rotate added their screen
delta unchanged, so the image spun against the pointer. A small helper,
rotate_view::screen_delta, negates the delta in a flipped view and serves
both paths; Shift still snaps the final angle.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
finish_gesture passed the device-pixel zoom (current_zoom) to
shape_dialog::is_click, whose 3-point slop is in egui points, so at
pixels_per_point 2 the tolerance halved and a slightly jittery click drew
a tiny shape instead of opening Create Rectangle/Ellipse/... Pass
point_zoom(), as the marquee already does.
Regression test: a 2 pt jitter at ppp 2 and 100 % opens the dialog and
adds no layer.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The shared adjustment slider rows rounded to 0.01 for ranges of 10 or
less and to whole numbers otherwise, so Exposure (-20..20) snapped to
whole stops: 0.5 became 1. Each row can now carry its own decimals:
Exposure keeps 2 and Offset 4, as in Photoshop, and their fields show
that many places. All other rows (Gamma, Posterize, Threshold,
Brightness/Contrast, Vibrance, Hue/Saturation ranges, Photo Filter
density) keep the old rule.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The Width/Height fields capped non-pixel units at 30000. egui's DragValue
clamps the shown value to its range when drawn, and number_edit reports that
as an edit, so a 40000 px document at 72 ppi shown in points opened as
30000 pt and OK resized (Image Size) or cropped (Canvas Size) it with no
edit. The bound is now the engine's 300000 px limit converted into the
current unit, never below 30000.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
A shift-line between two clicks inside the same pixels changed with the sub-pixel position. Pencil and pencil-mode Eraser clicks now use the same grid centre as the dab.
Fixes#4071
The rotate zone requested CursorIcon::None, which removes the system cursor. Crop already draws a turn arrow for that case. Free Transform now uses the same arrow.
Fixes#4050
Ctrl+Z was ignored whenever a slider's text edit had focus, so a stroke could not be undone from the keyboard. Other Liquify keys still stay out of that field. The bracket keys also step the brush by at least one pixel, so a 2 px brush can reach 1 px.
Fixes#4027
Root cause: the effect rows' double-click handler called
layer_style::open, which always reads the active layer. Double-clicking
an effect row of a layer that wasn't active opened (and on OK edited)
the active layer's style instead.
Fix: new layer_style::open_for_layer selects the row's layer with
layer.select first, as the row's right-click menu does, then opens the
dialog. Regression test covers two layers with only the inactive one
carrying an effect.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The dialog's values_of() started each Inner Glow from the factory
defaults ("source": "edge") and never loaded the effect's own source,
so a Center-source glow (from a command or a PSD) showed Edge, previewed
as Edge and became Edge on OK, since the engine's overlay applies the
"source" key it receives.
values_of() now loads the source for Inner Glow. Regression test:
inner_glow_center_source_survives_ok.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Destructive adjustments (pixels::adjusted) and the RGBA filter helper (adjust_cmds
process_surface) each had the same loop mixing the edited pixels back over the original by
selection coverage, looking the selection's tile up for every pixel (sample_channel, a
BTreeMap lookup per pixel). They now share pixels::mix_through_selection, which reads the
coverage one row at a time with sample_row_strided: one tile lookup per tile per row, and a
row-sized buffer.
Results are unchanged: a new test compares the helper with the per-pixel sample_channel mix,
bit for bit, for 8-bit, 8-bit-with-alpha and 32-bit selections over 8- and 16-bit layers,
across tile edges and untouched (default) selection tiles.
Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf
Co-authored-by: Claude <[email protected]>
effect_maps looked the environment variable up (taking the process environment lock) for
every effect-map lookup, i.e. per styled layer per render tile. It is read once, like
PHOTOCRAFT_FX_TRACE in the GPU compositor.
Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf
Co-authored-by: Claude <[email protected]>
move_layers cloned the document on every layer.translate, Align and Distribute only to read
the pre-move state while type and shapes re-render. Session::edit already clones the
document for the edit, and the callers hold the starting state (an Arc), so they pass it in.
The clone copies the layer tree and every surface's tile map (pixel tiles themselves are
shared), so its cost grows with the layer and tile count: a nudge in a document with many
layers or very large layers goes from two such clones per command to one.
Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf
Co-authored-by: Claude <[email protected]>
compose::bounds caches per-tile content bounds and drops entries of dead tiles when it holds
more than 65,536 of them. Once more than 65,536 tiles were alive (a large document plus its
history), every call (every render tile, every GPU plan) walked the whole map again and kept
it all. The sweep now runs past a high-water mark: twice the entries that survived the last
sweep, and at least 65,536.
The per-tile scan moves to raster as Tile::content_bounds, used by both
Surface::content_bounds and the cache instead of two copies of the loop; an empty pixel
yields no content instead of a chunks_exact(0) panic.
Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf
Co-authored-by: Claude <[email protected]>
Surface::translated copied every target tile of a move that isn't a whole number of tiles,
so a solid fill (one shared tile) became one unique tile per coordinate after its first
nudge: each later move, undo and refresh then rescanned and re-uploaded all of them, and
history kept them all.
A target tile whose window reads only tiles holding one single pixel value (each source
tile is scanned once per call; one tile is kept per value) now shares that tile. Fully
covered targets also skip the default-pixel pre-fill they are about to overwrite.
12 MP RGBA fill nudged by +-(10, 7) twenty times (release, 4-core container): unique tiles
192 -> 76; RGBA16 move p50 55 -> 21 ms; RGBA8 unchanged within noise. Pixels are unchanged
(the test compares every depth with the copied pixels, and checks that a shared but
non-uniform tile is still copied).
Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf
Co-authored-by: Claude <[email protected]>
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image
element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16
bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit
packing 0 or 1. 10- and 12-bit code values open as U16
round(c * 65535 / (2^b - 1)) and come back exactly when written at their
depth. No colour transform: the transfer characteristic and colorimetric
specification are reported as import notes; extra image elements are
reported; the orientation is applied on import.
Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits
(EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC;
`--dpx-bits` in the CLI), following the document depth by default. Gray
with alpha is written as luma. Creator, project, copyright, creation
time, transfer, colorimetric, frame position, frame rate and time code
travel as DPX:* text metadata and are written back.
Unsupported descriptors, signed or run-length-encoded data and other bit
depths return CodecError::Unsupported; malformed and truncated files
return errors.
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Italian: native-speaker review of the UI catalog
Fix mistranslations: Trap (Trapping, was overprint), HDR Toning (Mappatura tonale, was chemical toning), Display Text (Mostra testo), Vine Color, Override Action "Save" Commands. Improve phrasing: Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, Conditional Mode Change (metodo, as in Image > Mode), bit depth labels. Record the review and new glossary terms in the localization docs. Credit lucAmbr0 in people.toml.
* Tests: allow Italian's compact 8/16 bit labels, which match the source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* i18n(hu): informal address in the rows added on merge
Seven of the 23 rows translated when #3831 was merged addressed the
user with the formal verb forms; the catalog uses the informal second
person throughout. One of them also glued a hyphen to the {key}
placeholder; it now reads "{key}+kattintás: …" like the other hints.
* i18n(hu): translate the Enable animations preference
The Interface › Enable animations preference (#3273) reached main after
#3831; preference_labels_are_translated needs a Hungarian row for it.
---------
Co-authored-by: Brandon Thomas <[email protected]>
* Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (fixes#4303)
* Filters on a type layer: Liquify, Filter Gallery, Camera Raw, Adaptive Wide Angle, Vanishing Point and Last Filter ask too (fixes#4303)
winit's Windows pointer path reports pen pressure 0 as no force, so a tap
lighter than the pen's first pressure level arrived as a force-less touch.
Stylus::update ignored those, saw no pen, and the tap painted at 1 like a
mouse click.
Track touch contacts: a touch move outside a contact (hovering) or any
reported force marks a pen, and a pen contact with no force yet is pressure
0. A finger on a screen without force sensing still paints at 1. A contact
that starts in the frame another lifted now keeps its force too.
Also credit @Furash in contributors/people.toml.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Replacing the colours could shrink the table while the transparent index
still pointed past its end, so the palette PNG export refused the table.
A stale index is now dropped (pixels end up as with "transparent": null),
and an explicit invalid index (out of range, negative, non-integer) is a
BadParams error before any edit instead of silently keeping the old one.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.
Give Smoothing a default-on enable flag so disabling it preserves the
amount and modes. Old brush JSON stays compatible; toolbar amount edits
and explicit stroke overrides enable it, and ABR imports retain disabled
smoothing values.
Cover row, checkbox, lock and keyboard gestures, old section selections,
command replay, presets, legacy JSON and 8/16/32-bit rendering.
Fixes#4283
Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip
Brush tips wider than 6 screen pixels always drew the centre crosshair, so
Preferences > Cursors > Show Crosshair in Brush Tip could add it but never
remove it. Photoshop shows it only when that preference is on. The
Background Eraser keeps its sampling hotspot, and Clone Stamp / Healing
Brush still show it while Option is held.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* rustfmt canvas.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Resolve every pattern name before a batch delete removes any of them.
A missing name used to delete the earlier presets and then return an error. The pattern library is not on the document undo stack, so that partial delete could not be undone.
Fixes#4103
* Pattern batch delete: reject non-string entries and resolve against the library only
A document-only pattern resolved through pattern_cmds::resolve, so the batch
deleted earlier entries before pattern.delete refused it. Non-string array
members were silently dropped (ported from #4127).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Quantize the colour field when Only Web Colors is on.
The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible.
Fixes#4107
* Only Web Colors: draw the field and slider in flat web-safe bands
The coarse interpolated mesh blended between snapped corners, so it showed a
smooth low-resolution gradient rather than bands. Draw flat cells sampled at
their centres instead, and test that every cell is a single web-safe colour.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
* Windows: keep full screen and maximized apart; never restore full screen
winit on Windows keeps the maximized style on a window that goes full
screen from maximized. The shell then draws the taskbar black instead of
hiding it, and on the way back the window stays maximized at the
monitor's full size, over the taskbar (#3550, #4018, #4230). Un-maximize
before entering full screen and maximize again after leaving it.
eframe also restores full screen from ui.ron, as full screen and
maximized at once; drop that flag in the window builder hook, so
PhotoCraft starts in Standard Screen Mode as its own state says.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* contributors: add aweussom
* view_cmds: mark the winit#4756 workaround and how to retire it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* rustfmt view_cmds.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
decoded and written back; kept verbatim without a destination), newPlacedLayer /
placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
Esc returned to Standard only from Full Screen Mode, and was consumed in
the UI pass before tools, dialogs and popups could see it. Handle it in
the shortcut chain instead, after everything that owns Esc, for both
full screen modes, via view.screenMode.standard.
The preference was stored but read nowhere, so Edit › Preferences hid it.
Never now writes Photoshop's white placeholder instead of the merged image
and records has_real_merged_data = false in resource 1057, without
rendering the composite: smaller files and faster saves. Always and Ask
When Saving keep the real merged image, as Image Previews already does
with Ask. A file without layer records always keeps its real merged image,
and layered TIFF still renders the composite it is made of.
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Fix multi-layer Delete and Move drags on a locked Background.
Delete was removing only the active layer's targeted mask when several layers were selected. A Move drag that starts on the locked Background now draws the layer marquee instead of showing the lock dialog.
* Drop the multi-layer Delete hunk: #4078fixes#4025
This PR keeps only the Move-on-locked-Background marquee fix (#4058).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Move: a click on a locked layer still selects it before the marquee
Keeps hidden_layer_tests::auto_select_skips_hidden_layers green: Auto-Select
picks the visible Background under the pointer, and a drag's marquee replaces
that selection on release.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Actions panel: set folders, dialog toggle column, Insert Stop (#219)
- Engine actions_edit_cmds: actions.insert (any command at a position; Insert Stop… uses it
with actions.stopMessage), actions.dialog (the per-step dialog flag), actions.assignSet.
Flag sets follow inserted steps; nothing edits during playback.
- Panel: loose actions first, then each set as a collapsible folder row with its count
(ui.actions.collapsedSets); dropping an action among another set's rows moves it there;
Save Set… on a folder's context menu; a dialog toggle column next to the check marks;
Insert Stop… in the panel menu with a small message/Allow Continue dialog.
- Shell playback pauses on a dialog-toggled filter or adjustment step: the command's dialog
opens seeded with the recorded values; OK runs the dialog's values and resumes from the next
step, Cancel stops. Agents play headlessly. The play result reports paused: {step, id, dialog}.
- Docs: control protocol, scorecard AUTO-219-2 done, gaps G11.
* Translate the Actions panel's set, dialog-toggle and Insert Stop strings
* Resume a paused action only after the dialog's background job lands
A filter confirmed from a dialog runs as a background job in the desktop app; the resumed
steps met a locked document and failed. Wait for the job first (test covers background jobs).
Photoshop ships with Auto-Select on and its target set to Group;
PhotoCraft defaulted to Layer. ToolOptions::default and the serde
default now return "group"; saved preferences keep their stored value.
Co-authored-by: highperfocused <[email protected]>
* feat(shape): recolor vector shape fill via Alt/Cmd+Backspace fill keys
Photoshop fill keys (Alt+Backspace for foreground and Cmd+Backspace for background, as well as their Shift variants) now recolor the active vector shape layer's solid fill when targeting a shape layer, matching Photoshop 1:1 instead of erroring out with 'active layer is a Shape layer, not a pixel layer'. Channel and mask targets continue to be routed to edit.fill. Includes undo support, locked layer checks, and regression tests.
* Fill keys: keep Fill from History's own enablement after merging main; rustfmt
edit.fillHistory joined the shared fill-key IDS on main and shares their
enabled check, which now also accepts shape layers. Give Fill from History
edit.fill's check so it isn't enabled where it can only fail, and limit the
locked-shape test to the four colour keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Load and save Photoshop Actions (.atn) (#219)
- photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip,
truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and
`Idnt` reference items carry a class before the number (real action files failed to parse).
- photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters,
adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/
hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop).
Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at
playback by name); tool selections load switched off. Commands `actions.load`,
`actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs`
and `set`. `layer` params accept a layer name, so loaded steps replay in any document.
- UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled.
- CLI: `batch --actions set.atn`. Automation policy covers the new path params.
- Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2
partial, development guide.
* Translate the Actions panel's Load/Save strings in every catalog
The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label)
were missing from all 19 catalogs; the languages that were complete are back at 100%.
* Show the Actions entries in the default dock's panel menu
The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so
Load Actions… and Save Actions… were missing there.
* Selection: lasso polygons scan only the edges that cross each row
The polygon fill behind Lasso, Polygonal Lasso and Magnetic Lasso tested
every edge on every sub-row of the whole document, O(height x 4 x points),
on one thread. It now keeps an active edge list over the edges sorted by
their top and fills bands of 64 rows in parallel. Crossings use the same
formula and spans accumulate in the same sorted order, so the mask is bit
identical; a test compares it with the previous fill.
* Selection: the lasso fill bands go through par_rows; edges are a named struct
photocraft_io::named_for_file(doc, name) serialises the document under the
file it is being written to, so a .pcraft manifest carries the identity the
save is about to adopt rather than the previous one — the same rule every
other format follows on import, where document.name comes from the file it
was read from. A bare extension names no file and changes nothing.
Applied once, in the .pcraft branch of export_stages, which export and
export_with both funnel through: the desktop Save (services.rs:242),
Export (services.rs:384), the web build (web.rs:355) and the engine file
command (file_cmds.rs:295) are covered by one site, and the previews
rendered before the bytes describe the same document as the manifest.
Scope per review: the automation funnels (files::save / save_bytes) already
landed in #3319 with their own named_for_output, so this carries no
crates/automation change; its scenarios are covered by
crates/automation/tests/native_save_name.rs on main.
The 8-bit stroke dither (render.rs `dither8`) ran on every stroke, including
aliased ones. An aliased stroke paints each pixel by the same amount, so it
has no gradient to band; dithering only scattered it over neighbouring
levels. A 1 px Pencil line of 219/41/61 at 30% opacity over white came out
as four colours (244/191/197, 245/191/197, 244/190/196, ...), and a
foreground between 8-bit levels did the same at 100%. Photoshop's Pencil
paints one value, and pixel art relies on it: a tolerance-0 Magic Wand or
Paint Bucket and Indexed Color palettes trip on the grain.
Composite now skips the dither when the brush is aliased (Pencil, Eraser
Pencil and Block modes). Soft brushes keep it.
Test: an_aliased_stroke_at_partial_opacity_is_one_flat_colour (fails
before the fix at pixel 5). photocraft-paint 96/96, photocraft-engine
1529/1529, ui-egui pencil 13/13, clippy -D warnings and fmt clean.
Co-authored-by: cloudalister <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Tests: language-invariant bit-depth labels are allow-listed; add missing tr/hu 'Enable animations' (#4359)
Turkish writes '8 bit'/'16 bit' exactly like the English source, so the
translated-differs assertion needs an explicit allow-list (the entry must
still exist). hu and tr had no 'Enable animations' preference label.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Merge main; drop the Enable animations rows main already has
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
0.8.1's Web job failed: photocraft-web's wasm was 25,587,059 bytes, over
packaging/web/package.sh's 24 MiB gate. #4253 merged with #4147 brings
it to 24,161,781.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Combined with #4147's opt-level "z" list. Conflict in Cargo.toml's
wasm-release profile: kept #4147's photocraft-psd = "z" and dropped
#4253's "s" entry for it (one table per package). Measured with trunk
--release: main 184c226a is 25,235,857 bytes (over the 25,165,824 gate);
with this merge, 24,161,781.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(ui-egui): Export As artboards test accepts the Windows path separator
The test added in #3909 compared the second file's path with a literal
'/', but the exporter joins the sibling name with the platform separator,
so Windows CI saw '/out\boards-2-Capsule.jpg' and failed.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(ui): only Filter menu dialogs get the in-dialog preview window
#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* build(web): opt-level z for large cold crates in the wasm release profile
The browser wasm sat at the 24 MiB gate. The document model, file
parsers (psd, format, affinity), serde_json, usvg and naga are off the
per-frame and per-pixel paths; opt-level "z" for them takes the
release wasm from 25,186,233 to 24,829,840 bytes (-356 KB) with no
behaviour change.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(cli): run prints each missing-font warning once
Since #3630 the headless open and save also report missing fonts, so
`run <file> --out` and `run --cmd ... --out` printed the same warning
two or three times and missing_typeface_is_reported_by_run_info_and_convert
failed on main. Warnings from the open and the save now go through the
same already-reported set as the per-command ones.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(ui): the filter preview window never uploads a texture past the GPU limit
With low-resolution previews off, the preview window from #3877 uploaded
the whole document as one texture. egui panics on a texture bigger than
max_texture_side, so opening a filter dialog on a large image crashed
(full_res_preview::filter_dialog_previews_at_full_resolution_when_low_res_is_off).
The texture is now reduced to fit; the view maps through UV fractions,
so it still lines up.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* i18n(cs): drop duplicate catalog entries
The Czech catalog listed Tool, Clone Stamp, History Brush, Dodge, Burn,
Sponge and Stroke Path… twice, which fails `cargo xtask i18n-coverage`
and the bundled_catalogs_are_consistent test. Keep one row each; for
Stroke Path… keep the later row, the one the loader already used.
* test(crop): pin the UI language to English in crop_default_mode
The test queries widgets by their English labels ("Ratio", "Swaps height
and width") but its harness followed the system locale, so it failed on a
machine whose locale has a registered catalog. Set interface.language to
"en" in the harness, as paint_bucket_all_layers and export_as_scaling do.
* i18n: add Hungarian (hu) UI translation
A complete Hungarian catalog (hu.tsv, 3908 entries: 3829 plain, 50
contextual, 21 plural, 8 command-id overrides) registered with the
complete-menu languages; hu-HU locales resolve to it.
Plural rule: plural_none. A Hungarian count takes the singular noun
("3 réteg"), so every @plural entry has one form.
Clean room: written from the plain meaning of the English labels with
ordinary Hungarian image-editing vocabulary; no proprietary localisation
resource, string table or product UI was used.
Also listed in the coverage test, LICENSE-translations.txt,
ATTRIBUTION.md and docs/localization.md, with tag, system-locale and
plural tests.
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Add Turkish (tr) translation catalog covering 100% of measured UI strings (1,727/1,727 keys, 3,731 catalog entries).
- Register Turkish language in crates/ui-egui/src/i18n/mod.rs with complete_menus: true and plural_none
- Add tr.tsv translation catalog
- Add negotiation and coverage tests in system.rs, mod.rs and i18n_coverage.rs
- Add MIT OR Apache-2.0 notice in LICENSE-translations.txt
- Update docs/localization-parity.md
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Update .gitignore and add UI Dialogs documentation
- Added .specstory, .private, and .vscode to .gitignore to exclude additional files from version control.
- Introduced a new documentation file, ui-dialogs.md, detailing the architecture, rules, and usage of modal and modeless dialogs in the egui shell, mirroring Photoshop's functionality.
* Enhance UI Dialogs documentation in ui-dialogs.md
- Expanded the section on command routing and dialog types, clarifying the roles of various dialog kinds and their associated modules.
- Improved descriptions of routing markers and private keys, ensuring better understanding of the dialog architecture and usage.
- Updated the menu command entry details to reflect changes in module organization and functionality.
* Implement Alt+Cancel (Reset) functionality for various UI dialogs
- Introduced a new module `dialog_alt_reset` to handle the Alt+Cancel behavior, allowing users to reset dialog fields to their original values while keeping the dialog open.
- Updated multiple dialog modules to support this feature, including adjustments for color pickers, sizing, and guide layouts.
- Added tests to ensure that the reset functionality correctly restores original values and maintains dialog state.
- Enhanced documentation to reflect the new Alt+Reset behavior and its implementation details across different dialog types.
* Update manual UI testing command to include build timing summary
- Modified the `manual-ui-testing` command in `.cargo/config.toml` to include the `--timings` flag, enabling the generation of a build timing summary after compilation and linking.
- Refactored the `reset_fields` function in `filter_dialog.rs` for improved readability by consolidating the retrieval of the `spec` variable into a single line, enhancing code clarity.
* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119) (#3125)
* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)
- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
"Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
on main via #1961, #965).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)
On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.
shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Shortcuts: Ö / # are the brush keys on a German layout (#3109)
Photoshop's German shortcuts step the brush size with Ö / # and the hardness
with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries
Alt on the Mac and Windows, which the shortcut match rejects). egui can't name
ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+#
types ' and arrived as Quote.
shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and
Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of
shortcuts::handle (unless a text field has focus), so Liquify's and the
Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took
from the physical key or from that very text change, and never digits.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113)
A drag that started on a layer's mask thumbnail dragged the whole row, and
dropping it on the Trash deleted the layer. Now a drag from the pixel mask
thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer
Mask" ghost and starts neither a layer reorder nor a cross-document transfer.
Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before
removing?" with Apply (layer.layerMask.apply, greyed out where it can't run),
Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes
the targeted mask at once (#2197).
The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm
applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps.
Two new strings in all 16 catalogs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119)
A drag that started on a row's vector mask thumbnail picked up the whole
layer row, so dropping it on the Trash deleted the layer. The mask drag
from #3113 now covers both thumbnails: a vector mask dropped on the Trash
opens Photoshop's "Delete the vector mask?" (OK / Cancel), and OK runs
layer.vectorMask.delete. Clicks on the thumbnail are unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Brush keys: layout table plus bracket-position fallback for every layout (#3109)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* layout_keys: drop unused field, fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Interface: an Enable Animations preference (#3273) (#3276)
Preferences › Interface › Enable animations (interface.enableAnimations, on by
default). Off, prefs_ui::sync_animations sets egui's animation_time to 0 and
scroll_animation to none in both the dark and light styles, so menus, popups
and collapsing sections change at once and scroll areas jump. It is re-applied
each frame, so a theme change keeps it. Translated in all 16 catalogs; the
scorecard row UI-217-10 goes to partial.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267)
* automation: export layers, data sets and CSVs through the granted roots (#3216) (#3230)
* automation: doc.place reads a file through the read root (#2810)
file.placeEmbedded takes an ambient path, so automation refuses it and an
agent could not place a file into the open document at all. doc.place
resolves the path beneath the read root the way doc.open does and hands the
bytes it already read to the public file_cmds::place_bytes, so the existing
cap-std rules stay the only path rules: no new scope logic, and the ambient
command keeps its refusal. Trusted-local callers still run the command.
* automation: export layers, data sets and CSVs through the roots (#3216)
file.export.layersToFiles and file.export.dataSetsAsFiles take an ambient
dir, file.import.variableDataSets an ambient path, so all three are refused
in automation even for files inside the granted roots, and an agent could
not export a single file or a data set at all.
Both halves already existed: the engine writes bytes it has just encoded
(save_doc is encode + write_file, and the data-set exporter encodes with
photocraft_io::export before writing), and the automation side already
holds a write capability — AuthorizedWorkspace::export_sequence creates the
directory, refuses to overwrite, accepts only single-component names and
removes every file it created when a step fails, which is what Render Video
uses. So each command body moves behind the same sink callback
render_video_with takes: the command keeps writing the bytes it is handed
to its dir, the new methods write the very same bytes through the root, and
the two implementations cannot drift.
---------
Co-authored-by: switch87 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Type: fall back to installed Khmer, Lao and Burmese fonts (#218) (#3259)
Text in these scripts drew .notdef boxes although the OS had a font for
them. Detection was never the problem: the system font scan loads them,
and asking for one by name shapes correctly. The fallback stack is a
list of family names intersected with the installed fonts, and it had no
entry for any of the three, so an installed Khmer, Lao or Burmese font
could not be reached from a Latin-only face.
- photocraft-text: KHMER_FAMILIES, LAO_FAMILIES and MYANMAR_FAMILIES join
fallback_candidates next to THAI_FAMILIES; the web build's served-font
scripts gain Khmr, Laoo and Mymr (with their ranges and languages).
- photocraft-ui-egui: the UI's lazy loader had one non-CJK slot (Thai).
It becomes a LazyScript slot table, so the three scripts are data
rather than a third and fourth copy of the same state machine.
- Tests assert detection and selection separately, so a future failure
names its layer, and skip when a machine lacks the script's font.
Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Curves: pencil mode, Smooth and Edit points (#3088) (#3108)
* Curves: pencil mode, Smooth and Edit points (#3088)
The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.
- doc: Adjustment::Curves gains `pencil` (serde default), with
curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
256-level maps; switching modes draws the spline into maps or fits up to
16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
as padding, so pencil curves from Photoshop were misparsed; they are now
read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(io): smooth PSD gradients no longer overshoot their stops (#3028) (#3285)
The Catmull-Rom tangents used for Smoothness are derived from the
neighbouring stops, so a segment between two equal-coloured stops (a
flat plateau) picked up a non-zero tangent and dipped below its own
colour. In the Gradient Map from #3028 the 27%-53% plateau (#1c1c1c)
rendered about 19/255 too dark.
Bound each interpolated channel to the range of the segment's two end
colours. Smoothness still bends the ramp, but never invents a colour
outside the segment, so plateaus stay exactly flat.
The regression test uses the four stop positions and grey values from
the issue (synthetic, no artwork) and fails without the clamp.
* Codecs: decode PNM straight into the budgeted output buffer (#2938) (#3291)
Limits::max_alloc budgets the decoded pixel buffer, but the PNM decoder
collected one u32 per sample first (4x an 8-bit image, plus a u16 copy
for 16-bit) and PFM built a Vec<f32> beside the output bytes. Samples are
now rescaled and written straight into the native-endian output buffer
(reserved fallibly), so the only header-sized allocation is the one
Limits::check already charged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) (#3115)
* Curves: pencil mode, Smooth and Edit points (#3088)
The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.
- doc: Adjustment::Curves gains `pencil` (serde default), with
curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
256-level maps; switching modes draws the spline into maps or fits up to
16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
as padding, so pencil curves from Photoshop were misparsed; they are now
read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)
The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.
Scorecard UI-217-15 is done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Format: admit the declared .pcraft canvas before loading it (#2935) (#3295)
* Format: admit the declared .pcraft canvas before loading it (#2935)
A .pcraft manifest's canvas size was copied into the Document unchecked, so
a few bytes of JSON could declare a 300000x300000 (or u32::MAX) canvas that
the CPU reduction, thumbnails and display textures then work against in
proportion to its area.
The loader now refuses an empty canvas, a side above 300000 (Photoshop's PSB
limit, as for PSD and file.new) and an area above 4 gigapixels, right after
reading the manifest and before any tile or blob is decoded. Both limits are
LoadOptions fields so trusted callers can change them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Format: admit every canvas the app can create on .pcraft load (#2935)
The default pixel budget was 2^32 (65536 x 65536), but file.new, Canvas
Size, Image Size with Resample off and PSB import all reach 300000 x
300000, so a document a user could create and save would not reopen.
The default is now the full 300000^2 PSB canvas; the side limit still
refuses anything larger, and callers handling untrusted files can lower
either budget. Loading stays proportional to the file: layers keep only
the tiles the bundle holds, so a huge declared canvas allocates nothing
for its area.
A compile-time assert in document_preset_cmds ties file.new's
MAX_DIMENSION to the loader's defaults.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(format): save the target file name in native bundle manifests (#3312) (#3319)
* Levels: black, gray and white point eyedroppers (#3116) (#3128)
* Curves: pencil mode, Smooth and Edit points (#3088)
The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.
- doc: Adjustment::Curves gains `pencil` (serde default), with
curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
256-level maps; switching modes draws the spline into maps or fits up to
16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
as padding, so pencil curves from Photoshop were misparsed; they are now
read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)
The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.
Scorecard UI-217-15 is done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Levels: black, gray and white point eyedroppers (#3116)
Image > Adjustments > Levels and a Levels adjustment layer's Properties
panel get Photoshop's Set Black/Neutral Gray/White Point eyedroppers.
Black/white move each channel's input black/white to the sample, gray
sets each channel's midtone gamma so the sample turns neutral at its
luma (Grayscale: composite input, no gray). The engine takes the same
`eyedropper` param as Curves on image.adjustments.levels,
layer.newAdjustmentLayer.levels and layer.setAdjustment.
On a smart object, image.adjustments.levels/curves with `eyedropper`
now record the resolved adjustment as the smart filter instead of the
raw sample, which re-rendered as an identity (#3127).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Puppet Warp: the mesh follows the object outline (#3105) (#3117)
The mesh was every square grid cell touching the layer's opaque pixels:
a staircase up to a cell outside the shape, and shapes closer than about
a cell shared cells, so pinning one dragged the other along.
build_mesh now traces the outline of the opaque region grown (or shrunk)
by Expansion from a signed distance field, simplifies it (Douglas-Peucker,
corners kept), splits its sides to the Density spacing, fills the inside
with a triangular lattice and triangulates the points (Bowyer-Watson
Delaunay, outline sides recovered by midpoint splits), keeping the
triangles inside the outline. Separate shapes get separate meshes, so an
unpinned one stays put, as in Photoshop. Vertices are numbered in reverse
Cuthill-McKee order to keep the solver's banded factor narrow, and the
density's spacing counts the grown region so a large Expansion on a small
layer no longer builds tens of thousands of vertices.
Scorecard TOOL-214-6 done; docs/gaps.md G7 updated.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Clone Source: W/H link and overlay blend modes (#3298) (#3310)
The Clone Source panel gets Photoshop's W/H chain (Maintain aspect
ratio): with it on, a new W or H scales the other by the same factor.
cloneSource.set takes "linked" and cloneSource.list reports it.
The overlay's Darken, Lighten and Difference modes were stored but
never drawn. draw_clone_preview now blends the source with the
composite under the brush (clone_source::overlay_blend), so Difference
goes black where source and image line up.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) (#3133)
Filter > Vanishing Point now opens a dialog mode on the canvas instead of
the generic parameter dialog:
- Create Plane (C): four clicks, in any order, make a plane (corners are
normalised clockwise from the top-left, the order paste expects).
- Edit Plane (V): drag corner nodes, or edge nodes to slide an edge along
the plane's own perspective; click a plane to select it, Delete removes it.
- The plane grid is drawn at the Grid Size, in the accent colour when the
plane is valid, warning yellow when its perspective is doubtful and red
(outline only) when the quad isn't convex (VpPlane::check).
- With an image on the clipboard the selected plane previews it pasted in
perspective; drag to move it, Width sets its size, New Layer renders it
on a new layer. OK / Enter runs filter.vanishingPoint with the planes and
the paste as one history step; the control channel drives the same
session through {"ui": {...}}.
The engine now rejects a drawn plane whose corners don't form a convex
quad (bow-tie, dented) instead of warping along it.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Let `[` shrink the brush to 1 px (#3229) (#3332)
`[` steps the brush size down by 25 %, rounded: from 2 px that is 1.6,
which rounds back to 2, so the key never reached 1 px and pixel artists
had to type the size. `]` already grows by at least a pixel; `[` now
shrinks by at least a pixel the same way, still never below 1 px.
* MCP: command_batch says it is not atomic, and the docs explain recovery (#3338)
Agents read the tool description, not docs/mcp.md, and the command_batch
description only said it stops at the first error. It now also says the
steps run in order and that earlier successful steps stay applied: there
is no rollback. docs/mcp.md gives the stop_on_error default and a recovery
recipe: inspect with doc_inspect, undo with edit.undo once per history
entry (undo does not reverse files written to disk), or experiment on a
copy made with image.duplicate or history.newDocument. A test checks the
listed description.
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
Co-authored-by: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Valentin Klein <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Gert Pellin - GPE <[email protected]>
Co-authored-by: switch87 <[email protected]>
Co-authored-by: RaccoonPie-314 <[email protected]>
Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Sir Starch <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: myangaa1 <[email protected]>
Co-authored-by: KnottyDyes <[email protected]>
* ops: count selection masks in history pixel budget
* test: expect unclipped HDR levels in float documents
* web: optimize engine code for the wasm size budget
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
read_layer decoded every part and picked one; use a first-valid-layer reader
that declines all other parts. Cryptomatte now goes through read_meta and
read_layer. Enable exr/rayon on native targets.
Co-authored-by: Claude Opus 5.5 <[email protected]>
heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing:
four in release builds (a uuid box too small for its extended type, a
block starting right of the picture, a conformance window larger than the
picture, and a reference row below the picture) and two with overflow
checks on (a PCM bit depth above the sample bit depth, a slice QP out of
range). The API, MSRV and output are unchanged.
Both pins move together: crates/heif and the codecs fuzz target, which
calls heic-rs directly. guarded() stays as the backstop it was meant to be.
crates/raw/fuzz had no .gitignore, crates/psd/fuzz didn't ignore
Cargo.lock and crates/format/fuzz didn't ignore coverage/, so running
those fuzz targets left untracked files in the checkout. All five fuzz
crates now ignore target/, corpus/, artifacts/, coverage/ and Cargo.lock,
as codecs and affinity already did.
- store: `max_blob_bytes as u64 + 1` overflowed for usize::MAX (debug
panic; release take(0), so every blob failed). The blob read and the
tile decode now share decompress_bounded(), which saturates.
- zip: ZipReader::read sliced `data_offset..data_offset + compressed`
unchecked although Entry is pub + Clone; a foreign entry is now a
Corrupt error.
- store: Collect::tile reuses tile_le(); Object::content() gives the
stored bytes once (existing_object_is_valid no longer builds them twice).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Format: check pattern sizes on load (#3478)
A .pcraft pattern's width/height came straight from the manifest, so a
1 KB file could declare a 65535x65535 pattern without tiles; painting it
allocated width*height*16 bytes (abort) and the engine's u32 size math
overflowed.
- format: reject patterns 0 px on a side (Corrupt) or above the PSD
reader's 30 000 px edge (LimitExceeded).
- doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26,
a 1 GiB f32 RGBA working copy).
- compose/gpu: patterns too large to paint paint nothing instead of
allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan).
- engine: brush.texturePattern refuses them; average_color uses u64 math
and a bounded corner.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* gpu: skip unpaintable patterns in the Bevel texture too
The Bevel & Emboss texture element uploaded the pattern through
pattern_rgba, which now returns no pixels for a pattern too large to
paint; filter it like the other GPU pattern lookups.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The "web build and wasm size gate" job failed on main and every PR: the
release `_bg.wasm` measured 25,379,677 bytes at 8d49ecf5, over the
25,165,824-byte limit in packaging/web/package.sh.
- egui's default fonts (Ubuntu Light, Hack, Noto Emoji, emoji-icon-font)
were embedded uncompressed (1.4 MB) by egui's `default_fonts` feature.
The app now builds egui without it; ui-egui's build.rs deflates the
same files from the `epaint_default_fonts` crate (a build-dependency,
so no font files are committed) and `egui_fonts.rs` rebuilds egui's
exact default stack from them on first use. Test builds keep the
feature as the oracle: the rebuilt definitions equal egui's byte for
byte.
- Translation catalogs: every catalog repeated the same English sources.
build.rs now stores the `context<TAB>source` keys once and each catalog
as references plus translations, losslessly (`text()` still returns
each .tsv byte for byte; malformed lines are kept as they are).
- wasm-release profile: opt-level "z" for PhotoCraft's own crates.
Dependencies stay at "s" (at "z" the wasm grew 0.4 MB, measured), the
file-decoding crates (codecs, psd, raw, heif, vector) stay at "s" and
the pixel crates at 3, so decoding and filters keep their speed.
Measured with `trunk build --release` (trunk 0.21.14, wasm-opt v123 -Oz):
upstream main 8d49ecf5 25,379,677 bytes
this change 24,167,186 bytes (-1,212,491)
gate 25,165,824 bytes (998,638 headroom)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Retain the relative paper-white requirement and round-trip checks while validating finite, bounded endpoints and darker full-K ink.
Add an encoded and reparsed AToB1 regression with an independently calculated 100/87/74/61/48 lightness ramp, plus invalid endpoint cases.
Signed-off-by: FredApps <[email protected]>
* Layers: Merge Group for a selected group (#3832)
With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the
Layers panel's right-click menu offers Merge Group instead of Merge Down, as in
Photoshop. The new layer.mergeGroup command flattens the group's visible contents
into one pixel layer in its place, named after the group, keeping its opacity, blend
mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran
Merge Down and merged the whole group into the layer below it.
* i18n: Vietnamese row for Merge Group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Merging or stamping converts the straight-RGBA composite into the
document's colour model. Transparent black converts to K = 100% in CMYK
and neutral a/b in Lab, so those pixels no longer equal the surface's
default pixel, prune() keeps them, and the layer's content bounds span
everything that was rendered: the whole canvas for Stamp Visible, Stamp
Down and Copy Merged, and for Merge Layers, Merge Visible and Merge Down
whenever an input may draw anywhere (a fill layer, even a masked one).
Fully transparent pixels are now written as the default pixel in both
conversion paths: photocraft_compose::flatten_to_surface (stamps, Copy
Merged) and engine pixels::composite_layers (merges). Only formats with
alpha are affected; over a Background everything stays opaque.
Tests: engine merge_transparent_bounds (Merge Layers, Merge Visible over
a transparent document, with a masked fill layer and onto a Background,
Stamp Visible, Stamp Down, undo) in RGB/Gray at 8/16/32 bit and CMYK/Lab
at 8/16 bit, and a compose unit test for flatten_to_surface. The masked
fill layer test fails on main (merged layer = full canvas in CMYK).
Co-authored-by: dgliwa <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* PSD type: resolve paragraph and character styles from the run's own sheets (#3795)
apply_engine_data filled a run's missing attributes only from the Normal
sheets (TheNormalParagraphSheet / TheNormalStyleSheet). A run that takes
its alignment or size from a named sheet (by Parent index or Name), or
from its paragraph's DefaultStyleSheet, was read with Normal's values, so
a centred paragraph style came back Left and starting an edit re-laid
the text half a line to the side.
Resolve run -> named sheet -> paragraph's default character style ->
Normal, ignoring missing or out-of-range references. On export, runs
whose sheet reference is rewritten also get the attributes they
inherited, so they keep their look whatever they now point to.
* PSD type: place text at its Txt2 frame and keep box lines whose descent overhangs (#1317)
Recent Photoshop keeps part of a type layer's position in its Txt2 frame: a
frame-to-text-space matrix and, for point text, the anchor, with EngineData's
PointBase left at the origin and BoxBounds in the frame's space. Import ignored
them, so box text landed far off the canvas once an edit started (the text
'disappeared') and point text moved up a line and off its alignment anchor.
Apply the frame when its origin lands in the TySh bounds (and not already
without it, so a TySh PhotoCraft rewrote is not moved twice).
Photoshop also keeps a box line whose baseline is inside the box while its
descenders hang below it; layout dropped such lines (and some single-line boxes
rendered nothing).
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat: Left and Right arrows turn the brush tip
With a painting tool, Photoshop's Left and Right arrow keys turn the brush
tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush
turns the session brush's angle by "degrees", wrapped into -180..180, and
move_mods::arrow_keys gives it the plain and Shift arrows only for the
painting tools, when no Move-tool, transform or selection nudge applies
and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still
nudge the layer.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(ui): Shift-click with the Type tool starts new text over existing type
In Photoshop a click on existing type with the Type tool edits it, and a
Shift-click there starts a new type layer instead, so text can be placed
over other text. pointer_down now leaves a Shift-click unconsumed when no
type is being edited, so it starts new text; inside the text being edited,
Shift-click still extends the selection.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(ui): Space+F cycles the canvas colour
Photoshop's Space+F steps the pasteboard (canvas) colour through Default,
Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F
steps back, round and round. While the Hand key is held, F does this
instead of Cycle Screen Mode; the choice is saved like picking it in the
pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new
tool keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
A PSD re-saved from a PSB kept the source's global FMsk and cinf blocks with
their 8B64 signature, and Photoshop 27.11 refuses such a file as "not
compatible with this version". 8B64 exists only in PSB, so a kept block is
written as 8BIM when the output is a PSD; a PSB keeps 8B64. Measured by
bisecting the 3 refused corpus re-exports in Photoshop: each opened once the
8B64 blocks were gone.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Resolve an issue where rasterizing a smart object layer resulted in an empty raster surface if the cache was not yet populated. Now refreshes the smart layer snapshot if needed, extracts the rendered surface, converts to the document pixel format, and cleans up PSD placed-layer blocks (SoLd, SoLE, PlLd). Includes a regression test suite covering solid fills, new layers, shape layers, and transformed smart objects.
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end
The built-in contour presets were hand-drawn guesses and none matched
Photoshop. They are now Photoshop 27.11's own curves, measured by running
a Satin with each preset against a Linear one (38 levels per curve,
thinned to within 1 %, straight segments).
The measurement also showed how Satin applies a contour: |c(1 - v) - c(1)|
(Cone and Cone - Inverted Satins are identical; a Gaussian Satin is 0.36
at 0.5 while the Gaussian on a glow is 0.64). New effects::satin_lut, used
by the CPU and GPU satin paths.
Probe results vs Photoshop (max level error): Satin with every preset
<= 3 (Cove - Shallow 9), was up to 128; Outer Glow Gaussian 38 -> 2,
Ring - Double 121 -> 15, Cone 48-52 -> 4-7; bevel Contour Cone at 100 %
range 27 -> 12. Corpus oracles unchanged (their stored curves are
symmetric). Gloss Contour is still off: that is the bevel's gloss model,
not the presets.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Layer style UI test: the Contour Editor on the measured Cone
The measured Cone preset has 18 points, all inner ones corners, so the
editor test moves point 1 between its neighbours and checks the corner
flags by position instead of the old three-point shape.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's
Measured as a black box on Photoshop 25.4's Camera Raw Filter.
Whites and Blacks are global curves of the grey level: the measured rows
per slider value, Whites first, after Shadows and Highlights. The
clipping directions adapt to the image's black and white points
(Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2).
On ten public-domain photos the mean error against Photoshop drops from
4.2-36.8 to 0.9-8.9 levels of 255.
Shadows and Highlights are a local Laplacian filter (Paris et al. 2011,
fast form of Aubry et al. 2014) on the log luminance, the method Adobe
credits for process version 2012. Shadows raises pixels darker than
their surroundings by an amount set by their own and their
surroundings' tone, so big edges keep no halo, and keeps the 99th
percentile; Highlights pulls pixels brighter than their surroundings
toward them and keeps the 1st. The strengths are fitted to the photos
(mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator
was not recovered.
The tone stages decode light with the document's curve: filter.cameraRaw
stores encodingGamma (1.8 for ProPhoto, where raws open).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Camera Raw Shadows/Highlights: no contour lines on smooth gradients
Twelve reference values over a raw's whole range left more than a stop
between them, and the remapping changes within half a stop: coefficients
interpolated across that gap drew contour lines in out-of-focus areas
(Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop
apart over the image's tones (the deepest 0.1 % share the lowest), on a
copy at most 1600 px across whose change comes back to full size by a
guided upsampling, so a 16-megapixel image takes about 2 s per slider and
the preview matches the result.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop
Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at
+-100 and +50, exposure +-1) against our own linear development of the same files.
- Shadows and Highlights of the open-time dialog now act while developing, in linear light before
the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2
gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy
1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the
linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the
preview undoes the camera curve to show the same (photocraft_raw::retone).
- The D4 profile copies Camera Raw's default rendering: look matrix folded into the
ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of
the luminance, Sensor::black_point).
Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference
0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights
-100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* docs: the open-time Camera Raw re-develop covers Shadows and Highlights
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does
The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs:
with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38
stop lighter. Two causes:
- The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting
first let the lift wash out the black point's deep-shadow contrast).
- The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges
but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near
strong edges the effect still matches Photoshop as well as elsewhere.
Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation
Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop
Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor
keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act
before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99).
- Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on
the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus
a tone term; carried to full size with the Shadows / Highlights change.
- Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response
(small detail raised more than strong edges, flat areas unchanged), guided-upsampled.
- Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3,
airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100;
−100 a uniform veil. The black point stays the default rendering's.
Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect
correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85;
Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows
differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886).
The open-time Camera Raw dialog re-develops for them and its preview shows them (retone).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* ui: a panel expanded from its collapsed strip auto-collapses on an outside click
* ui: test the Auto-Collapse Iconic Panels peek open and outside close
* engine: show Auto-Collapse Iconic Panels in Preferences, it is wired
* docs: scorecard, settings that do nothing 30 -> 29 of 154
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo
step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above
the target, only where the hole is, leaving the target as it was (with Sample All Layers it
fills from the composite). The options bar shows Photoshop's three options; with Remove after
each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and
Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
Session::edit reported damage only for edits that changed no pixels (same_pixels) and left
every other edit to recomposite the whole document unless the command reported its own
damage. Duplicate and paste do (#2883, #2901), but Fill, Invert, filters, Delete Layer and
the undo/redo of a duplicate, a paste or a delete all recomposited everything.
- Session::edit now reports step_damage(before, after), the same bound undo and redo use.
- step_damage bounds layers only one side has in both directions, so a removed layer counts
where it drew like an added one; the bounded_additions flag and insert_damage go, and
added_bounds becomes one_sided_bounds. A layer that is (or was) the clipping base of
clipped layers above it, an adjustment, a video layer or one excluding channels still
recomposites everything (#3090).
- The subject-mask cache is dropped only when the step can have changed pixels.
perf_scenarios on the layered 20 MP document, edits on a 1 MP layer (p50, RTX 3090, Vulkan):
delete layer 9.1 -> 1.4 ms, undo a duplicate 9.3 -> 1.6 ms, fill a 500 px square
17.4 -> 5.5 ms, Invert 16.7 -> 9.2 ms. Duplicate and paste are unchanged (already bounded).
Tests: duplicate_paste_and_delete_report_where_the_layer_draws checks that each step, its
undo and its redo report the same bounded damage and that no pixel outside it changes.
new_layers_that_change_clipping_recomposite_everything (renamed) now expects bounded damage
for a painted or empty new layer and None for a new clipping base or an adjustment; deleting
and restoring a layer reports its bounds.
With the Navigator open, every edit re-rendered the whole document into a new thumbnail on the
UI thread: a full-resolution CPU composite, 303 ms after an opacity change and 486 ms after a
brush stroke on a 20 MP layered document.
The navigator cache now records the document revision of its image. When the document is
exactly one edit ahead, with the same preview key, a bounded last_damage and the same thumbnail
size, only the damage (grown by the layers' effect reach) is rendered with
render_reduced_damage and written into the texture with set_partial; an edit with empty damage
(a rename, a selection, a new empty layer) costs nothing. Anything else renders the whole
thumbnail as before. compose gains thumbnail_size, which thumbnail_buffer now uses, so both
paths agree on the size.
On the 20 MP document: 303 -> 36 ms after an opacity change, 486 -> 171 ms after a stroke
(RTX 3090 machine; the navigator renders on the CPU).
Test: navigator_damage_updates_match_a_full_thumbnail patches a thumbnail after a translate,
an opacity change, a stroke, a blend mode change and a new layer on a document with a drop
shadow, and compares it with a full thumbnail; it fails without the effect-reach inflation.
A duplicate shares its source's tiles (Arc), but its resident texture was new, so the
compositor converted and uploaded every tile again: duplicating a 20 MP photo layer uploaded
330 tiles. When a new resident's surface shares tiles with another resident of the same role,
region, texel kind, format and default pixel, its texture now starts as a copy of that one
(Stats.tiles_shared counts the tiles taken) and the usual per-tile pass fixes up whatever
differs. Layer textures gain COPY_SRC.
The copy is recorded in the frame's encoder, after whatever brought the donor up to date this
frame. Queued writes land before the encoder runs, so both textures are marked `ordered` and
their writes go through the encoder from then on; otherwise a write to either in the same
refresh would be overwritten by the copy or leak into it. A resident whose region only moved
still keeps its own texels (keep_overlap) first.
Duplicating a 20 MP photo layer in perf_scenarios: 25.1 -> 6.3 ms, 330 tile uploads -> 0
(RTX 3090, Vulkan). Paste is unchanged: the pasted layer has new tiles.
Test: duplicates_copy_their_source_texture covers a plain duplicate (no uploads), a
duplicate and its source each painted with one tile in the same refresh, and a duplicate
missing a tile, all against the CPU composite. Without the `ordered` routing the second case
is off by 167.6/255.
The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack
recorded every pass into a single command buffer: 700 full-size layers make 1403 render
passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command
buffers per queue, so a few thousand passes in one cell can lose the device.
run_cell now counts copies and render passes and, when a refresh may submit in between,
submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the
page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the
queue keeps the order, so a chunk continues in the next command buffer. A device lost in
such a submit ends the refresh with an error instead of recording more work.
Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise.
Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs
flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes
without a flush.
Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op.
Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).
With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).
Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop's absolute mode scales each range's ink change by how much the
ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by
min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so
Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in
Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1
level now (8 of 72 within 3 before). CPU and GPU paths.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* engine: expose workspace.enableNarrowOptionsBar through a Workspace helper
* ui: the options bar honors the narrow options bar preference
* docs: scorecard, settings that do nothing 30 -> 29 of 154
Photoshop CS2 writes `phfl` version 3 with "4 bytes each for XYZ color".
The parser read those values as Lab x100, so real files (L = 241.91)
fell back to Unsupported. Read them as D50 XYZ with 1.0 = 32768 (the
ICC 16-bit PCSXYZ encoding; the scale is inferred from a real file, the
spec does not give it). Writing stays version 2 RGB.
Fixes#3777
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* text: missing glyph protection can turn the script fallback stack off (#1909)
* engine: apply type.missingGlyphProtection to the live text engine
* docs: scorecard, settings that do nothing 30 -> 29 of 154
Flatten Image, Merge Visible and Merge Down now composite a CMYK stack of
plain Normal raster layers in its ink values, so a lone Background flattens
bit-exact and 16-bit depth is kept. Stacks that still need the RGB
compositor separate back through the document's own CMYK profile instead of
the built-in one, and Merge Down's scratch document keeps the profile.
Fixes#3778
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop's Brushes panel nests folders by dragging one onto another; ours
could only reorder a folder among its siblings.
- brush.presets.moveGroup takes `into: {group?, folder?}`: the group or
nested folder (with everything in it, presets in order) moves inside that
folder of that group (`folder` [] = directly in the group), or with no
`group` out to the top level as a group of its own. `before` names a
sibling folder (or top-level group) there to land before. Moving a folder
into itself or one of its own folders, a name clash at the destination,
a result deeper than MAX_FOLDER_DEPTH, the ungrouped presets, `index`
with `into`, and bad shapes are errors that leave the library unchanged.
The preset store follows the move (a group moved into another one loses
its group file; a folder moved out gets one).
- Brushes panel and Brush Preset picker: a group or folder header dropped
on another header goes inside it; on the header's upper third it lands
before that folder, reordering siblings or moving a nested folder next to
it (out to the top level before a group). Every drop is one moveGroup
command (`folder_drop` maps the drop).
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z.
Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity.
* fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop
Measured in Photoshop 27.11:
- Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft
multiplied by (1 + s), up to 26 levels off.
- Master Lightness applies first; hue and saturation then work on the
result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue
+40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %.
- Colorize builds the colour at the lightened lightness, so black with
Lightness +30 is a 30 % lightness colour, not grey (was 78 off).
- A colour range's positive Lightness lightens towards the pixel's
brightest channel, not towards white (Blues +50 was 71 off).
All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the
Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb
120 -> 3). CPU and GPU paths.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(adjust): a range's negative Lightness darkens towards the darkest channel
Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204)
at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel,
not towards black. Mirrors the positive rule (towards the brightest).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop's Black & White tint keeps the grey's luminosity and takes the
tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the
same as the Color blend mode. PhotoCraft mixed half the grey with twice
the grey times the tint, up to 141 levels off. Measured in Photoshop
27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all
within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16
black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %.
CPU and GPU paths.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Image › Adjustments › Shadows/Highlights follows Photoshop 25.4, measured as a
black box: per-channel screen curves (1 - (1 - v)^q blended over q = 1, 2, 4,
8), the Tone mask as a Beta distribution function of an 8-bit neighbourhood
brightness, an edge-aware (guided bilateral) neighbourhood, the luma of the
result with the colour differences scaled by Color, and the Midtone S-curve.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The per-file colour fit (`fit_preview_look`) converted the embedded JPEG to sRGB only when the JPEG
carried an ICC profile, and otherwise read it as sRGB. A Sony body set to Adobe RGB writes no profile
into its previews; it states the colour space only in the raw's EXIF (ColorSpace Uncalibrated with
InteropIndex "R03", the DCF option file for Adobe RGB). The fit then targeted a desaturated,
hue-shifted reference, and the developed raw came out that way.
`photocraft_raw::embedded_preview_color_space` reads that EXIF (sRGB, Adobe RGB or unstated; never for a
DNG, whose previews are the converter's), and `preview_profile` uses it when the preview has no ICC
profile of its own. A preview's own profile still wins. LightCraft reads the same tags for the same
reason (storytold/lightcraft#778).
Measured on 19 private Sony ILCE-1M2 ARWs, all shot in Adobe RGB, against the camera's JPEG read as
Adobe RGB (median CIE76 ΔE at 240 px): full-frame 400 mm shots against blue sky 5.6-7.0 → 1.5-2.4,
their mean a* offset +4.3…+4.9 → +0.7…+1.1; every file improved, mean of the medians 4.9 → 2.2.
Tests: `exif_colour_space_of_the_previews` (sRGB, Adobe RGB; R98, a missing interop IFD, other values,
a DNG and junk are unstated) and `a_preview_without_a_profile_takes_the_raws_exif_colour_space` (fails
without the EXIF fallback; the preview's own ICC profile wins). The hostile-input harness calls the new
function.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Imported brushes whose Texture uses Height or Linear Height at a low depth
(common in Photoshop brush packs) painted nothing: the modes were modelled
backwards (paint only on the brightest texels, more of it with more depth).
Measured against Photoshop with single dabs over a 0..255 ramp pattern:
- Height: paint reaches 12 x depth x coverage into the pattern and fills
what lies below it (dark valleys first; a brighter pattern takes paint
away, which is why the reporter had to flip Brightness's sign).
Linear Height grades the shallow end by the pattern instead.
- Subtract takes depth x pattern away (white removes the most paint);
Color Dodge/Burn scale the pattern toward their neutral with depth.
These now live in texture_combine; the Dual Brush keeps mask_combine.
- Brightness shifts the pattern by value/255 (not value/150); positive
Contrast steepens by 1/(1-c/100) after the shift, negative flattens by
1+c/100 before it. Texture::level is shared by the renderer and the
Brush Settings swatch.
- ABR import mapped textureContrast with /50 for both signs; positive
values are now /100, matching the model and the Brush Settings field.
Built-in Subtract presets get Invert so they look as before.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Photoshop keeps Stylistic Sets, Contextual Alternates, Swash, Fractions, Ordinals, Titling and
Stylistic Alternates and Ornaments only in a PSD's Txt2, not in EngineData. Import now reads them
into the runs' font features, so the layout drawn while the type is edited keeps Photoshop's
glyphs instead of falling back to the default ones.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of
2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured
on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12
cases already matched within 1 level. Applied to the CPU and GPU paths and
to the effect reach.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(automation): warn when text falls back from a missing font (#3391)
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* View: remember Extras, Show and Snap To across restarts (#3762)
View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and
the View › Snap To targets reset to their defaults on every launch. They
are now saved in Preferences (`viewExtras`) as they change and restored at
startup, as Photoshop keeps them between sessions.
Only items that differ from their defaults are stored, so a default
changed in a later release still reaches users who never touched it. A
hand-edited file can't break them: unknown keys and non-boolean values
keep their defaults.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* View: rustfmt the view-extras restart test (#3762)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock
Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once
they are all hidden, shows them again: its Layer menu item then reads
Show Layers. layer.hideLayers now does the same, so the key toggles: it
shows them only when every targeted layer is hidden.
The / key toggles Lock Transparent Pixels, as the Layers panel's first
lock button does (layer.toggleLockTransparency). With several layers
selected, any unlocked one means lock them all; the Background, which
can't take this lock, is left out. Each press is one undoable step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity
Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking
its thumbnail does, beside Ctrl+2 for the composite
(channel.target.layerMask; greyed when the layer has no mask). Its
Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection,
the Ctrl-click on the composite channel (select.loadLuminosity).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(engine): Ctrl+Alt+Backspace fills from history
Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or
the layer, from the History Brush's source state. edit.fillHistory is
edit.fill with contents "history": the oldest state still held, unless
"state" names another. It joins the other fill keys, so it honours the
Channels panel target and appears with them in Keyboard Shortcuts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer
While a layer name is being edited, Tab commits it and starts renaming the
layer below, and Shift+Tab the layer above, as in Photoshop; the
Background, which isn't renamed in place, ends it. Alt-clicking a layer's
thumbnail selects the layer and fits its content on screen (View › Fit
Layer(s) on Screen).
Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace
through the shortcut dispatcher. docs/ui-parity.md lists the new keys.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(ui): an explicit channel target drives the Layers panel's mask target
After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back
to the composite, but the Layers panel kept the mask targeted (its own
flag, which only ever followed the engine towards the mask), so painting
still went to the mask. The same happened after clicking the mask
thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets
the panel's mask and vector-mask targets from the engine's target.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Use Document::is_background after #4036 removed extra_cmds::is_background
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.
Fixes#3664
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route.
Co-authored-by: Claude Opus 5.5 <[email protected]>
A second `auth` frame on an authenticated TCP connection fell through to
the method dispatcher and came back as an unknown method, in both the
headless serve server and the desktop control server. A shared
`security::repeat_auth_reply` now answers it with `already authenticated`
(keeping the request id) and the connection keeps serving.
Fixes#3668
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
When drawing faux-bold glyphs, the offset dilation was composed as
`Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o`
first (`self ∘ o`), the translation offset was added after the glyph's
scaling transform, leaving faux bold dilation unscaled by horizontal
and vertical font scaling.
Applying the glyph transform to the offset transform (`glyph.mul(&offset)`)
ensures dilation is performed in font-local coordinates and scaled
proportionally with anisotropic glyph scaling, matching Photoshop.
A tiny file declaring a huge image reserved the full output buffer before any
data was checked. Add Limits::check_plausible (compression-ratio guard) and a
fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default
max_alloc to 1 GiB.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.
- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
"Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
lock the Background's position like every other place that creates one.
Co-authored-by: Claude Opus 5.5 <[email protected]>
- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
they match the channels), except the ones that only describe layers, plus the document-level
tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
layered writer.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* feat(io): open Krita .kra composites as editable raster documents (#3692)
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias
warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and
Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification
filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size
in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render
is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly
this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent.
Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices.
Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its
source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8).
Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or
enlarge the source still take the exact single sample, so identity and unscaled warps are
bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead
of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh
warp's scale varies across the surface: one reduction factor over-blurs the parts that stay
large or under-filters the parts that shrink most.
Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop
exports of real mockups (RMSE of the placed design): 1.78% -> 0.81%
(White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged.
Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at
1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid
black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period.
Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7):
1/3 size: 55 ms -> 87 ms
1/1 size: 240 ms -> 239 ms (no subsampling: identical path)
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Use as_chunks for the opaque-pixel scan (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Free Transform: layer styles render with the transformed layer (#3653), work in progress
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)
The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).
A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.
The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.
Still plain: the selected part of a styled layer, and Warp mode.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Format the type warp texel read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Free Transform: Content-Aware Scale's preview has no styled composite
Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge builds.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Gradient Map: edit color stops on the ramp (#2958)
* Gradient Map: keep stop removal scoped to the editor
* Gradient Map: fit selected stop controls in narrow Properties panels
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.
Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).
The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.
Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).
Co-authored-by: Claude Opus 5.5 <[email protected]>
A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp
mesh) can have control points outside its source rect 0..w x 0..h. Photoshop
maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft
mapped the source rect onto it, so a mesh that spills past the source counted
the overhang twice and the re-rendered smart object came out stretched.
Measured against Photoshop's own export of a real shirt mockup (4200 x 4800
source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch,
the tile offset growing from 0 to -16 px top to bottom; after, alignment is
exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of
the evaluated warp outline (rather than the control points) fixed that file
but put another at 5.4% RMSE, so it is the control-point box.
- blocks::placed_frame: the shared rule. The control-point box of a
single-patch custom warp, else the source rect; a degenerate or non-finite
box falls back to the source rect.
- blocks::parse_smart (and so smart_map::parse_sold): the affine maps that
frame onto Trnf.
- smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of
the same frame, so a spilled mesh round-trips and quad_moved keeps the
imported Trnf as stored. Distort / Perspective placements keep the source
rect, matching parse_smart_perspective.
Refs #579.
Co-authored-by: Claude Opus 5.5 <[email protected]>
A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.
apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.
Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).
Co-authored-by: Claude Opus 5.5 <[email protected]>
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:
- Invert and Posterize act on L, a and b alike (normalised L/100,
(a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).
Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(ui): offer label colors from the layer eye icon
* fix(ui): offer label colors from the layer eye icon
* test(ui): cover layer eye color context menu
* style(ui): keep extracted color menu indentation consistent
Chrome stored the document count when Home opened so File New could leave the overlay. Closing a tab that restored that count re-opened Home and hid remaining tabs (#3981). Clear the stored count as soon as it no longer matches, then keep Home dismissed until the user opens it again.
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen
Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.
- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
(vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
command made it one; a vector mask (vmsk/vsms with its vogk) stays.
Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
- TySh: the four values after the warp descriptor are big-endian f32, not
i32. Written as Photoshop writes them: zeros for unwarped type, the
text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
write.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(ui): allow selecting shapes and editing color with path selection (#3915)
- In vector_ui.rs, implement pick_shape_at to hit-test visible shape layers by path, surface pixels, or live shape geometry.
- In path_selection_finish and canvas ToolEvent::Down, select the clicked shape layer under the pointer when using Path Selection.
- In direct_select.rs, include visible shape layers in candidate hit tests and sync layer.select on press.
- In options_bar, display Fill swatch/checkbox, Stroke swatch/width/style, and corner radius when a shape is selected with Path Selection or Direct Selection, and dispatch shape.edit.
- Add tests in path_selection_tests.rs and vector_ui.rs verifying shape selection on click and options bar editing.
Fixesstorytold/photocraft#3915
* Collapse nested ifs for clippy (collapsible_if)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged.
Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls.
With several layers selected, Export As was greyed: its dialog exported only
the active layer. Someone with 20+ artboards (ads, Steam store capsules) had
to export them one at a time.
Export As now opens the same dialog for the selection ("Export As: N
layers", the preview showing the first) and, on Export, asks for one
destination and writes one file per layer with the dialog's format, quality,
transparency and scale, each at its own size. Files are named as Quick
Export as PNG names a selection's: the chosen path, then numbered siblings
named after each layer, now in the chosen format. The layers are captured
when Export is pressed.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field.
Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm
Printer marks (corner/centre crop, registration) were silently dropped on
Windows GDI prints -- only the PDF export path drew them, as content-stream
vector operators. A workshop test print surfaced the gap.
Adds a Marks type to photocraft-winprint and draws the same geometry GDI's
way (MoveToEx/LineTo for lines, Ellipse for registration circles) once per
page between StretchDIBits and EndPage. The JSON field marksSkipped becomes
textSkipped: description and label text are still PDF-only (no font layout
in the GDI path yet).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
The settings check compared the printer name stored in the DEVMODE with
the queue name. Windows keeps only 31 characters of it, so a long queue
name ("Sublimation - EPSON ET-18100 Series") failed the check and the
chosen paper type and quality were silently dropped: the job printed on
the driver defaults (plain paper).
Settings now go through DocumentProperties(DM_IN_BUFFER | DM_OUT_BUFFER)
before CreateDC, so the driver validates them itself, and a refusal is
an error instead of a silent fallback.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- photocraft-winprint: an isolated unsafe crate (like photocraft-tablet)
for Windows printing: printer list, the driver's Print Settings dialog,
page geometry, and GDI printing with ICM off.
- file.print prints on Windows (explicit "send": true only); new
file.print.printers and file.print.page commands; printerSettings param.
- Print dialog on Windows: Printer menu, Print Settings…, paper from the
driver.
- panic_hunt fuzzes print commands as dry runs.
Closes#2697.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:18:58 +01:00
765 changed files with 107331 additions and 6935 deletions
@@ -131,7 +131,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
-`../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
| `crates/ui-egui/src/i18n/el.tsv` | Greek 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/de.tsv` | German 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/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | 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/it.tsv` | Italian 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/ko.tsv` | Korean 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/nl.tsv` | Dutch 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) |
@@ -19,10 +21,13 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese 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/uk.tsv` | Ukrainian 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/cs.tsv` | Czech 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/hu.tsv` | Hungarian 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/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) |
| `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`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.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`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.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/{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/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -34,8 +39,10 @@ next to them.
| `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/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 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -49,6 +56,7 @@ next to them.
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
### Optional build input: craft-fonts (not files in this repo)
@@ -106,6 +114,6 @@ next to the files:
| `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/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, 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) | 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 |
| `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 |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
@@ -187,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗃️ Layers, done properly</h4>
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
</td>
</tr>
<tr>
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
@@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
@@ -331,8 +331,6 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so).
> **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
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)
@@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules);
architecture and the PSD writer are the serial parts.
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
@@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
| Rank | Milestone | Estimate |
|---|---|---:|
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
| 3 | User bug backlog to under 100 open | 250–400 h |
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
@@ -135,6 +135,7 @@ 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-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 |
@@ -20,6 +20,15 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- 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.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
| Artboards (legacy `ABEn`, or current `.af``phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
"pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged"
"pass `path`: without one, only a PSD, PSB, .pcraft or OpenRaster file that holds the whole document is written back, in its own format, so `{p}` was left unchanged{why}"
)));
}
PathBuf::from(p)
@@ -240,7 +244,7 @@ impl Headless {
}
};
lettarget_text=target.to_str().ok_or_else(||AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
letwarnings=match&self.filesystem{
letmutwarnings=match&self.filesystem{
Filesystem::Denied=>returnErr(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
redistribution conditions or unclear licences, so Photocraft generates its own
redistribution conditions or unclear licences, so PhotoCraft generates its own
(`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with
ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY,
17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink
Loaded 100 of 765 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.