Compare commits

..
Author SHA1 Message Date
Tanish Manku 0ed723924f Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
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.
2026-10-14 15:58:55 -07:00
wwew 98fc6ab072 fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) 2026-10-14 15:06:35 -07:00
Ikki Nishiharaandharanishi 42fb79e909 fix(ui): widen tool-options drag targets (#5135)
Co-authored-by: haranishi <[email protected]>
2026-10-14 15:06:22 -07:00
Ahmed0_0Mamdouh 1f1e8d734f Fix #5414: Keep created paths visible and editable (#5424)
* Paths: keep work and shape paths visible with any tool (#5414)

* Tests: improve path overlay regression coverage
2026-10-14 15:06:13 -07:00
agent-346022andagent-346022 602344bae0 Fix filter preview live during drag (#5549)
* 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]>
2026-10-14 15:06:04 -07:00
David MannandClaude Opus 5.5 64efae47c8 Guide Layout: the disabled Rows field no longer turns Rows on (#5625) (#5626)
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]>
2026-10-14 15:05:55 -07:00
Jason Xiang 2a0bebb49a fix(engine): validate bounded extended filter parameters (#5586) 2026-10-14 15:05:46 -07:00
Sabbir Ahmed 2eabf45ba5 fix(ui): ⇧ marquee square keeps equal sides on the pixel grid (#5472) (#5506)
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
2026-10-14 15:05:36 -07:00
Juan Antonio RedondoandClaude Opus 5.5 1f33e4f6d7 fix(ui): a held arrow key keeps stepping the focused value field (#5474) (#5497)
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]>
2026-10-14 15:05:26 -07:00
DaktylandNigel Ihza Firdausy 051959ea7b Double-clicking the empty workspace shows File › Open (#5367) (#5429)
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]>
2026-10-14 15:05:16 -07:00
Nigel Ihza FirdausyandMarco Arnulfo 23dc83928c fix(ui): expose toolbar column toggle in all themes (#3538)
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]>
2026-10-14 15:05:06 -07:00
Sidney Markowitzandnrq6993-a11y d334d32c12 fix(heif): a grid's ICC profile is inherited from its tiles (#5553)
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]>
2026-10-14 15:04:55 -07:00
Sir Clangington III.andLabDennis 914d51f9bb perf(ui): bound carried selection outlines and stabilize clipping (#5699)
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]>
2026-10-14 15:04:45 -07:00
Vikram BhaskaranandZer0C00L01 0f8c1a4106 Curves: apply to masks, alpha, and Quick Mask (#5335) (#5448)
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]>
2026-10-14 15:04:37 -07:00
optimalbyteeandoptimalbytee 0e15fe77f7 Fix Nikon NEF lossy levels, packed decoding and metadata crops (#3238)
Co-authored-by: optimalbytee <[email protected]>
2026-10-14 15:04:27 -07:00
Michael HigginsandClaude Opus 5.5 7e94c507e9 Smart Objects: Curves preview no longer zooms in on a reduced proxy (#5766) (#5779)
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]>
2026-10-14 15:04:19 -07:00
David Mannandnrq6993-a11y 772dfb9e7e Options bar: Brush and Eraser size go to 5000 px in every theme (#5708) (#5709)
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]>
2026-10-14 15:04:09 -07:00
FinnNkandClaude Opus 5.5 2807deb209 fix(psd): write the layer block after a kept merged-transparency marker (#5673)
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]>
2026-10-14 15:03:59 -07:00
David MannandClaude Opus 5.5 1025914b0e Properties / New Document: tiny sizes in physical units no longer resize the document (#5661) (#5662)
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]>
2026-10-14 15:03:50 -07:00
David Mannandnrq6993-a11y d8d592b1bb FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627) (#5628)
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]>
2026-10-14 15:03:42 -07:00
9a905959ed fix(linux): install the AppImage menu entry when AppImageLauncher skips it (#5551)
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]>
2026-10-14 15:03:32 -07:00
Skyfuller 9cc8be4392 fix(algo): keep clipped warp bounds from overflowing (#5451)
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
2026-10-14 15:03:24 -07:00
Saad AbdullahandSaad Abdullah e1706507b5 fix(geom): clamp huge sizes in Rect::from_xywh instead of wrapping, and saturate TileCoord::rect (#5161)
Co-authored-by: Saad Abdullah <[email protected]>
2026-10-14 15:03:16 -07:00
MarekSpodBiedry f1d13ca9c7 fix(ui): keep the close prompt up when a Save Document script edits the document after saving (#4953) (#5081) 2026-10-14 15:03:07 -07:00
MarekSpodBiedry dbfe8ba412 fix(history): count fill caches and smart filter masks in undo memory (#4972) (#5075) 2026-10-14 15:03:00 -07:00
MarekSpodBiedry 686f3ee944 fix(raw): reject truncated or oversized Deflate tiles in DNG import (#4977) (#5072) 2026-10-14 15:02:52 -07:00
mroxsoandhighperfocused 57d488032c fix(doc): variable definitions follow layer id remapping on copy and load (#5023)
* 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]>
2026-10-14 15:02:43 -07:00
Nigel Ihza Firdausy 4c8101b576 fix(ui): avoid overflow when allocating layer style instance IDs (#4884)
* fix(ui): avoid overflow when allocating layer style instance IDs

* fix(ui): avoid panicking when layer style IDs overflow

* fix(ui): format non-panicking layer style ID allocation
2026-10-14 15:02:35 -07:00
Nigel Ihza Firdausyandbuivanvu94 13f893f998 fix(export): prompt for a Render Video output directory (#3365) (#3378)
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]>
2026-10-14 15:02:27 -07:00
Nigel Ihza Firdausy 5c78d0848f fix: limit Render Video timeline and GIF staging allocations (#5670) 2026-10-14 15:02:17 -07:00
Valentin KleinandClaude Opus 5.5 bcaadccbfa Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204)
* 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]>
2026-10-14 15:02:09 -07:00
8d840eab59 i18n(es): complete dynamic UI translations (#4306)
* i18n(es): complete dynamic UI translations

* es.tsv: keep Blur Angle once after merging main

---------

Co-authored-by: gorevyoneticisi <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:36:32 -04:00
7e0f48a290 Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373) (#3829)
* 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]>
2026-10-14 15:33:05 -04:00
Sir StarchandBrandon Thomas cd046f4a70 Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259) (#2764)
* 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]>
2026-10-14 15:32:39 -04:00
3ebc42a200 Export As: exact pixel size, named presets, and profile options (#5344)
* 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]>
2026-10-14 15:17:44 -04:00
a61df64cc9 feat(ui): select guides, the guide context menu and Edit Selected Guides (#3734)
* 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]>
2026-10-14 15:17:10 -04:00
9788e5ee5e Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665) (#3746)
* 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]>
2026-10-14 15:16:43 -04:00
Fabio LissiandBrandon Thomas 4cc0518169 Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) (#5091)
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]>
2026-10-14 15:16:09 -04:00
9b34c49128 Add Shift+scroll snap zoom to Photoshop zoom ladder levels (#3695)
* 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]>
2026-10-14 15:15:42 -04:00
5cb5ab52fc Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823) (#3827)
* 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]>
2026-10-14 15:15:15 -04:00
9df067b297 Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12) (#5276)
* 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]>
2026-10-14 15:14:49 -04:00
Brandon ThomasandClaude Opus 5.5 1c6f138763 Posterize and Threshold commands use the 16-bit rules on 16-bit layers (#5711)
#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]>
2026-10-14 15:14:30 -04:00
d56aa35bce Blur Gallery: Iris Blur's pins and handles on the image (#3373) (#3822)
* 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]>
2026-10-14 15:14:12 -04:00
21c7cf637a Curvature Pen: edit finished paths (drag, add, smooth/corner) with an adaptive cursor (#5197)
* 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]>
2026-10-14 15:13:46 -04:00
524d608b50 feat(psd): editable Filter Gallery smart filters (Chrome) (#3426)
* 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]>
2026-10-14 15:13:20 -04:00
28e58d20d9 fix(ui): snap distorted corners to visible guides (#3928)
Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:12:49 -04:00
a62ef68ca2 CMYK: Hue/Saturation and Color Balance keep the black ink (#5000)
* 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]>
2026-10-14 14:12:06 -04:00
1e6f565a7b CMYK: adjustment layers composite on the document's own inks (14 oracles pass) (#4945)
* 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]>
2026-10-14 14:11:42 -04:00
92c7922230 CMYK: Brightness/Contrast curves each ink, as Photoshop does (#4930)
* 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]>
2026-10-14 14:11:19 -04:00
852d2a1dfd Resize cursors turn with the box and with a rotated or flipped view (#5188)
* 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]>
2026-10-14 14:11:10 -04:00
00c16f2c6d Modern Brightness/Contrast: Photoshop's brightness curve (six oracles pass) (#4722)
* 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]>
2026-10-14 14:10:57 -04:00
GiuseppeandBrandon Thomas 86037b2354 feature(ui): added brushes panel dockable with floating option (#4262) (#4603)
* feature(ui): added brushes panel dockable with floating option (#4262)

* feat(ui): implement dockable and floating brushes panel (#4262)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:49 -04:00
4c450b3879 Lab: adjustment layers composite on the stored L, a and b (seven Lab oracles pass) (#5171)
* 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]>
2026-10-14 14:10:36 -04:00
4fb866942c Layers panel: Clip Thumbnails to Layer Bounds (#4281) (#4312)
* 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]>
2026-10-14 14:10:22 -04:00
d24f6e9d5f Levels: whole levels at every step and Photoshop's shadow toe (levels_rgb + stack oracle pass) (#5143)
* 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]>
2026-10-14 14:10:14 -04:00
835f33a476 Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751) (#3756)
* 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]>
2026-10-14 14:10:01 -04:00
f5606093cd Brush Settings: a response curve per dynamic control (#3294) (#3302)
* 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]>
2026-10-14 14:09:39 -04:00
973e868173 Brushes: import an image file as a brush tip (#3279) (#3292)
* 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]>
2026-10-14 14:09:08 -04:00
david419krandBrandon Thomas 03aa7e4962 fix(ui): remember the selected tool between launches (Photoshop parity) (#5043)
* 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]>
2026-10-14 14:08:43 -04:00
aad56285ad Batch: Include All Subfolders (AUTO-219-5, #3753) (#3758)
* 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]>
2026-10-14 14:08:20 -04:00
Zach SchneiderandBrandon Thomas 7833a6cad4 Layers panel: filter-type dropdown with Kind, Name and Mode (#5048) (#5268)
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]>
2026-10-14 14:07:59 -04:00
3ee78a368b Free Transform: the box is the selection, and the marching ants follow it (#5297) (#5337)
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]>
2026-10-14 14:07:42 -04:00
620bccef42 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped, as in Photoshop (#5011)
* 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]>
2026-10-14 14:03:03 -04:00
eb26e8f0d7 Masks: feathers blur with exactly the feather's variance (mask-parameters-no-real-channel passes) (#5163)
* 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]>
2026-10-14 14:02:40 -04:00
9954c2935e Threshold: split on Photoshop's luminosity of whole samples (threshold_rgb, threshold_16bits_rgb pass) (#5263)
* 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]>
2026-10-14 14:02:26 -04:00
3feebb9b13 Groups: a pass-through clipping base with an adjustment renders isolated (passthrough_clipping_mask_adjustment passes) (#5272)
* 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]>
2026-10-14 14:01:50 -04:00
8504dc615e Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does (#5267)
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]>
2026-10-14 14:01:01 -04:00
texjerandBrandon Thomas f499d97eee Embedded Profile Mismatch: the profile names on the choices, and Remember this choice (#5208)
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]>
2026-10-14 13:47:43 -04:00
caad010b61 feat(actions): add Button Mode on the current Actions model (#5213)
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]>
2026-10-14 13:47:27 -04:00
c208e8ffc1 feat(ui): Darken and Lighten Interface (Ctrl/Cmd+Shift+F1, F2) (#3709)
* 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]>
2026-10-14 13:47:09 -04:00
da6ceeaf96 Layer Style: Bevel & Emboss Technique dropdown (#5147) (#5181)
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]>
2026-10-14 13:46:51 -04:00
Elvis33LEandBrandon Thomas aa44eafe2c ACEScg: File Handling option opens colour-less EXR renders in the ACES working space (#4908)
* 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]>
2026-10-14 13:46:27 -04:00
David MannandClaude Opus 5.5 a3a47e9cb0 History panel: the trash deletes the selected snapshot (#5324) (#5325)
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]>
2026-10-14 13:46:08 -04:00
fabiodalez-dev 32fdc4e402 i18n(it): translate the 103 labels other catalogs added since #3742 (#4860)
* i18n(it): translate the 103 labels other catalogs added since the last sync

* i18n(it): translate the 53 labels main added
2026-10-14 13:28:56 -04:00
Elvis33LE 3e74c4d8d3 raster: a shifted surface copies in one pass per row without pre-filled tiles (#2667)
* 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)
2026-10-14 13:28:48 -04:00
jonathanwxh-cellandjonathnwxh 7e447dfd40 Start Free Transform after placing an image from the file picker (#5390)
* test(ui): reproduce missing transform after menu placement

* fix(ui): start placement transform after choosing a file

* test(ui): exercise native linked placement file paths

* test(ui): pass context to existing menu wrapper tests

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-14 13:28:42 -04:00
FredApps c73bb0ed95 Fix custom-frame maximization with a top taskbar (#5237)
* 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
2026-10-14 13:28:35 -04:00
Vikram BhaskaranandBrandon Thomas 8215b0b969 Undo and redo Camera Raw adjustments from the keyboard. (#4136)
* 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]>
2026-10-14 13:28:27 -04:00
Sabbir Ahmed 4dec65f4ce fix(ui): guides draw over the Free Transform preview, under its box (#5086) (#5214)
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.
2026-10-14 13:28:18 -04:00
Valentin KleinandClaude Opus 5.5 e51ba6720d Actions: a recorded File > Save of a flat file saves the file it is played on (#3531) (#3537)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:28:11 -04:00
max 6a766345d3 Layer styles: editing effects no longer re-enables a disabled effect list (#3648) (#3799)
* 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
2026-10-14 13:28:04 -04:00
90b3f9e891 Large document: large damage over the GPU budget redraws in the background; open warms layer bounds (#4024) (#5083)
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]>
2026-10-14 13:27:58 -04:00
12a474d550 Interface: hide the Home screen's recent files (#4382) (#4387)
* 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]>
2026-10-14 13:27:52 -04:00
Ivan · VanTan 5ae3ee0ae5 fix(ui): show brush tip thumbnails while dragging presets (#5203)
* fix(ui): show brush tip thumbnails while dragging presets

* test(app): wait for autosave sidecar revision before recovery assertions
2026-10-14 13:08:05 -04:00
Sabbir Ahmed 11c46b7121 fix(ui): swatches touch with no padding, as in Photoshop (#5103) (#5253)
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.
2026-10-14 13:08:00 -04:00
MarekSpodBiedry 94fdd279e8 fix(puppet-warp): drop open Puppet Warp and other distort sessions when the active document changes (#5092) (#5123) 2026-10-14 13:07:55 -04:00
mroxsoandhighperfocused a076f617e0 fix(ui): give the Quick Selection tool its own icon (#5116) (#5184)
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]>
2026-10-14 13:07:48 -04:00
imSlicedBread 237a82b87f fix(ui): show feathering in layer mask views (#5342) 2026-10-14 13:07:41 -04:00
FinnNkandClaude Opus 5.5 51674512db perf(ui): the Info panel composites its Color Sampler readouts once per revision (#4528) (#4753)
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]>
2026-10-14 13:07:35 -04:00
Valentin KleinandClaude Opus 5.5 a7f7f1fd1e Edit › Menus: Menus, Keyboard Shortcuts, Preferences, Save and Exit can't be hidden (#5096) (#5183)
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]>
2026-10-14 13:07:28 -04:00
Pharbieandkwesi f053a924a6 fix(ui): Color Picker hex field accepts repeated pastes (#5398)
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]>
2026-10-14 13:07:22 -04:00
44bb43e94d Number fields: centre the number in its box (#5150) (#5226)
#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]>
2026-10-14 13:07:15 -04:00
0f3d2f133e Type: hovering a font previews it with the Character section shown too (#5152) (#5222)
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]>
2026-10-14 13:07:08 -04:00
Nicolas FalesyandClaude Opus 5.5 89a763a368 Display profile notice: one card for all displays without a usable profile (#5312)
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]>
2026-10-14 13:06:47 -04:00
FinnNkandClaude Opus 5.5 8428645311 perf(gpu): keep the DX12 mip scratch textures for Intel adapters only (#5354)
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]>
2026-10-14 13:06:39 -04:00
MarekSpodBiedry a904bd7a39 Frame tool: take type, shape and smart object layers into the frame (#4435) (#4441) 2026-10-14 13:06:32 -04:00
Ivan · VanTan df10129f6c fix(ui): fit Curves dialog width to its graph (#5160) 2026-10-14 13:06:25 -04:00
Michael HigginsandClaude Opus 5.5 42c5cc64be Canvas: themes without a dot pattern draw no dots texture (#4542) (#5093)
* 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]>
2026-10-14 13:06:18 -04:00
mjohnson7cd 08d9c1d67d fix(transform): keep masked objects visible during transform (#5239) (#5319) 2026-10-14 13:06:12 -04:00
Gert Pellin - GPE fe8fa3b9bc Color Range: sample colours outside the active selection (#5244) (#5296)
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.
2026-10-14 13:06:05 -04:00
Nigel Ihza Firdausy 08def9648d fix(ui): make Preferences modal resizable on desktop (#5170) 2026-10-14 13:05:59 -04:00
mjohnson7cd be64d2dcbc fix(windows): handle GPU initialization failures gracefully (#5191) (#5309)
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.
2026-10-14 13:05:53 -04:00
mroxsoandhighperfocused b37a148759 ui: remove the title-bar Discord button (#1737)
Co-authored-by: highperfocused <[email protected]>
2026-10-14 13:05:47 -04:00
Zach Schneider 72d4359999 Font preview test: skip installed fonts whose Arabic glyphs are blank (#5259) (#5261)
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.
2026-10-14 13:05:26 -04:00
Nigel Ihza Firdausy b8ae1cd92f fix(brush): preserve Dual Brush tip spacing and scale its size with the primary brush (#4856)
* fix(abr): prefer dual brush tip spacing over wrapper spacing

* test(abr): cover conflicting dual brush spacing descriptors

* fix(paint): scale secondary brush tip when changing primary diameter

* fix(engine): preserve dual tip proportions through brush commands

* test(brush): cover secondary tip scaling across settings, strokes and presets

* style: apply rustfmt to Dual Brush ABR code

* style: apply rustfmt to Dual Brush ABR code
2026-10-14 12:47:23 -04:00
Valentin KleinandClaude Opus 5.5 2d5e60ad51 Compose: build a layer's effect-map identity once per render, not once per tile (#4437) (#4438)
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]>
2026-10-14 12:46:51 -04:00
Valentin KleinandClaude Opus 5.5 88b2a21b33 Linked smart objects: read embedded WebP/GIF/BMP/EXR sizes from their headers (#5216)
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]>
2026-10-14 12:46:44 -04:00
David MannandClaude Opus 5.5 8fb3298161 Color Balance: fold the tones into one Levels per channel (#3423) (#5339)
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]>
2026-10-14 12:46:38 -04:00
Wil 0eb008ce5d Actions: record layer selections by name and display the target (#2441)
* 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
2026-10-14 12:46:31 -04:00
Valentin KleinandClaude Opus 5.5 3b45e8baee PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) (#5206)
* 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]>
2026-10-14 12:46:25 -04:00
Gert Pellin - GPE 7b08b56531 feat(engine): layer.list, a read-only listing of the layer tree (#5059) (#5148)
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.
2026-10-14 12:46:18 -04:00
45f8921ae8 Free Transform: solid pixel stretch (edge-repeat sampling, selected-pixel bounds) (#3867)
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]>
2026-10-14 12:46:11 -04:00
8d2806f66c Expose shared Smart Object instances in inspection and replacement results (#4832)
* 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]>
2026-10-14 12:46:04 -04:00
David MannandClaude Opus 5.5 d9eb46b485 Performance: the lower-stack cache follows an edit up the stack (P9 / P11, #5286) (#5288)
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]>
2026-10-14 12:45:59 -04:00
Michael HigginsandClaude Opus 5.5 a37fa8171b GPU compositor: pattern textures are freed on close, when idle and over a budget (#4971) (#5232)
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]>
2026-10-14 12:45:52 -04:00
Michael HigginsandClaude Opus 5.5 b059a6d8b3 ICC parser: checked arithmetic on offsets and counts from the file (#4976) (#5227)
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]>
2026-10-14 12:45:46 -04:00
卢文彬andDeepSeek 6b9e173477 affinity: decoded layers are admitted against an aggregate pixel budget (#4960) (#5266)
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]>
2026-10-14 12:45:41 -04:00
shmuel biller 7b8e5034d0 Fix/scale marker rollback layer state 4975 (#5391)
* fix(engine): restore document targeting on failed compound edits

* test(engine): prove failed scale-marker rollback preserves layer targets

* fix(engine): resync tool mask target after restoring channel view

* test(engine): cover channel and tool-mask rollback after failed compound
2026-10-14 12:45:36 -04:00
Roberto DutraandClaude Opus 5.5 887dcd7ef9 Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) (#5295)
* 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]>
2026-10-14 12:45:30 -04:00
Gert Pellin - GPE 56c5b355a4 feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5140)
* 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.
2026-10-14 12:45:24 -04:00
Valentin KleinandClaude Opus 5.5 3888e95e47 Text: non-finite numbers and negative indices from PSD files are sanitized (#4429) (#4433)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:19 -04:00
Abdul karim mia 1ab33d5113 Map .atn duplicate-into-new-document (Mk) to layer.duplicate (#5371)
* 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
2026-10-14 12:45:11 -04:00
MarekSpodBiedry d12dc6b718 fix(layers): merging layers keeps pixels that lie outside the canvas (#5067) (#5124) 2026-10-14 12:45:05 -04:00
93a16218de Gradient Map is driven by the W3C luminosity, as in Photoshop (#5018)
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]>
2026-10-14 12:44:59 -04:00
Vikram BhaskaranandBrandon Thomas c4bf9eb045 Bound selection sampling buffers and report allocation failures. (#4594)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:54 -04:00
Vikram BhaskaranandBrandon Thomas e34cdcbcca Keep channel pastes transactional when pixel allocation fails. (#4588)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:47 -04:00
Brandon ThomasandClaude Opus 5.5 c699e3cda6 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:16:05 -04:00
Brandon ThomasandClaude Opus 5.5 7250e6e29a Tests: autosave retry waits for the finished write, not a mid-write listing
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]>
2026-10-14 09:21:47 -04:00
MarekSpodBiedry ed7df56a24 fix(format): recover autosaves with missing sidecars
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
2026-10-14 02:02:34 -06:00
Valentin KleinandClaude Opus 5.5 706638b284 Tests: bad-layer-id checks use an id that can't exist (#3343) (#3351)
* 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]>
2026-10-14 01:59:43 -04:00
Valentin KleinandClaude Opus 5.5 74812f4874 IO: abr fit and gradient eval check their own bounds (#5218)
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]>
2026-10-14 01:59:38 -04:00
Timothy DalyandClaude Opus 5.5 ff57499774 PSD: Mt16/Mt32 blocks mark the merged image's transparency (#5178)
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]>
2026-10-14 01:59:32 -04:00
7ad75b5db2 Layer masks: Reveal Selection drops the selection, as Photoshop does (#5251)
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]>
2026-10-14 01:59:27 -04:00
c7366b8938 Drop shadow: knocked out beneath the whole shape when the layer's mode blends with it (#5304)
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]>
2026-10-14 01:59:23 -04:00
c0cb78090b panic_hunt: count panics that Session::execute contains (#4982) (#5219)
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]>
2026-10-14 01:59:18 -04:00
Nicolas FalesyandClaude Opus 5.5 7a0e60c104 test(engine): add Color Range fuzziness 0 exact-match test (TOOL-214-3) (#3581)
* 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]>
2026-10-14 01:59:13 -04:00
Gert Pellin - GPE 7e28900df3 layer.setProps: refuse opacity and fill outside 0..1 (#5242) (#5291)
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.
2026-10-14 01:59:09 -04:00
Gert Pellin - GPE 15f7618504 Object Selection: say so when the sampled layer is empty (#5246) (#5298)
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.
2026-10-14 01:59:04 -04:00
Gert Pellin - GPE 4bbcede572 Fibers: fill an empty layer, as Photoshop does (#5241) (#5289)
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.
2026-10-14 01:59:00 -04:00
Gert Pellin - GPE 2da2889546 Background: an opacity, fill, blend mode or Select and Mask layer mask makes it Layer 0 (#4837)
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
2026-10-14 01:58:55 -04:00
Vikram Bhaskaran 7c916d9ecd Validate selection modification limits before allocating the canvas mask. (#4591) 2026-10-14 01:58:51 -04:00
Vikram Bhaskaran 79e2107f71 Report channel copy allocation failures without losing the clipboard. (#4587) 2026-10-14 01:58:46 -04:00
33b360d5ee Reject clipboard origins that cannot form a new document (#4586)
* 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]>
2026-10-14 01:58:42 -04:00
Gert Pellin - GPE 077e513686 Color Range presets: weigh dark colours like Photoshop (#5243) (#5293)
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 %.
2026-10-14 01:58:36 -04:00
Ivan · VanTan e12b7f7352 fix(prefs): preserve selected themes during legacy migration (#5179) 2026-10-14 01:58:31 -04:00
Zer0C00L01andCursor Agent a954194277 PSD import: ResolutionInfo stays pixels per inch when the display unit is cm (#5280)
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]>
2026-10-14 01:58:27 -04:00
Michael HigginsandClaude Opus 5.5 2388d82165 Docs: oracle floor, open issues, workspace size and layer filters match main (#5376) (#5382)
- 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]>
2026-10-14 01:58:22 -04:00
LorenzoFasolino d11be6de78 docs: drop stale missing-tool rows, only Art History Brush is missing (#5063)
Task T-0002 del lab artcraft-lab.

File toccati:
   docs/brush-parity.md      | 5 +++--
   docs/gaps.md              | 6 +++---
   docs/target-app-parity.md | 5 +++--
   docs/ui-parity.md         | 7 ++++---
   4 files changed, 13 insertions(+), 10 deletions(-)

Evidenze: artifacts/T-0002/ (nel lab, non in questo repo).
2026-10-14 01:58:18 -04:00
wwew 9df59c96b8 docs: write product name as PhotoCraft in development guide (#5228) 2026-10-14 01:58:14 -04:00
skylar taylor-barrick 369704ac55 Merge pull request #4599: native Windows printer settings and RGB printing
Share native Windows driver settings and RGB printing through craft-print
2026-10-13 22:38:25 -06:00
Tanish Manku 583425b4df Stroke latency trace: PHOTOCRAFT_STROKE_TRACE times each frame of a brush stroke (#1343) (#4403)
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.
2026-10-13 18:09:37 -07:00
skylartaylor a9ac922fc1 Align printer menu regression with the merged system-printer cache 2026-10-14 02:21:09 +02:00
skylartaylor 6c1a3590f5 Remove obsolete app-owned Windows printing layer comment 2026-10-13 21:33:44 +02:00
skylartaylor 235d4e22fa Merge current main while preserving native printing and system printer menus 2026-10-13 21:18:34 +02:00
skylar taylor-barrick 3a2d657b9a Keep custom paper for Windows PDF exports and repair merged catalogs 2026-10-13 20:43:45 +02:00
Scott C Hughes 99e1138877 Stroke layer effect: the outer edge stays round at wide sizes (#3646) (#3650)
* 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`).
2026-10-13 13:24:53 -04:00
Elvis33LE 1bcb662828 Deep EXR: open cropped renders on their full display-window stage (#4907)
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.
2026-10-13 13:24:46 -04:00
Ikki Nishiharaandharanishi 2571423bb3 fix(gpu): contain errors in unwatched compositor work (#4894)
* fix(gpu): contain errors in unwatched compositor work

* test(gpu): gate recovery fixtures on format capabilities

---------

Co-authored-by: haranishi <[email protected]>
2026-10-13 13:24:37 -04:00
Gert Pellin - GPE 5064d80b1e TIFF: write and read EXIF as the file's own Exif and GPS directories (#4876)
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
2026-10-13 13:24:27 -04:00
David MannandClaude Opus 5.5 b41e34c20f Rulers: drag the zero point from the ruler corner, double-click resets it (#2955) (#3505)
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]>
2026-10-13 13:24:17 -04:00
Gotya-OFFCIALandGotya-OFFCIAL 34ce5766d2 raw: decode Panasonic RW2 RawFormat 3 and 4 (vendor-compressed) (#3286)
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]>
2026-10-13 13:24:09 -04:00
optimalbyteeandoptimalbytee 50c4f72af7 Speed up large-radius Unsharp Mask with guarded FFT convolution (#2987)
* Speed up large-radius Unsharp Mask with guarded FFT convolution

* Address Unsharp Mask review feedback

---------

Co-authored-by: optimalbytee <[email protected]>
2026-10-13 13:23:55 -04:00
Elvis33LE fc0a579a4a PSD: parallel RLE for the merged image; layer channels decode and gather on all cores (#2754)
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).
2026-10-13 13:23:44 -04:00
ragtuxandClaude Fable 5.1 995bc89c40 Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur (#2073)
* 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]>
2026-10-13 13:23:35 -04:00
DomiLuebbenandClaude Sonnet 5.5 d35725200d i18n(de): translate the labels that were still English (344 entries) and fix three display bugs (#4008)
* 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]>
2026-10-13 13:21:38 -04:00
Timothy DalyandClaude Opus 5.5 be7a1f80fc Blur Gallery: Field Blur pins on the image (#3373) (#3801)
* 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]>
2026-10-13 13:10:11 -04:00
kayrockscreenprintingandClaude Opus 5.5 50631b54f7 Profile prompts on open: one file at a time, before it opens, and the answer goes to that file (#3471)
* 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]>
2026-10-13 13:10:04 -04:00
jonathanwxh-cellandjonathnwxh 01b354a340 Add filename templates to Batch export (#4466)
* Add filename templates to Batch export

* Keep Batch naming evidence out of the shared gap-summary header

* Avoid catalog append conflicts for Batch naming labels

* Keep Batch naming labels clear of catalog append conflicts

* Keep Batch naming details in the automation parity row

* Separate Batch documentation from evolving Actions details

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 13:03:02 -04:00
jonathanwxh-cellandjonathnwxh 5f8733c74d Speed up retouch blur convolution with exact-output coverage (#4511)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:56:28 -04:00
25a82978b2 fix(segment): let Remove Background's cancel stop Select Subject's min cuts (#4878)
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]>
2026-10-13 12:56:21 -04:00
Sir Starch 3260f6abb9 fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928) (#4950)
* 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
2026-10-13 12:56:15 -04:00
MrCloudy2andClaude Opus 5.5 57624bd02e PSD: Backgrounds survive a round trip through PhotoCraft (#3560)
* 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]>
2026-10-13 12:56:08 -04:00
Ian Swope 74cb293570 Large document: full CPU canvas redraws run in the background, visible part first (#4024) (#5001)
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.
2026-10-13 12:49:58 -04:00
9a4d925b34 Brush: remember the last selected brush across restarts (split from #2516) (#3472)
* 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]>
2026-10-13 12:48:41 -04:00
jonathanwxh-cellandjonathnwxh d0fc974aae Wrap Clone Stamp strokes and sampling in Pattern Preview (#4997)
* Wrap Clone Stamp sampling and strokes in Pattern Preview

* Preserve per-dab flow and source alpha across pattern seams

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:48:33 -04:00
David MannandClaude Opus 5.5 a666ab5a85 History: jump to a state in one step (history.selectState) (#2966) (#3533)
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]>
2026-10-13 12:48:26 -04:00
Abdul karim mia ca5755f5b0 Layer: Duplicate Layer to a new document, as in Photoshop (#4805)
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.
2026-10-13 12:48:18 -04:00
ce6fccc3f5 fix(type): missing-font checks follow layout's PostScript resolution; split hyphenless style suffixes (#608)
* 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]>
2026-10-13 12:48:09 -04:00
DeWarePebandClaude Opus 5.5 d740ea638d Puppet Warp: a linked layer mask moves with the pixels (#4910) (#4989)
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]>
2026-10-13 12:48:02 -04:00
Nicolas FalesyandClaude Opus 5.5 4b76f10072 perf: encode and quantize canvas pixels in parallel (display_color, Buffer::to_rgba8) (#4768)
* 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]>
2026-10-13 12:47:55 -04:00
Gert Pellin - GPE b007b1d355 Replace Color: Localized Color Clusters (points, localized, range) (#4477)
* 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
2026-10-13 12:47:47 -04:00
FinnNkandClaude Opus 5.5 b3df06b8c4 perf(engine): lasso and polygon selections rasterise only the rows they span (#4532) (#4773)
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]>
2026-10-13 12:47:40 -04:00
David MannandClaude Opus 5.5 55ae9bc11b Docs: document the engine.commands reply shape (#3684) (#3750)
* 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]>
2026-10-13 12:47:32 -04:00
Sean Hellwig 1460187522 Layers: Cmd/Ctrl-click New Layer adds the layer below the current one (#4475)
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.
2026-10-13 12:47:24 -04:00
.CooCoo 0a5cc0d1a0 Swatches: importing an .ase file numbers repeated group names in linear time (#5087)
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.
2026-10-13 12:47:17 -04:00
F4LCON ef8dc9ea30 Layer locks: locking the pixels clears the transparency lock, as in Photoshop (#4892) (#4898) 2026-10-13 12:47:10 -04:00
Gert Pellin - GPE 1bac788706 info: a smart object embedded in a PSD is reported as embedded (#4843)
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
2026-10-13 12:47:01 -04:00
jonathanwxh-cellandClaude Opus 5.5 ecf54d1baf Count document patterns in history budgets (#5105)
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]>
2026-10-13 12:46:53 -04:00
fec0573e71 fix(window): reopen at the saved window position instead of re-centring (#4826) (#4850)
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]>
2026-10-13 12:46:46 -04:00
Gert Pellin - GPE 663863e62b Smart Objects > Export Contents: another extension exports the contents in that format (#4847)
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
2026-10-13 12:46:40 -04:00
Nigel Ihza Firdausy 0df333f623 fix(gpu): require consent to persist CPU recovery (#3539) 2026-10-13 12:46:33 -04:00
FinnNkandClaude Opus 5.5 52fcca883e perf(paint): sampled tips share their samples, so cloning brush settings is cheap (#4534) (#4798)
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]>
2026-10-13 12:46:25 -04:00
Tarek Mohamed 2307acef92 feat(select): preserve existing mask metadata and allow explicit target in refineEdge (#4499)
- 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.
2026-10-13 12:46:17 -04:00
Vikram Bhaskaran b52115fc0a Preserve document and history when Fade cannot allocate its buffers. (#4593) 2026-10-13 12:46:10 -04:00
Gert Pellin - GPE f389d4ee64 info: adjustment layers are reported with the params their command takes (#4871)
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
2026-10-13 12:46:03 -04:00
Persi 207d05a0e2 Channels: keep the cached composite across view-only changes (#4915)
* 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.
2026-10-13 12:45:56 -04:00
Vikram Bhaskaran 213770008a Return allocation errors when clearing a color channel. (#4589) 2026-10-13 12:45:49 -04:00
Vikram Bhaskaran 759780e40d Clip Fill source reads to the canvas before allocating pixels. (#4590) 2026-10-13 12:45:42 -04:00
Gert Pellin - GPE 0bdaf905cc Curves: points outside 0..255 are refused, as the message says (#4868)
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
2026-10-13 12:45:35 -04:00
Antonandkuvshin888 911881880d Web: show the brush preset storage banner only while a change is unsaved (#4918)
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]>
2026-10-13 12:45:26 -04:00
David MannandClaude Opus 5.5 523f1f06bc Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532) (#2589)
* 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]>
2026-10-13 12:41:08 -04:00
Aurosandluy 2e242bde67 Fix Export As freeze on large layered PSDs (#5104)
Co-authored-by: luy <[email protected]>
2026-10-13 12:41:01 -04:00
bazandClaude Opus 5.5 1e916a4e4a Pen: Ctrl/Cmd edits the path being drawn in place (#4022) (#4526)
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]>
2026-10-13 12:34:23 -04:00
jonathanwxh-cellandjonathnwxh 907752daf2 Give Image Statistics a source-file dialog and stack mode selector (#4778)
* 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]>
2026-10-13 12:34:16 -04:00
Gert Pellin - GPE d3d86da130 Select › Color Range: Skin Tones (#4473)
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
2026-10-13 12:34:04 -04:00
F4LCON 96c0241b26 Distort: a quad dragged past the horizon draws only its front side, in the preview and the result (#4863) (#4870) 2026-10-13 12:33:53 -04:00
Tarek Mohamed c4f0b8aa8b fix(compose): redraw shapes crossing canvas bounds and include vector bounds in damage (#3946) (#4493) 2026-10-13 12:33:47 -04:00
Marco Arnulfo e4f005d3dc Palette PNGs open in Indexed Color mode with their colour table (part of #4804) (#4817) 2026-10-13 12:33:39 -04:00
Valentin KleinandClaude Opus 5.5 efb67fc826 Text: binary-search the run lookup and probe smcp once per layout (#4430) (#4432)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:33 -04:00
Ikki Nishiharaandharanishi 66e07cbf79 fix(gpu): refresh patterns when defaults or formats change (#5013)
Co-authored-by: haranishi <[email protected]>
2026-10-13 12:33:27 -04:00
FinnNkandClaude Opus 5.5 752f611264 perf(compose): the shape stroke split cache keeps to a byte budget and empties on Purge (#4530) (#4774)
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]>
2026-10-13 12:33:21 -04:00
MrCloudy2andClaude Opus 5.5 5f2c23e142 Filters: Photoshop's Blur More kernel, Find Edges strength and Polar Coordinates direction (#4652)
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]>
2026-10-13 12:33:15 -04:00
FinnNkandClaude Opus 5.5 cdfec72f71 perf(ui): the Navigator draws the GPU canvas's texture instead of a CPU thumbnail (#4518) (#4701)
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]>
2026-10-13 12:29:25 -04:00
Dan HiornsandClaude Opus 5.5 13697cf5f5 Preset panels: the footer stays in view in a short dock group (#4790) (#4824)
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]>
2026-10-13 12:29:19 -04:00
JGFandClaude Opus 5.5 033ec991c2 fix(ui): show a magnifier cursor for the Zoom tool on Windows (#3735) (#3783)
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]>
2026-10-13 12:29:13 -04:00
Dan HiornsandClaude Opus 5.5 ef2905ea8e Control: list and answer the unsaved-changes prompt (#4920) (#4924)
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]>
2026-10-13 12:24:35 -04:00
b21956aef7 fix(ui): Match Color's preview reads the Source document (#4851) (#4886)
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]>
2026-10-13 12:24:28 -04:00
avdyliisni9-pngandZulejhaAvduli cd35577c45 Polygonal Lasso: drop the polygon in progress when another document becomes active (#5015)
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]>
2026-10-13 12:24:22 -04:00
F4LCONandpersi-man 62bf7391ce New Document: a custom background colour, from Custom… or the swatch beside the menu (#4789) (#4801)
* 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]>
2026-10-13 12:24:16 -04:00
f4b3766b91 Smart Filters: drag a filter up or down its stack to reorder it (#4897)
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]>
2026-10-13 12:18:42 -04:00
Dan HiornsandClaude Opus 5.5 33d9cb6ac4 UI fonts: draw a variable CJK fallback at Regular, not Thin (#4820) (#4830)
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]>
2026-10-13 12:18:36 -04:00
OctaNebulaandClaude Opus 5.5 026de5e2a3 fix(layers): Ctrl+Alt+G releases the clipping mask when pressed again (#4927)
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]>
2026-10-13 12:18:28 -04:00
Dan HiornsandClaude Opus 5.5 ad62f350e5 Symmetry: Transform Symmetry gets commit/cancel buttons and its own cursors (#4495) (#4508)
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]>
2026-10-13 12:18:21 -04:00
Michael HigginsandClaude Opus 5.5 c72dda1914 Assign Profile: list only profiles of the document's colour mode (#4951) (#5020)
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]>
2026-10-13 12:11:56 -04:00
imSlicedBread a0627311ab Fix window dragging with modal dialogs (#4913) 2026-10-13 12:11:50 -04:00
f43d019a72 Brush Settings: use an amount slider for Hue Jitter (#4874)
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]>
2026-10-13 12:11:44 -04:00
Dan HiornsandClaude Opus 5.5 05d361a367 Shortcuts: ⌥⇧⌘N makes a new layer without the dialog (#4794) (#4807)
⇧⌘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]>
2026-10-13 12:11:37 -04:00
Dan HiornsandClaude Opus 5.5 e4d281eb20 Brushes: preset card size numbers stay clear of the tips (#4454) (#4485)
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]>
2026-10-13 12:11:31 -04:00
Marco Arnulfo 2ca12e17b5 Direct Selection: arrow keys nudge the selected anchors (fixes #4486) (#4500) 2026-10-13 12:11:24 -04:00
FinnNkandClaude Opus 5.5 33728243fb perf(gpu): closing a document frees what the compositor holds for it (#4529) (#4772)
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]>
2026-10-13 12:07:27 -04:00
deadmeuandClaude Opus 5.5 533b9a1c62 perf(raster): moving a layer no longer stores tiles of only the default pixel (#4844)
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]>
2026-10-13 12:07:20 -04:00
Timothy DalyandClaude Opus 5.5 f1233bb43a io: a tagged CMYK file's merged image reads through its own profile (#4948)
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]>
2026-10-13 12:07:14 -04:00
Gert Pellin - GPE 7b0dbf182b PNG: a whole-ppi pHYs reads back whole (300 ppi stays 300) (#4857)
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
2026-10-13 12:07:08 -04:00
Nigel Ihza Firdausy 5320cecb6d fix(adjust): preserve midtone luminosity with multiple sliders (#4941) (#4990) 2026-10-13 12:07:03 -04:00
Dan HiornsandClaude Opus 5.5 87aed12d62 Brush import: Dual Brush spacing comes from its tip (#4809) (#4889)
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]>
2026-10-13 12:06:56 -04:00
F4LCON 1168a9dd73 Pen: a new path joins the selected path instead of replacing the Work Path (#4721) (#4730) 2026-10-13 12:03:43 -04:00
Nigel Ihza Firdausy 625141c2ad fix(ui): display uppercase hex values in the color picker (#4859)
* 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
2026-10-13 12:03:37 -04:00
F4LCON 5adba14cd6 Navigator: one zoom row as in Photoshop, the percentage, Zoom Out, the slider and Zoom In (#4840) (#4854) 2026-10-13 12:03:30 -04:00
Valentin KleinandClaude Opus 5.5 cca24dd0d8 Type options: the wheel over the font menu steps through the families (#3350) (#3524)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:03:23 -04:00
jonathanwxh-cellandjonathnwxh f20605219e Keep applied guide layouts visible after preview closes (#5084)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:03:16 -04:00
Nigel Ihza Firdausy 35710a94ad Fix: native gesture fallback dedupe (#5080)
* fix(input): avoid double-applying native pinch during legacy fallback

* test(input): cover native trackpad rotation and preference gating
2026-10-13 12:03:11 -04:00
Alon 77d3478df1 fix(gradient): options-bar Opacity updates the live gradient layer (#5012) (#5045) 2026-10-13 12:03:05 -04:00
Marco Arnulfo 997406a584 Layer Style applies an open Free Transform first, so its preview shows (fixes #4821) (#4835) 2026-10-13 12:02:58 -04:00
Marco Arnulfo 78b70df227 Canvas: the CPU canvas shows crisp pixels when zoomed in (fixes #4810) (#4825) 2026-10-13 12:02:51 -04:00
Marco Arnulfo 665cb581ad Smart Filters: Delete removes the selected filter, not its smart object (part of #4751) (#4764) 2026-10-13 12:02:44 -04:00
Ivan · VanTan eae87bacfa i18n(ru): translate dynamic selection, theme and measurement labels (#4838) 2026-10-13 12:02:38 -04:00
Sir Starch 2ec1c8c0aa Themes: default light theme to Pro Light to preserve the Pro layout (#5016) (#5029)
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.
2026-10-13 12:02:32 -04:00
Dan HiornsandClaude Opus 5.5 334d378f0f Toolbar: clicking a tool's button again closes its open flyout (#4932) (#4937)
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]>
2026-10-13 12:02:26 -04:00
F4LCON 3d54e8cd68 Alerts: Enter is OK, but not the Enter that raised the alert (#4872) (#4880) 2026-10-13 12:02:12 -04:00
F4LCON e50b25f45c Shapes: test that the options bar Fill swatch recolours the shape just drawn (#3997, #3999) (#4510)
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.
2026-10-13 12:02:07 -04:00
Marco Arnulfo 78dea7c6e4 fix(ui): an open Free Transform box takes Zoom tool drags and clicks (part of #4799) (#4808) 2026-10-13 12:02:01 -04:00
Marco Arnulfo 9a4efabe22 A file dropped on the canvas applies the open Free Transform and is placed at once (fixes #4833) (#4849) 2026-10-13 12:01:54 -04:00
mroxsoandhighperfocused 26e003059b fix(ui): a save name without an extension gets the suggested one (#5039) (#5049)
Co-authored-by: highperfocused <[email protected]>
2026-10-13 12:01:47 -04:00
Michael HigginsandClaude Opus 5.5 6f9227efa3 Brush Settings: no section title above the options pane (#3477) (#5017)
* 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]>
2026-10-13 12:01:40 -04:00
Jyrki VesterinenandClaude Sonnet 5.5 1304765067 fix(canvas): Fit View opens small images at 100%, not at the display scale (#5089)
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]>
2026-10-13 12:01:34 -04:00
Nigel Ihza Firdausy ccb33741f9 fix(psd): reserve imported layer IDs when allocating section dividers (#4885) 2026-10-13 11:56:55 -04:00
Nigel Ihza Firdausy 40c9c3c451 Validate merged PSD channel indices before decoding (#4994)
* fix(psd): validate merged channel index before decoding (#4981)

* test(psd): compare decoded channel bytes as vectors
2026-10-13 11:56:47 -04:00
mroxsoandhighperfocused 41e578b4d3 fix(io): a version 3 Photo Filter colour is XYZ relative to the D50 white (#4987)
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]>
2026-10-13 11:56:40 -04:00
1735c81e67 fix(segment): stop push/relabel looping forever on unreachable nodes (#4869)
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]>
2026-10-13 11:56:34 -04:00
Marco Arnulfo 5c73f4fffa TIFF: save uncompressed by default, like Photoshop (fixes #4484) (#4491) 2026-10-13 11:56:27 -04:00
Gert Pellin - GPE cb6ccaafc4 Flat export: a Lab document with one layer isn't reported as flattened (#4853)
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
2026-10-13 11:56:21 -04:00
Gert Pellin - GPE 88ed837a86 Save for Web: JPEG chroma is full (4:4:4) above quality 50, as in Photoshop (#4823)
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
2026-10-13 11:56:16 -04:00
Ufuk Efe fe04f6acaa docs: credit Ufuk Efe in people.toml (#3809) (#5056) 2026-10-13 11:46:50 -04:00
Fabio Lissi c7a34b04ec Scorecard: Smart Object corner skew and perspective is done (FILE-215-6, #426) (#5094)
#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.
2026-10-13 11:46:42 -04:00
shmuel billerandskylar taylor-barrick 2d5a7497fd fix(format): harden directory-bundle cleanup
Protect Unix bundle cleanup with pinned directory handles and remove only recognized content-addressed files. Keep legitimate linked roots and write/search-only directories working, with regression coverage for GC races and permission failures.

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-13 09:20:31 -06:00
Nicolas FalesyandClaude Opus 5.5 6f85054288 fix(clipboard): paste JPEG, BMP and other non-PNG images on Wayland (#2546) (#4752)
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]>
2026-10-13 03:06:34 -04:00
Nigel Ihza Firdausy c0e62673f8 fix(macos): default droplets to a writable user directory (#4746) 2026-10-13 03:06:27 -04:00
5c579ba81a Channels: Channel Options dialog to make alpha channels spot colours (#3761) (#3784)
* 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]>
2026-10-13 02:59:12 -04:00
04f0b0e86b Brush: the Mode dropdown shows in every theme, not only Pro (#4537)
* 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]>
2026-10-13 02:52:14 -04:00
36112e35d4 Brush Settings: disable minimum inputs when Control is Off (#4775)
Keep configured minimum values across Control changes and cover disabled input, independent controls and reactivation with UI regressions.

Refs #3825 (minimum input state only).

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
2026-10-13 02:49:44 -04:00
6cbf4bd24d fix(input): support precision trackpad pinch zoom on Linux (#4786)
* 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]>
2026-10-13 02:49:40 -04:00
Ivan · VanTan a999ce4a77 i18n(ru): fix counted status agreement and font weight labels (#4787) 2026-10-13 02:49:34 -04:00
Nigel Ihza Firdausy 00802b03de feat(new-document): add ISO B3 and B4 print presets (#4784)
* feat(new-document): add ISO B3 and B4 print presets

* test(new-document): compare dimensions without signed integer conversion
2026-10-13 02:49:27 -04:00
null 00000111 67a3db229a i18n(pt-br): fix 12 Brazilian Portuguese strings (#4796)
Typos, European Portuguese spellings, grammar slips, and terms that
disagree with the catalog's own wording elsewhere.
2026-10-13 02:49:23 -04:00
isaiahoadeoye-starandClaude Opus 5.5 d3b2dd1aef feat(print): Printer menu lists the system's printers (file.printers) (#4748)
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]>
2026-10-13 02:49:04 -04:00
6ddb0e0b0d Fix print fuzzing and Windows crop GPU test fixtures (#4726)
* 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]>
2026-10-13 02:48:36 -04:00
5b1b6b075e feat(export): offer ICO in Export As with dimension validation (#4744)
* 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]>
2026-10-13 02:48:25 -04:00
Nigel Ihza FirdausyandBrandon Thomas cb95e17fc1 fix(type): preserve keyboard focus when searching for fonts (#4745)
* 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]>
2026-10-13 02:48:13 -04:00
4fdf1a723b fix(batch): skip recorded Quick Export steps when writing batch output (#4747)
* 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]>
2026-10-13 02:48:04 -04:00
29f3656fe6 perf(algo): filters read the selection once per tile when mixing it back (#4533) (#4754)
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]>
2026-10-13 02:47:53 -04:00
f2a1af86e1 perf(camera-raw): convert both previews in parallel bands (#4682) (#4757)
* 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]>
2026-10-13 02:47:42 -04:00
Vikram BhaskaranandBrandon Thomas 753b610105 Keep gradient stops closer than one LUT step on the CPU. (#4712)
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]>
2026-10-13 02:47:31 -04:00
Marco ArnulfoandBrandon Thomas 25e29e2583 Filters: ⌘Z steps the open dialog's settings instead of the document (part of #4697) (#4715)
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]>
2026-10-13 02:47:18 -04:00
6af9e1d977 feat(ui): bidirectional hover highlighting between Layers panel and canvas (#4718) (#4719)
Co-authored-by: safvanedge <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:07 -04:00
c758ebffac perf(raster): mode and depth conversion keep shared tiles shared (#4531) (#4725)
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]>
2026-10-13 02:46:54 -04:00
Marco ArnulfoandBrandon Thomas 7610116123 Layers panel: clicking a row's lock icon unlocks the layer (fixes #4723) (#4734)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:43 -04:00
10b936f159 perf(ui): Duotone and live-adjust display documents are built once per snapshot (#4527) (#4736)
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]>
2026-10-13 02:46:32 -04:00
42b399881a Handle signed coordinate limits in the Offset filter (#4738)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:22 -04:00
95a9a6b29c Layers: Add vector mask makes the mask from the selected path and targets it (#4740)
* 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]>
2026-10-13 02:45:53 -04:00
F4LCONandBrandon Thomas f4e3dc63b6 Properties: Align and Distribute with the Background in a multiple selection (#4608) (#4637)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:45:42 -04:00
015ad7083a Select › Sky: a white or grey backdrop that reaches the bottom is not sky (#4638)
* 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]>
2026-10-13 02:45:31 -04:00
2d190e7c57 perf(engine): Edit › Copy shares whole tiles instead of rebuilding them (#4516) (#4621)
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]>
2026-10-13 02:45:20 -04:00
4062d40070 Color: Dot Gain 20%-compatible default working gray, as in Photoshop (#4610)
* 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]>
2026-10-13 02:45:09 -04:00
f2c52a92c9 Pen: a close-path pointer over the first anchor (#4596) (#4612)
* 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]>
2026-10-13 02:44:57 -04:00
f57db2b5bb feat(gpu): composite Blend If on the GPU (#4395) (#4541)
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]>
2026-10-13 02:11:23 -04:00
af21e1e21b fix(text): preserve hyphenated PSD font identity on reopen (#3878)
* fix(text): preserve hyphenated PSD font identity on reopen

* fix(io): preserve installed font families during PSD import

---------

Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:11:19 -04:00
Brandon ThomasandClaude Opus 5.5 ebffaa6e42 i18n: drop duplicate Japanese rows; Serbian for the mask-to-selection items (#4793)
#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]>
2026-10-13 02:04:35 -04:00
mjohnson7cdandBrandon Thomas e0f6ce4302 fix(ui): floating panel groups get an opaque title strip in Studio themes (#4509) (#4543)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:04:10 -04:00
e18e3c066f OpenRaster: never overwrite an .ora with less than the document has (#3806) (#3881)
* 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]>
2026-10-13 02:04:02 -04:00
mjohnson7cdandBrandon Thomas d3e33d89a9 fix(ui): click and hold on a toolbar button selects its tool (#4453) (#4544)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:51:23 -04:00
becc27ac1c CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral (#3930)
* 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]>
2026-10-13 01:38:52 -04:00
Scott C Hughesandskylar taylor-barrick 9ccd4aa557 fix(engine): preserve copied smart object source records
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]>
2026-10-12 23:38:45 -06:00
skylar taylor-barrick 728579aea5 Integrate current PhotoCraft UI while retaining native driver settings 2026-10-13 07:36:47 +02:00
cda46050f7 Background Eraser: sample and match against the pre-stroke pixels (#4552)
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]>
2026-10-13 01:28:29 -04:00
skylar taylor-barrick f85eddf3cc Complete native printing translations and Serbian theme labels 2026-10-13 07:20:25 +02:00
Marco ArnulfoandBrandon Thomas 4eb8ca25bd Layers: right-clicking a layer-mask thumbnail opens the mask menu (fixes #4575) (#4605)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:15:13 -04:00
Marco ArnulfoandBrandon Thomas 93397b08c6 Fill keys: Alt/Cmd+Backspace recolour the text of a type layer (fixes #4580) (#4606)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:15:00 -04:00
F4LCONandBrandon Thomas ad5b11a120 Layers panel menus: highlight the item a press-drag is over (#4601) (#4607)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:49 -04:00
Marco ArnulfoandBrandon Thomas 9716b2b319 Gradient options bar: the swatch covers its checkerboard at half-pixel offsets (fixes #4577) (#4616)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:37 -04:00
cf804c4b27 feat(brush): Photoshop's measured round tip (#4617)
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]>
2026-10-13 01:14:26 -04:00
bf5d01b00d Layers panel: shape layers show a shape badge on their thumbnail (#4584) (#4619)
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]>
2026-10-13 01:14:14 -04:00
Jason BrashearandBrandon Thomas 781871ff93 Codecs: FITS keeps long CONTINUE strings intact on open and save (#4633)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:52 -04:00
9c3f96649f fix(transform): Photoshop's Bicubic for Free Transform and Warp, with a reduction prefilter (#4635)
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]>
2026-10-13 01:13:40 -04:00
Marco ArnulfoandBrandon Thomas 8da02bf540 Hue/Saturation: colour ranges apply in full to dull colours (part of #4089) (#4645)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:26 -04:00
f8cfc1131a Cursors: the pen tools show their icon, and Caps Lock swaps it for the crosshair (#4639) (#4650)
* 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]>
2026-10-13 01:13:16 -04:00
a63964dd63 Update Japanese translations in ja.tsv (#4651)
* 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]>
2026-10-13 01:13:03 -04:00
Marco ArnulfoandBrandon Thomas bb15340af6 Filter dialogs: the preview keeps square pixels (fixes #4640) (#4655)
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]>
2026-10-13 01:12:50 -04:00
Marco ArnulfoandBrandon Thomas 481b939fdf PSD save: keep the Lr16/Lr32 padding outside the block length (part of #4654) (#4663)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:12:39 -04:00
Dan HiornsandClaude Opus 5.5 caabe4b836 Screen color picker: every way out ends it (#4291) (#4636)
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]>
2026-10-13 01:12:15 -04:00
f769c3735d perf(engine): Fill, Stroke and Clear keep tiles the selection doesn't reach (#4517) (#4677)
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]>
2026-10-13 01:11:53 -04:00
0de20b5523 perf(ui): Channels thumbnails skip selection-only steps and reuse their textures (#4518) (#4678)
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]>
2026-10-13 01:11:42 -04:00
233b6c63f0 perf(text): applying preferences no longer waits for the system font scan (#4519) (#4679)
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]>
2026-10-13 01:11:31 -04:00
Nigel Ihza FirdausyandBrandon Thomas 5cff9ebed2 fix(clipboard): prompt to save new documents containing pasted pixels (#4683)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:20 -04:00
bb4e55ebb4 Checkboxes: a pressed box shows it, as in Photoshop (#4476) (#4553)
* 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]>
2026-10-13 01:11:18 -04:00
Marco ArnulfoandBrandon Thomas 45ce763ebb New Document: blank presets follow the picked orientation and physical unit (part of #4668) (#4686)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:09 -04:00
3a307a757e Play real Photoshop action sets: TrueGrit .atn mappings and fixes (#4356, #219) (#4698)
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]>
2026-10-13 01:10:58 -04:00
a80fefce5c fix(ui): expose custom paper dimensions in Print Settings (#3893)
* 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]>
2026-10-13 01:04:53 -04:00
F4LCONandBrandon Thomas 8c135457b4 Brushes: a New Brush Preset dialog for every way of making a preset (#4490) (#4563)
* 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]>
2026-10-13 00:58:50 -04:00
a6e1beb1be Fix: Preview pixelation in Sharpen filters (#3936)
* 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]>
2026-10-13 00:39:08 -04:00
Marco ArnulfoandBrandon Thomas adaebc084d Channels: clicking RGB hides the alpha channel overlay (fixes #4538) (#4564)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:37:42 -04:00
skylar taylor-barrick 06387a955c Apply CI formatter output for print guard 2026-10-13 06:36:33 +02:00
6b6a9234d1 feat(vector): add shape from selection with simplify tolerance (#214) (#3954)
* 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]>
2026-10-13 00:28:50 -04:00
935aa35a4a Add source layer, Scale and Offset to the Apply Image dialog (#4574)
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]>
2026-10-13 00:19:21 -04:00
Vikram BhaskaranandBrandon Thomas cb2cf3ccbd Show a Cancel mnemonic on the unsaved-changes prompt. (#4622)
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]>
2026-10-13 00:14:12 -04:00
Vikram BhaskaranandBrandon Thomas d825840bce Make the space between popup menu rows part of the rows. (#4624)
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]>
2026-10-13 00:14:01 -04:00
Vikram BhaskaranandBrandon Thomas 53db0f5c1f Indent layer-effect rows under their layer. (#4627)
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]>
2026-10-13 00:13:50 -04:00
Vikram BhaskaranandBrandon Thomas 24930b3188 Draw adjustment thumbnails as a half-filled circle. (#4628)
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]>
2026-10-13 00:13:39 -04:00
Vikram BhaskaranandBrandon Thomas 2fc225c782 Translate Turkish Amount as Miktar. (#4629)
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]>
2026-10-13 00:13:28 -04:00
Vikram BhaskaranandBrandon Thomas 438490040b Keep the scrollbar thumb visible while the pointer is on it. (#4630)
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]>
2026-10-13 00:13:16 -04:00
Vikram BhaskaranandBrandon Thomas 339397aabb Reject negative PSD style-sheet indexes and a non-finite dpi. (#4631)
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]>
2026-10-13 00:13:05 -04:00
rgoza1996andBrandon Thomas 9ade93b247 perf(select): reuse run-table buffer in Feather horizontal pass (#4632)
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]>
2026-10-13 00:12:54 -04:00
F4LCONandBrandon Thomas 99fbd8e96b Move tool: the curved rotate arrow just outside its transform controls (#4658) (#4687)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:42 -04:00
Marco ArnulfoandBrandon Thomas 139f4cf01d Fit Image: the dialog is as narrow as its few fields (fixes #4673) (#4692)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:30 -04:00
f02ba1eadd Channels: drag a channel onto New, Delete or Load Selection (#4689) (#4693)
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]>
2026-10-13 00:12:17 -04:00
4fd08b5395 Free Transform: H and V skew fields in the options bar (#4699)
* 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]>
2026-10-13 00:11:54 -04:00
1cfcc31518 Add timeline playback to Save for Web previews (#4703)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:11:40 -04:00
9cba6a66d8 Screen color picker: no DX12 crash, and the overlay can be left on Windows (#4443) (#4704)
* 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]>
2026-10-13 00:11:29 -04:00
715f4fd397 Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3983)
* 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]>
2026-10-13 00:09:46 -04:00
skylar taylor-barrick 3454620f89 Format shared print-disable error 2026-10-13 06:04:03 +02:00
39f3660def Open: warn about type in fonts that aren't installed (#4559) (#4576)
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]>
2026-10-13 00:02:50 -04:00
a7919b24e1 Edit › Transform › Rotate and Skew no longer scale (#3941) (#3986)
* 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]>
2026-10-12 23:55:31 -04:00
skylar taylor-barrick 8f56cd5be5 Preserve explicit PDF export before disabled submission refusal 2026-10-13 05:54:57 +02:00
F4LCONandBrandon Thomas 321b7da12b Load Files into Stack: Photoshop's dialog with a Source Files list and the align option (#4671, #4696) (#4705)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:54:15 -04:00
skylar taylor-barrick f7a3262531 Merge current PhotoCraft and enforce print-disable guard on both transports 2026-10-13 05:52:52 +02:00
F4LCONandBrandon Thomas 25dd3b4794 Save for Web: no matte by default, as Photoshop's scripted export (#4115) (#4578)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:52:16 -04:00
Brandon ThomasandClaude Opus 5.5 68c624003a i18n(sr): translate the Soft Dark and Soft Light theme strings (#4727)
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]>
2026-10-12 23:50:26 -04:00
Nigel Ihza FirdausyandBrandon Thomas c8de7870d5 perf: reuse sampled composite across channel visibility toggles (#4080)
* 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]>
2026-10-12 23:44:50 -04:00
Marco ArnulfoandBrandon Thomas 71968f0806 Free Transform: the live preview feathers a linked layer mask (part of #4560) (#4581)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:38:14 -04:00
Vikram BhaskaranandBrandon Thomas 13a16a695b Resize the Liquify brush with Alt+right-drag instead of painting. (#4707)
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]>
2026-10-12 23:35:35 -04:00
Vikram BhaskaranandBrandon Thomas 964f9fb9d0 Sample every visible layer in Content-Aware Fill by default. (#4133)
* 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]>
2026-10-12 23:35:21 -04:00
70537058eb Open Canon CR3 camera JPEG previews with container orientation (#4595)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:29:29 -04:00
adfeab1750 Composited CMYK documents now export as CMYK TIFF and JPEG with their profile (#2220) (#4199)
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]>
2026-10-12 23:29:26 -04:00
Gert Pellin - GPEandBrandon Thomas 09db9f1424 Content-Aware Fill: no tinted blotch in a smooth gradient (#4708)
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]>
2026-10-12 23:27:49 -04:00
876bb8e281 Check peak RAW development memory and bound AHD scratch (#4597)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:25:42 -04:00
23daf16f82 Align tighter camera JPEG references for RAW colour fitting (#4598)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:24:18 -04:00
Brandon ThomasandClaude Opus 5.5 72b499a28f Tile grid overlay: gate rayon to native so the web build compiles (#4694)
#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]>
2026-10-12 23:20:45 -04:00
Fabio Lissi 70e182b08f panic_hunt: don't fuzz the print commands into a real printer (#4467)
* 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.
2026-10-12 23:13:42 -04:00
15aac8d7b6 Type: type on a path, glyphs follow the curve (#4365) (#4377)
* 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]>
2026-10-12 23:11:09 -04:00
e8af9d592f Themes: Soft Dark and Soft Light (#4368) (#4378)
* 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]>
2026-10-12 23:06:37 -04:00
skylar taylor-barrick d6fbac1720 Pin qualification fixes integrated with current print progress 2026-10-13 05:04:13 +02:00
f5f0f73f25 feat(ui): Linux caption buttons follow the desktop layout, style and icon theme (#4439)
* 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]>
2026-10-12 23:01:08 -04:00
skylar taylor-barrick 70059ac1b4 Pin driver option retention qualification fixes 2026-10-13 04:56:32 +02:00
20539b5dc3 i18n(sr): add a Serbian (Latin, Ekavian) UI catalog (#4436)
* 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]>
2026-10-12 22:50:20 -04:00
0a32b3593d Add Krita (.kra) import (#4388)
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]>
2026-10-12 22:41:38 -04:00
F4LCON 05e98e7ade Smart Filters: select a filter, drag or ⌥-drag it onto another smart object (#4271) (#4371) 2026-10-12 22:35:54 -04:00
2842973dc0 Compose: no overflow or unbounded allocation on hostile coordinates (#4427) (#4431)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:35:39 -04:00
skylar taylor-barrick 4de93daa91 Pin pre-submission annotation visibility guard 2026-10-13 04:25:32 +02:00
F4LCON b4aea45e19 Actions: record File › Save a Copy and play it back as a copy (#4373) (#4390) 2026-10-12 22:24:18 -04:00
Valentin KleinandClaude Opus 5.5 f586e7133e Compose: key the vector-mask cache by mask content, not a temporary's address (#4426) (#4428)
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]>
2026-10-12 22:24:15 -04:00
F4LCON 1f76d839e3 SVG: a group clipped by one path opens as editable shapes with a vector mask (#4266) (#4425) 2026-10-12 22:24:11 -04:00
FredApps 78c61e6df5 Swatches: an RGB group with the exact primaries and secondaries (#4302) (#4409)
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.
2026-10-12 22:24:07 -04:00
Valentin KleinandClaude Opus 5.5 5aa6e33adf Content-Aware Move: live preview of the result (#4385) (#4393)
* 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]>
2026-10-12 22:24:03 -04:00
Marco Arnulfo 88f84aab59 Raw: treat DNG samples piled up below the stated white level as clipped (fixes #4348) (#4389)
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.
2026-10-12 22:23:59 -04:00
Valentin KleinandClaude Opus 5.5 20e8f949f9 Type: fall back to installed Indic fonts for document text (#4383) (#4386)
* 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]>
2026-10-12 22:23:55 -04:00
jonathanwxh-cellandjonathnwxh 4543ef35b9 Preserve signed HDR colors in float text rasterization (#4379)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-12 22:23:50 -04:00
Valentin KleinandClaude Opus 5.5 ee8aca562b Engine: reject unknown params for layer.hideLayers/showLayers via an opt-in registry check (#4336) (#4364)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:46 -04:00
f80e57fb8a Fix macOS system dialog localization (#4261)
* 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]>
2026-10-12 22:16:46 -04:00
575f8eb2bc Allow toolbar tool shortcuts to be remapped (#4194)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:16:06 -04:00
cee010ef5a Replace Color: a canvas eyedropper picks the From colour (#4143) (#4247)
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]>
2026-10-12 22:03:15 -04:00
4d4103460f Toolbar: a press outside a tool flyout closes it (#4460) (#4480)
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]>
2026-10-12 22:01:17 -04:00
1cb3eaa5d2 Free Transform: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) (#4218)
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]>
2026-10-12 21:57:47 -04:00
b1b44595b7 Toolbar: no tooltips over an open tool flyout (#4450) (#4469)
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]>
2026-10-12 21:56:29 -04:00
875e17ea33 Save As: on Windows, picking another file type changes the extension (#4394) (#4407)
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]>
2026-10-12 21:56:24 -04:00
c1e3ae9f16 Pen: Auto Add/Delete anchors on the active path (#4144) (#4540)
* 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]>
2026-10-12 21:56:19 -04:00
c96842f7d9 The wheel steps the options bar's Mode dropdown for Brush, Pencil and Gradient (#2061) (#4447)
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]>
2026-10-12 21:56:14 -04:00
a9bb38c96e Clone Source: clip the overlay to a shaped brush tip (#4372) (#4376)
* 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]>
2026-10-12 21:56:09 -04:00
Marco Arnulfo 2cb3b70d97 Crop tool: the drawing crosshair, not the move cursor, inside the untouched default frame (fixes #4461) (#4481) 2026-10-12 21:47:50 -04:00
F4LCON 1bc7d7baad Puppet Warp: ⌘Z / ⌘⇧Z undo and redo pin edits (#4440) (#4479) 2026-10-12 21:47:45 -04:00
Dan HiornsandClaude Opus 5.5 40a93b7dc4 Toolbar: click and hold on Change Screen Mode opens its modes (#4444) (#4464)
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]>
2026-10-12 21:47:41 -04:00
Dan HiornsandClaude Opus 5.5 98ec507375 Paths: curves stay smooth when zoomed in (#4084) (#4445)
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]>
2026-10-12 21:47:36 -04:00
F4LCON 6c2934ba79 Recent files: a saved document is listed, as an opened one is (#4400) (#4411) 2026-10-12 21:47:32 -04:00
F4LCON 851916e0d7 Save As: offer ICO, GIF and BMP in the file type list (#4357) (#4405) 2026-10-12 21:47:27 -04:00
Tanish Manku 3f2ac1601e macOS: big brushes keep a native outline too, and big cursor bitmaps rasterise 7x faster (#1343) (#4401)
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.
2026-10-12 21:47:23 -04:00
F4LCON f5d64a66bd Color panel: the spectrum field in every theme (#4367) (#4399) 2026-10-12 21:47:18 -04:00
10ba166a82 feat(codecs): open DDS textures, uncompressed and BC1-BC3 (#1327) (#4255)
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]>
2026-10-12 21:47:16 -04:00
FredApps b507e04f1f Menus keep clear of a panel that covers a maximized window (X11) (#3737) (#4380)
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.
2026-10-12 21:47:14 -04:00
Valentin KleinandClaude Opus 5.5 f1855caf73 Type tool: spelling suggestions in the edit menu (#4370) (#4375)
* 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]>
2026-10-12 21:47:09 -04:00
Valentin KleinandClaude Opus 5.5 72ef526d8e Pen: show a close cue when hovering the first anchor (#4193) (#4361)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:04 -04:00
0002d861ee fix(io): read CS2 placed layers that carry only a PlLd block (#2465) (#4268)
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]>
2026-10-12 21:44:49 -04:00
e90dfdcf10 feat(ui): Photoshop's type formatting keys while editing text (#4239)
* 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]>
2026-10-12 21:41:23 -04:00
ccfc878a14 Camera Raw Filter: save and load settings as .xmp (#4279)
* 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]>
2026-10-12 21:34:54 -04:00
465a663540 feat(ui): developer tile grid overlay (ui.set tileGrid) (#4217)
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]>
2026-10-12 21:31:32 -04:00
8157d1a851 Free Transform: refuse outputs over a pixel budget instead of building them (#4293)
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]>
2026-10-12 21:27:12 -04:00
Valentin KleinandClaude Opus 5.5 dd258cccf9 Type: faux bold keeps antialiased edges at small sizes (#4130) (#4360)
* 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]>
2026-10-12 21:27:07 -04:00
Valentin KleinandClaude Opus 5.5 f6db7a8115 Smart Objects Warp: mesh knots span the contents, not the skewed bounding frame (#4113) (#4341)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:03 -04:00
FredApps 96f4256ced fix(type): the Type tool's canvas pick skips text in hidden groups (#4320) (#4332) 2026-10-12 21:26:59 -04:00
MrCloudy2andClaude Opus 5.5 b79d0a0bcf feat(gradients): Photoshop's interpolation method for Gradient Overlay, gradient Stroke and the Gradient tool (#4292)
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]>
2026-10-12 21:26:53 -04:00
Nigel Ihza Firdausy ab5a6c5ed2 fix(layer-picker): include text bounds in right-click layer list (#4288) 2026-10-12 21:26:49 -04:00
1412ac26e2 Save for Web and Export As remember their settings across restarts (FILE-215-18) (#4184)
* 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]>
2026-10-12 21:26:47 -04:00
Persi 37ee4f5a20 Simplify the raster stale-block filter for clippy's nonminimal_bool (#4314)
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.
2026-10-12 21:26:45 -04:00
97d06b240b feat(ui): add hamburger menu to the Brush Settings panel (#4272) (#4358)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:24:09 -04:00
AlonandClaude Opus 5.5 ab82871271 fix(compose): paint interior effects over the layer's blend mode (#3996) (#4231)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:57 -04:00
David MannandClaude Opus 5.5 95663451c0 Find Edges and Solarize: treat CMYK values as ink (#4222) (#4223)
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]>
2026-10-12 21:17:54 -04:00
David MannandClaude Opus 5.5 468d1c622a Bitmap mode: validate the halftone screen angle (#4214) (#4215)
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]>
2026-10-12 21:17:49 -04:00
David MannandClaude Opus 5.5 8029b061af EXR: an unnamed part can be opened by the name the part list gives it (#4210) (#4211)
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]>
2026-10-12 21:17:45 -04:00
David MannandClaude Opus 5.5 a0e9ee213d Cryptomatte: decode the .blue/.alpha rank of every channel set (#4208) (#4209)
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]>
2026-10-12 21:17:41 -04:00
David MannandClaude Opus 5.5 72c8f58e11 Shapes: a rectangle with a negative size extends from its origin like the other shapes (#4203) (#4204)
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]>
2026-10-12 21:17:38 -04:00
David MannandClaude Opus 5.5 e4f15eecdf Vector masks: validate feather like pixel masks (#4201) (#4202)
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]>
2026-10-12 21:17:14 -04:00
JGFandClaude Opus 5.5 63a0e43e74 fix(type): key font menu caches on the shared engine's fonts (#4200)
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]>
2026-10-12 21:17:10 -04:00
David MannandClaude Opus 5.5 56869d12e3 Layer Comps: the warning icon clears only its own comp (#4197) (#4198)
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]>
2026-10-12 21:17:06 -04:00
David MannandClaude Opus 5.5 71827a614c Color Sampler: keep the CMYK readout in range on 32-bit documents (#4191) (#4192)
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]>
2026-10-12 21:17:01 -04:00
David MannandClaude Opus 5.5 60c825513c Type: every text edit respects Lock All (#4188) (#4189)
#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]>
2026-10-12 21:16:56 -04:00
94ea94f889 feat(ui): add GRD import option to gradient map preset picker (#4337)
* 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]>
2026-10-12 21:14:16 -04:00
858fbcf1ad Object Selection: hover preview of the object it would select (Object Finder) (#4087) (#4095)
* 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]>
2026-10-12 21:06:58 -04:00
908c47021f Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) (#4308)
* 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]>
2026-10-12 21:05:59 -04:00
1874214f79 fix(ui): keep brush settings section lock icons permanently visible (#4278) (#4300)
* 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]>
2026-10-12 21:00:58 -04:00
Korg1ch 0d6a83e3ce fix(ui): add Brushes button at top of Brush Settings control list (#4265) (#4354)
* 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)
2026-10-12 20:56:18 -04:00
skylartaylor d9bc5a8cd5 Use shared native print transport and keep page composition in PhotoCraft 2026-10-13 02:56:11 +02:00
Korg1ch 4122bc6918 fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) (#4335)
* 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)
2026-10-12 20:56:09 -04:00
Korg1ch ec61df6f06 fix(ui): make clickable tab, checkbox, and radio labels non-selectable (#3972) (#4331)
* 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)
2026-10-12 20:55:47 -04:00
Dan HiornsandClaude Opus 5.5 68a31b5a12 Free Transform: unlinking W and H frees the handles too (#4174) (#4322)
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]>
2026-10-12 20:55:38 -04:00
Lessa 315c9cc0bc Give numeric type controls translated accessible names (#4310)
* Give numeric type controls translated accessible names

* Find the size and leading row by accessible names in layout tests
2026-10-12 20:55:28 -04:00
Dan HiornsandClaude Opus 5.5 e777690ac6 Properties: the Studio-layout themes show it for every layer (#4246) (#4299)
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]>
2026-10-12 20:54:20 -04:00
Dan HiornsandClaude Opus 5.5 21b018c8a9 Brush Settings: Brush Tip Shape lines up with the other section names (#4282) (#4294)
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]>
2026-10-12 20:54:11 -04:00
Nigel Ihza Firdausy 94b6042289 fix(linux): follow GNOME's light default when the portal has no preference (#4289) 2026-10-12 20:53:58 -04:00
6f41b324a6 Canvas: a tool drag past the edge auto-scrolls the view (#4183) (#4257)
* 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]>
2026-10-12 20:53:49 -04:00
Marco Arnulfo 8145bea70f Control: a transport that gives up knows whether the command was dispatched (#3662) (#4256)
* 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.
2026-10-12 20:53:33 -04:00
.CooCoo 3e3a948c0b Tests: GPU startup marker tests wait while another test's child process holds the lock (#4251)
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.
2026-10-12 20:53:09 -04:00
e627b01169 feat: resize Save for Web previews and custom palette editing for Gif export (#4129)
* 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]>
2026-10-12 20:50:00 -04:00
Fabio Lissi 137d7c2df1 Tests: set the Move tool's Auto-Select target to Layer in the layout_perf clicks (#4056) (#4548)
#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.
2026-10-12 20:44:01 -04:00
David MannandClaude Opus 5.5 2f0979f61f Global Light: reject angles beyond f32 range instead of storing NaN (#4181) (#4182)
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]>
2026-10-12 20:38:05 -04:00
David MannandClaude Opus 5.5 0fe109f7b2 Contours (.shc): keep long names valid and readable (#4177) (#4179)
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]>
2026-10-12 20:38:01 -04:00
David MannandClaude Opus 5.5 43c7584d5d Warp: move only the selected part of a linked mask (#4175) (#4176)
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]>
2026-10-12 20:37:56 -04:00
David MannandClaude Opus 5.5 e4bfa7907f Video layers: edits keep the timeline work area (#4170) (#4171)
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]>
2026-10-12 20:37:52 -04:00
David MannandClaude Opus 5.5 a256f1c579 Paint Bucket and Gradient: validate opacity like the Magic Eraser (#4167) (#4168)
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]>
2026-10-12 20:37:47 -04:00
David MannandClaude Opus 5.5 f4b73f42ed Automation: reject an invalid document index instead of using the active document (#4160) (#4161)
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]>
2026-10-12 20:37:40 -04:00
David MannandClaude Opus 5.5 a8a30aed33 Layers panel: show the lock icon for every lock (#4158) (#4159)
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]>
2026-10-12 20:37:34 -04:00
David MannandClaude Opus 5.5 cb3aef5753 Count tool: Marker Size and Label Size fields take effect (#4156) (#4124)
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]>
2026-10-12 20:37:29 -04:00
David MannandClaude Opus 5.5 991ebe16d5 Liquify, Puppet Warp and Perspective Warp: refuse locked smart objects (#4155) (#4123)
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]>
2026-10-12 20:37:24 -04:00
David MannandClaude Opus 5.5 1014c37eaa Find and Replace Text: backward Change/Find moves past the replaced text (#4154) (#4122)
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]>
2026-10-12 20:37:20 -04:00
David MannandClaude Opus 5.5 426747d7d2 Auto-Select: honour layer mask density when picking layers (#4153) (#4121)
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]>
2026-10-12 20:37:15 -04:00
David MannandClaude Opus 5.5 b0451df33b Compose: the effect cache follows Transparency Shapes Layer and Blend If (#4119)
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]>
2026-10-12 20:37:11 -04:00
David MannandClaude Opus 5.5 b87180e14e Swatches: reject colour values beyond f32 range so presets survive a restart (#4150) (#4118)
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]>
2026-10-12 20:37:07 -04:00
David MannandClaude Opus 5.5 b1b3dff8f1 PSD: a zero-length annotation entry no longer hangs the import (#4149) (#4117)
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]>
2026-10-12 20:37:02 -04:00
0fbba911ba Quick Selection: cut window sizes in parallel, cancel the larger ones once one is kept (#4062)
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]>
2026-10-12 20:29:06 -04:00
96f4d68db7 feat(ui): spring-loaded tool letters (#2672) (#4238)
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]>
2026-10-12 20:23:56 -04:00
7716d609e3 Affinity: rectangles keep per-corner radii and straight corners (#1606) (#4055)
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]>
2026-10-12 20:20:44 -04:00
003f9b4a18 Affinity import: pie shapes with huge angles no longer hang the reader (#4054)
* 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]>
2026-10-12 20:16:56 -04:00
d3d735c6c7 feat(ui): the shaped brush cursor turns and flips with the view (#4233)
#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]>
2026-10-12 20:16:49 -04:00
9712f5e7d5 fix(selection): reject incomplete magic wand pixel buffers (#4046)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:14:41 -04:00
0761234c2f fix(algo): reject oversized Magic Eraser masks without overflow (#4043)
* 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]>
2026-10-12 20:14:33 -04:00
2338492b12 Type: Space inserts a space while editing type (#4224)
* 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]>
2026-10-12 20:11:42 -04:00
Nigel Ihza Firdausy 799f28167f fix(selection): avoid panics when sampling incomplete region masks
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.
2026-10-12 18:10:27 -06:00
ef937adf98 Refresh Liquify's zoomed preview when a stroke is undone. (#4135)
* 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]>
2026-10-12 20:06:24 -04:00
dbadd21a3e Stylus: a pen held still is not a press-and-hold right click (#4106)
* 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]>
2026-10-12 20:06:11 -04:00
xsvm 776b9dddd0 Clone Stamp and History Brush: releasing a stroke commits the live pixels (#4019)
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.
2026-10-12 20:05:09 -04:00
13657f35f2 Docs: refresh target-app-parity and file-format-parity against main (#4417) (#4418)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:04:19 -04:00
af9316b97e Docs: refresh gaps.md against main (#4412) (#4413)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:03:29 -04:00
skylartaylor 3702555aa9 Make native RGB color policy explicit and preserve embedded profiles 2026-10-13 02:02:13 +02:00
4a53fad835 feat(codecs): add XPM2 plain-text image import support (#4264)
* 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]>
2026-10-12 20:01:35 -04:00
5981a02e64 feat(codecs): support full X11 rgb.txt color names in XPM (#4263)
* 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]>
2026-10-12 19:59:36 -04:00
David MannandClaude Opus 5.5 9970cf7214 Brush resize HUD: horizontal is size and vertical is hardness in a rotated view (#4228) (#4229)
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]>
2026-10-12 19:59:11 -04:00
David MannandClaude Opus 5.5 f52549571f Rotate View: the image follows the pointer in a flipped view (#4226) (#4227)
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]>
2026-10-12 19:58:57 -04:00
David MannandClaude Opus 5.5 4566f5f55f Shape tools: the click-to-open-dialog tolerance is the same on HiDPI displays (#4205) (#4207)
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]>
2026-10-12 19:58:43 -04:00
David MannandClaude Opus 5.5 f60202879d Exposure: the slider keeps fractional stops (#4164) (#4165)
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]>
2026-10-12 19:58:30 -04:00
David MannandClaude Opus 5.5 5ce608e5f5 Image Size and Canvas Size: no silent resize of large documents in physical units (#4162) (#4163)
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]>
2026-10-12 19:58:16 -04:00
Vikram Bhaskaran bfa63f77b4 Snap 1 px pencil clicks to the pixel the aliased tip paints. (#4140)
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
2026-10-12 19:58:03 -04:00
Vikram Bhaskaran 4709af046b Draw the Free Transform rotate arrow instead of hiding the pointer. (#4139)
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
2026-10-12 19:57:49 -04:00
Vikram Bhaskaran 9fd20de408 Keep Liquify undo and redo working while a numeric field is focused. (#4134)
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
2026-10-12 19:57:34 -04:00
David MannandClaude Opus 5.5 d323d872b2 Layers panel: double-clicking an effect row opens that layer's Layer Style (#4157) (#4131)
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]>
2026-10-12 19:56:50 -04:00
David MannandClaude Opus 5.5 b26bab69ee Layer Style: OK keeps an Inner Glow's Center source (#4152) (#4120)
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]>
2026-10-12 19:56:47 -04:00
SckraTandClaude 1e9f1ae486 perf(engine): read selection coverage per row when mixing edits back (#4424)
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]>
2026-10-12 19:55:21 -04:00
SckraTandClaude e683108613 perf(compose): read PHOTOCRAFT_FX_NOCACHE once (#4423)
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]>
2026-10-12 19:55:13 -04:00
SckraTandClaude 3c469bfd05 perf(engine): move layers without a second document clone (#4422)
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]>
2026-10-12 19:55:05 -04:00
SckraTandClaude be43d58f1a perf(compose): stop sweeping the bounds cache on every call once it is large (#4415)
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]>
2026-10-12 19:54:57 -04:00
SckraTandClaude 97448b27f3 perf(raster): moved fills keep sharing one copy-on-write tile (#4414)
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]>
2026-10-12 19:54:50 -04:00
Valentin KleinandClaude Opus 5.5 dc8a3f0b35 Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) (#4352)
* 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]>
2026-10-12 19:54:16 -04:00
skylartaylor 4a730912c0 Preserve PDF text and CMYK paths and reject native layout mismatches 2026-10-13 01:53:16 +02:00
skylartaylor 9f7f8b7a7b Check that driver copy fields remain active after validation 2026-10-13 01:51:12 +02:00
skylartaylor 3c4e1536a0 Merge current PhotoCraft and preserve asynchronous native printing 2026-10-13 01:48:54 +02:00
Valentin KleinandClaude Opus 5.5 46740ad4bc Brush: CMYK foreground paints the picked inks into a CMYK document (#4344) (#4347)
* 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]>
2026-10-12 19:47:14 -04:00
f48a72cbe3 Codecs: open and save DPX frames (#1049) (#4104)
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]>
2026-10-12 19:44:04 -04:00
Valentin KleinandClaude Opus 5.5 3ba0e9419c Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) (#4339)
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]>
2026-10-12 19:39:55 -04:00
7366a0b429 Italian: native-speaker review of the UI catalog (#4290)
* 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]>
2026-10-12 19:38:55 -04:00
Bihacsy LászlóandBrandon Thomas 992d945da7 i18n(hu): informal address in the rows added on merge (#4334)
* 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]>
2026-10-12 19:32:00 -04:00
Marco Arnulfo a1fbfd9a6c Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (#4333)
* 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)
2026-10-12 19:30:58 -04:00
9f98df582a Stylus: a light Windows pen tap is pressure 0, not a full-pressure click (#4072)
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]>
2026-10-12 19:27:14 -04:00
David MannandClaude Opus 5.5 6c0dcf428b Color Table: keep the transparent index inside the palette (#4186) (#4187)
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]>
2026-10-12 19:24:17 -04:00
61c52c08db Brush Settings: toggle switch rows without losing smoothing settings (#4463)
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]>
2026-10-12 19:23:17 -04:00
d303bf9b83 Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip (#4105)
* 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]>
2026-10-12 19:22:05 -04:00
ae72084a9c Resolve every pattern name before a batch delete removes any of them. (#4132)
* 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]>
2026-10-12 19:19:23 -04:00
AlonandClaude Opus 5.5 9d37e1518b fix(move): no trail when dragging a shape past the canvas edge (#3946) (#4069)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:17:36 -04:00
Nigel Ihza Firdausy 5c4ad593de fix(ui): reject rotation angles outside f32 range (#4044) 2026-10-12 19:17:31 -04:00
Nigel Ihza Firdausy fd4ba52f79 fix(ui): cancel unfinished polygon on lasso tool switch (#4021) 2026-10-12 19:17:25 -04:00
67c90e307f Quantize the colour field when Only Web Colors is on. (#4138)
* 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]>
2026-10-12 19:12:59 -04:00
Fabio Lissi 90c42f5555 Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470)
- 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'.
2026-10-12 19:12:35 -04:00
3f0a633056 Make channel filter previews match the committed edit (#3979)
* Fix selected-channel filter previews and reduced Quick Mask proxies

Co-authored-by: Codex <[email protected]>

* Keep the new dialog preview aligned with the selected channel

* Bound full-resolution dialog preview textures to the device limit

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:05:49 -04:00
e2a35b04fd Windows: keep full screen and maximized apart; never restore full screen (#4396)
* 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]>
2026-10-12 19:05:37 -04:00
Fabio Lissi 13f5c104f6 Map the remaining frequent Photoshop action events (#219) (#4465)
- 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.
2026-10-12 19:02:15 -04:00
FredApps 7015f2916d View: Esc leaves full screen modes (part of #4116) (#4145)
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.
2026-10-12 19:01:24 -04:00
bbfaefd5f0 File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955)
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]>
2026-10-12 19:00:03 -04:00
4ff2dc33ca Fix multi-layer Delete and Move drags on a locked Background (#4109)
* 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: #4078 fixes #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]>
2026-10-12 18:56:58 -04:00
Marco Arnulfo cd36d1ea0d Delete with several layers selected deletes them, not the active layer's targeted mask (#4025) (#4078) 2026-10-12 18:52:43 -04:00
Fabio Lissi 9862dfbb09 Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456)
* 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).
2026-10-12 18:51:53 -04:00
Marco Arnulfo 40427f25dc Properties: a new adjustment or fill layer brings Properties to the front (#4028) (#4079) 2026-10-12 18:47:59 -04:00
zenjabbaandzenjabba 95dbb01ad9 fix(gpu): preflight compositor uniform buffer limits (#3931)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 18:44:09 -04:00
mroxsoandhighperfocused 23abc4302b fix(ui): default the Move tool Auto-Select target to Group (#4056) (#4093)
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]>
2026-10-12 18:43:21 -04:00
049a92c288 feat(engine): recolor vector shape layer fill with Alt+Backspace and Cmd+Backspace (#3925)
* 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]>
2026-10-12 18:40:05 -04:00
Fabio Lissi 3204231d7d Load and save Photoshop Actions (.atn) (#4356, #219) (#4434)
* 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.
2026-10-12 18:39:57 -04:00
zenjabbaandzenjabba dd992c7e57 fix(color): validate RGB working profiles atomically (#3924)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 18:34:59 -04:00
7f8b5816ca Use whole group bounds for transforms with active selections (#3970)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 18:30:20 -04:00
xsvm 63fd5ec7b6 Selection: lasso polygons scan only the edges that cross each row (#3959)
* 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
2026-10-12 18:26:39 -04:00
Gert Pellin - GPE d32d3f41bb Save As: a saved .pcraft manifest reports the file it was saved under (#3312) (#3571)
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.
2026-10-12 18:19:22 -04:00
50b97605fb Convert grayscale profiles before untagged exports (#3978)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 18:13:21 -04:00
ddf6696dce Pencil: no 8-bit dither on aliased strokes, one flat colour at partial opacity (#3990)
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]>
2026-10-12 18:10:29 -04:00
buivanvu94 e753a71851 feat(automation): allow Crop and Straighten Photos through automation (#1052) (#3917) 2026-10-12 18:03:32 -04:00
5dac5c6d61 Tests: allow-list language-invariant bit-depth labels, add missing tr/hu 'Enable animations' (#4359) (#4362)
* 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]>
2026-10-12 17:33:30 -04:00
skylar taylor-barrick b3222ee8e7 Merge pull request #3872 from skylartaylor/codex/photocraft-craft-print
Use craft-print for system printing
2026-10-12 15:00:09 -06:00
skylar taylor-barrick 91b08488c8 Validate paired purge availability in MCP resource tests 2026-10-12 20:55:44 +02:00
skylar taylor-barrick 7b0317ff41 Merge PhotoCraft 0.8.2 and stabilize background-job tests 2026-10-12 20:21:10 +02:00
skylar taylor-barrick a301e31864 Merge current main fixes and validate craft-print rollout 2026-10-12 19:39:05 +02:00
Claude d43db3e153 Release 0.8.2: web build back under the wasm size gate
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]>
2026-10-12 12:57:00 -04:00
Claude 7f9c4394c1 Merge #4253: wasm back under the 24 MiB gate
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]>
2026-10-12 12:56:54 -04:00
skylar taylor-barrick 27fecdab44 Translate print queue guidance in new Vietnamese catalog 2026-10-12 16:22:43 +02:00
skylar taylor-barrick 5fe4d09478 Merge current main while preserving craft-print transport contracts 2026-10-12 16:17:51 +02:00
JGFandClaude Opus 5.5 5a0e2e2658 Web: wasm back under the 24 MiB gate with ~1 MiB headroom (25.38 -> 24.17 MB)
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]>
2026-10-12 09:59:25 -04:00
skylar taylor-barrick 83fcbe7d9b Merge current main while preserving hardened print contracts through craft-print 2026-10-12 07:36:25 +02:00
skylar taylor-barrick e3d118e0f3 Separate print queue status from generic cancellable progress controls 2026-10-12 05:53:56 +02:00
skylar taylor-barrick 0467f715c6 Reuse upstream counted catalogs to prevent duplicate keys in PR merge 2026-10-12 05:48:59 +02:00
skylar taylor-barrick 8c557fcb7d Place identical browser engine profile beside upstream override 2026-10-12 05:47:22 +02:00
skylar taylor-barrick 67ca74fe18 Align overlapping CI repairs with upstream while retaining regression coverage 2026-10-12 05:44:48 +02:00
skylar taylor-barrick 82d2b7e0f1 Fill missing plural statuses in coverage-enforced catalogs 2026-10-12 04:54:47 +02:00
skylar taylor-barrick a538567a0d Read plural keys from form arguments and report all catalog gaps 2026-10-12 04:49:59 +02:00
skylar taylor-barrick 6651724db8 Align CLI save test with native destination filename semantics 2026-10-12 04:35:18 +02:00
skylar taylor-barrick 3a06871d69 Keep print queue translations alongside background job controls 2026-10-12 04:11:22 +02:00
skylar taylor-barrick 055e4fee1b Merge remote-tracking branch 'upstream/main' into codex/photocraft-craft-print-sync
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-12 04:10:55 +02:00
skylar taylor-barrick ce48721c6b Translate the system print queue guidance in every UI language 2026-10-12 04:04:07 +02:00
skylar taylor-barrick cf98b91f7b Remove duplicate Czech catalog rows while retaining active translations 2026-10-12 04:03:54 +02:00
skylar taylor-barrick 9db0b30910 Keep browser command orchestration within the existing size budget 2026-10-12 03:53:52 +02:00
skylar taylor-barrick f65f7cc9eb Correct stale HDR Levels test endpoints 2026-10-12 03:24:40 +02:00
skylar taylor-barrick 4bb6ffc46f Record background print hooks as one command 2026-10-12 02:58:32 +02:00
skylar taylor-barrick cc902c681b Report refused print cancellation accurately to automation 2026-10-12 02:56:10 +02:00
skylar taylor-barrick b8ea8206cc Refresh document views after completed print hooks 2026-10-12 02:51:42 +02:00
skylar taylor-barrick 223e9499c3 Keep keyboard shortcut parser available in browser builds 2026-10-12 02:46:08 +02:00
skylar taylor-barrick c6407cac07 Keep scorecard test reference current 2026-10-12 02:34:03 +02:00
skylar taylor-barrick bb46f846d9 Keep print completion tied to its document and hold quit 2026-10-12 02:32:26 +02:00
skylar taylor-barrick 698c0f554d Format printing completion regression 2026-10-12 02:29:59 +02:00
skylar taylor-barrick ccee4894bd Use craft-print for PhotoCraft system printing 2026-10-12 02:28:40 +02:00
moiztermindandClaude Sonnet 5 f9b3c6bd85 winprint: draw crop and registration marks on GDI prints
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]>
2026-10-11 23:19:28 +01:00
MoiztermindandClaude Opus 5.5 ce8d8d950b winprint: let the driver validate settings; never print on defaults silently
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]>
2026-10-11 23:19:07 +01:00
MoiztermindandClaude Opus 5.5 628a54b564 Print on Windows: printer list, driver Print Settings, GDI printing
- 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
679 changed files with 84322 additions and 6251 deletions

No files matched your search

+4
View File
@@ -17,6 +17,10 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
-1
View File
@@ -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.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
-
## 8. Keeping the native format complete
+8 -2
View File
@@ -10,8 +10,10 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `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) |
@@ -22,9 +24,10 @@ next to them.
| `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/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.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 |
@@ -36,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) |
@@ -51,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)
@@ -108,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.
Generated
+1266 -45
View File
File diff suppressed because it is too large. Load diff
+22 -20
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.1"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -50,7 +51,9 @@ zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
egui = "0.36"
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -81,13 +84,14 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -95,19 +99,15 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
# builds and the speed-oriented pixel-processing overrides below retain their settings.
[profile.wasm-release.package.photocraft-engine]
opt-level = "z"
[profile.wasm-release.package.photocraft-io]
opt-level = "z"
[profile.wasm-release.package.photocraft-web]
opt-level = "z"
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
@@ -127,6 +127,7 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
@@ -139,3 +140,4 @@ opt-level = 3
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+3 -3
View File
@@ -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:
+4 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **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 |
+61 -18
View File
@@ -15,19 +15,21 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
@@ -67,17 +69,17 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
@@ -213,6 +215,10 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
if let Some(b) = a.get("--dpx-bits") {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
Ok(o)
}
@@ -292,13 +298,14 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Each missing-font warning is printed once, wherever it is first detected (open, a command or
// the save).
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
@@ -344,9 +351,8 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
warn_all(err, &remaining);
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
// The save repeats the document's missing-font warnings; each was already reported above.
let ws: Vec<String> =
serde_json::from_value::<Vec<String>>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect();
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
@@ -392,6 +398,38 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
@@ -400,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
@@ -410,8 +448,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
@@ -501,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
})
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
@@ -634,6 +672,11 @@ mod missing_font_warning_tests {
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
+69
View File
@@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() {
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
@@ -455,6 +460,40 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
let out = d.join("a.dpx");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--dpx-bits", b]);
}
ok(&mut convert);
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
assert!(back.exists());
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
@@ -684,3 +723,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
assert!(!err.contains("panicked"), "{err}");
}
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
/// PhotoCraft can't play are warned about up front.
#[test]
fn batch_plays_a_photoshop_actions_file() {
use photocraft_engine::actions_cmds::Action;
let d = tmp("atn");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
assert!(skipped.is_empty());
let atn = d.join("Set.atn");
std::fs::write(&atn, bytes).unwrap();
let out_dir = d.join("out");
// Two actions: the set needs --action.
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+1
View File
@@ -337,6 +337,7 @@ mod tests {
let b = Bridge::default();
b.unavailable("SecurityError".into());
let (mut s, _) = session(b.clone());
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
import(&mut s);
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
assert!(b.unsaved());
+12 -18
View File
@@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -305,7 +305,12 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if let Some(error) = self.presets.error() {
// Only while a preset change is unsaved. Storage the browser denies from the start (a
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
// change brings the banner with the reason.
if self.presets.unsaved()
&& let Some(error) = self.presets.error()
{
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
@@ -335,19 +340,7 @@ fn services(inbox: Inbox) -> Services {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the files' contents, not paths. File › Open
@@ -440,6 +433,7 @@ fn mime_for(name: &str) -> &'static str {
Some("gif") => "image/gif",
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
Some("ora") => "image/openraster",
Some("kra" | "krz") => "application/x-krita",
_ => "application/octet-stream",
}
}
+7 -1
View File
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -66,8 +66,14 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
resvg = { version = "=0.45.1", default-features = false }
# Already used by the portal stack; bounded waits let cancelling close the OS request.
futures-lite = "=2.6.1"
async-io = "=2.6.0"
+10 -7
View File
@@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
@@ -14,7 +14,7 @@ fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
portal_code(value).and_then(decode).or_else(gtk_theme)
}
#[cfg(target_os = "linux")]
@@ -39,8 +39,8 @@ fn decode(code: u32) -> Option<egui::Theme> {
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
/// using an absolute path rather than the shell's PATH.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
@@ -55,7 +55,7 @@ fn gtk_theme() -> Option<egui::Theme> {
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
"prefer-light" | "default" => Some(egui::Theme::Light),
_ => None,
}
}
@@ -97,7 +97,7 @@ pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
publisher.publish(portal_code(changed).and_then(decode));
publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme));
}
})
}
@@ -184,7 +184,10 @@ mod tests {
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
// GNOME's "default" appearance is light, even though the portal reports no preference.
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None);
}
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
+399
View File
@@ -0,0 +1,399 @@
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
//!
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
#[cfg(target_os = "linux")]
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Desktop {
Gnome,
Kde,
Other,
}
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
/// the like count as GNOME.
#[cfg(target_os = "linux")]
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
return if v.contains("gnome") {
Desktop::Gnome
} else if v.contains("kde") || v.contains("plasma") {
Desktop::Kde
} else {
Desktop::Other
};
}
if var("KDE_FULL_SESSION").is_some() {
Desktop::Kde
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
Desktop::Gnome
} else {
Desktop::Other
}
}
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
#[cfg(target_os = "linux")]
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
match desktop {
Desktop::Gnome => CaptionFamily::Adwaita,
Desktop::Kde => CaptionFamily::Breeze,
Desktop::Other => {
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
CaptionFamily::Adwaita
} else if theme.starts_with("breeze") {
CaptionFamily::Breeze
} else {
CaptionFamily::Generic
}
}
}
}
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
#[cfg(target_os = "linux")]
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
let mut inside = false;
for line in text.lines().map(str::trim) {
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
inside = name == section;
} else if inside
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
return Some(v.trim().to_string());
}
}
None
}
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
/// defaults: the window menu on the left, minimize, maximize and close on the right.
#[cfg(target_os = "linux")]
fn kwin_layout(kwinrc: &str) -> String {
let side = |key: &str, default: &str| {
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
letters
.chars()
.filter_map(|c| match c {
'I' => Some("minimize"),
'A' => Some("maximize"),
'X' => Some("close"),
_ => None,
})
.collect::<Vec<_>>()
.join(",")
};
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
}
/// What the style is worked out from.
#[cfg(target_os = "linux")]
#[derive(Clone, Default)]
struct Found {
gtk_theme: Option<String>,
/// A GTK decoration layout.
layout: Option<String>,
icon_theme: Option<String>,
}
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
/// the desktop's setting, or its default theme.
#[cfg(target_os = "linux")]
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
match var("PHOTOCRAFT_CAPTION_ICONS") {
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
Some(name) => Some(name.trim().to_string()),
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
}
}
/// The style from what was found; `icons` loads a theme's glyphs.
#[cfg(target_os = "linux")]
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
// GNOME's own default shows Close only; GTK's shows all three.
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
style
}
#[cfg(target_os = "linux")]
fn read_text(path: &std::path::Path) -> Option<String> {
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
}
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
#[cfg(target_os = "linux")]
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
let config = var("XDG_CONFIG_HOME")
.filter(|d| std::path::Path::new(d).is_absolute())
.map(std::path::PathBuf::from)
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
let Some(config) = config else { return Found::default() };
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
let gtk_theme = ini_value(&gtk, "Settings", "gtk-theme-name");
if desktop == Desktop::Kde {
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
Found { gtk_theme, layout, icon_theme }
} else {
let layout = ini_value(&gtk, "Settings", "gtk-decoration-layout");
Found { gtk_theme, layout, icon_theme: ini_value(&gtk, "Settings", "gtk-icon-theme-name") }
}
}
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
#[cfg(target_os = "linux")]
fn load_icons(theme: &str) -> Option<CaptionIcons> {
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
if files.iter().all(Option::is_none) {
return None;
}
let render = move |icon: CaptionIcon, px: u32| {
let i = match icon {
CaptionIcon::Minimize => 0,
CaptionIcon::Maximize => 1,
CaptionIcon::Restore => 2,
CaptionIcon::Close => 3,
};
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
};
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
}
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
#[cfg(target_os = "linux")]
impl IconCache {
fn get(&self, theme: &str) -> Option<CaptionIcons> {
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((name, icons)) = last.as_ref()
&& name == theme
{
return icons.clone();
}
let icons = load_icons(theme);
*last = Some((theme.to_string(), icons.clone()));
icons
}
}
#[cfg(target_os = "linux")]
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
#[cfg(target_os = "linux")]
const LAYOUT_KEY: &str = "button-layout";
#[cfg(target_os = "linux")]
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
#[cfg(target_os = "linux")]
const ICONS_KEY: &str = "icon-theme";
/// A string setting from the portal, unwrapping the variants some portals nest it in.
#[cfg(target_os = "linux")]
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
_ => return None,
}
}
None
}
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
/// work and wakes the UI only when the style changes; the first answer usually lands before the
/// first frame.
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
let var = |k: &str| std::env::var(k).ok();
let desktop = desktop(&var);
let mut found = from_files(desktop, &var);
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (shared, waker) = (Arc::clone(&current), Arc::clone(&wake));
let cache = IconCache::default();
let publish = move |found: &Found| {
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
if *cur != style {
*cur = style;
if let Some(ctx) = waker.get() {
ctx.request_repaint();
}
}
};
let (ready, wait) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
let Some(proxy) = proxy else {
publish(&found);
let _ = ready.send(());
return;
};
// Subscribe before the first read, so a change during start-up isn't lost.
let signals = proxy.receive_signal("SettingChanged").ok();
let read = |namespace: &str, key: &str| {
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
value.and_then(portal_string)
};
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
publish(&found);
let _ = ready.send(());
for message in signals.into_iter().flatten() {
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
match (namespace.as_str(), key.as_str()) {
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
_ => continue,
}
publish(&found);
}
});
// A fast desktop answers before the first frame; a slow one updates the bar later.
if spawned.is_ok() {
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
}
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
}
#[test]
fn the_desktop_comes_from_the_first_variable_set() {
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[])), Desktop::Other);
}
#[test]
fn the_family_follows_the_desktop_then_the_gtk_theme() {
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
}
#[test]
fn kwin_button_letters_become_a_gtk_layout() {
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
assert_eq!(ini_value(rc, "nope", "library"), None);
// Garbage is just no buttons on that side, never a panic.
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
}
fn no_icons(_: &str) -> Option<CaptionIcons> {
None
}
#[test]
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
// The style carries what the loader returns for that name.
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
});
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
}
#[test]
fn layouts_and_overrides_resolve_to_a_style() {
let none = env(&[]);
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
let other = resolve(Desktop::Other, &found, &none, &no_icons);
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
// An unknown override name falls back to what was found.
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
}
#[test]
fn portal_strings_unwrap_nested_variants() {
use zbus::zvariant::{OwnedValue, Value};
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
}
#[test]
fn settings_files_are_read_from_the_config_dir() {
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
let other = from_files(Desktop::Other, &var);
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
let kde = from_files(Desktop::Kde, &var);
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
let _ = std::fs::remove_dir_all(dir);
}
}
+3 -2
View File
@@ -12,7 +12,7 @@ use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
@@ -93,12 +93,13 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let deadline = Instant::now() + REPLY_DEADLINE;
let (mut req, rrx) = ControlRequest::new(method, params);
req.deadline = Some(deadline);
let dispatch = req.dispatch.clone();
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r =
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
+86 -14
View File
@@ -177,6 +177,12 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
plan(backend, crashed, env, safe_gpu, os)
}
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
@@ -227,9 +233,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]).
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -541,6 +548,27 @@ mod tests {
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
/// #3483: A temporary CPU recovery must not change the next launch.
#[test]
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
let gl_crash = crashed("gl", "gl");
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
assert_eq!(backend_to_remember(&cpu), None);
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
assert_eq!(safe.backend, Cpu);
assert_eq!(backend_to_remember(&safe), None);
// An unconfirmed fallback leaves the original automatic selection intact.
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
// A working hardware fallback can still be remembered.
let vk_crash = crashed("auto", "vulkan");
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
assert_eq!(backend_to_remember(&gl), Some(Gl));
// CPU explicitly selected in preferences still works.
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
@@ -608,6 +636,20 @@ mod tests {
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
}
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
/// GL drew a black window; WARP works. GL stays the last resort.
#[test]
fn windows_auto_prefers_warp_over_gl() {
use wgpu::{Backend as B, DeviceType as T};
let svga_gl = cand(0x15ad, T::Other, B::Gl);
let warp = cand(0x1414, T::Cpu, B::Dx12);
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
// Elsewhere GL keeps its rank.
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
@@ -672,7 +714,7 @@ mod tests {
} else {
s.finish();
}
let (prev, _s) = Sentinel::begin(&dir);
let (prev, _s) = begin_when_unlocked(&dir);
assert_eq!(prev, Previous::Clean);
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
let _ = std::fs::remove_dir_all(&dir);
@@ -684,6 +726,22 @@ mod tests {
d
}
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
/// drops its sentinel.
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
let (mut prev, mut s) = Sentinel::begin(dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(dir);
}
(prev, s)
}
#[test]
fn sentinel_detects_a_crash_and_clears_on_success() {
let dir = temp_dir("cycle");
@@ -699,16 +757,7 @@ mod tests {
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
// Another test in this binary spawns a process: between its fork and exec the child briefly
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
let (mut prev, mut s) = Sentinel::begin(&dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(&dir);
}
let (prev, s) = begin_when_unlocked(&dir);
let m = prev.crashed().cloned().expect("crash detected");
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
// This start succeeds: the next one finds nothing.
@@ -721,6 +770,29 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
let dir = temp_dir("shared-lock");
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
// spawned child's copy of the descriptor does until the child calls exec.
let held = File::open(dir.join(MARKER_FILE)).unwrap();
drop(s);
held.lock().unwrap();
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
held.unlock().unwrap();
});
let (prev, s) = begin_when_unlocked(&dir);
release.join().unwrap();
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
@@ -738,7 +810,7 @@ mod tests {
} else {
s.finish();
}
let (previous, s) = Sentinel::begin(&dir);
let (previous, s) = begin_when_unlocked(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
+329
View File
@@ -0,0 +1,329 @@
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
use std::path::{Path, PathBuf};
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
/// theme lists, and the bytes read from one file.
const MAX_THEMES: usize = 32;
const MAX_DIRS: usize = 4096;
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
const SCAN_DEPTH: usize = 3;
const MAX_FILE: u64 = 1 << 20;
/// The size the caption glyphs are drawn at, in the spec's units.
const ICON_SIZE: i64 = 16;
/// A theme or icon name that stays inside its directory.
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
}
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let mut bytes = vec![];
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
Some(bytes)
}
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let absolute = |p: &String| Path::new(p).is_absolute();
let home = var("HOME").filter(absolute).map(PathBuf::from);
let mut dirs = vec![];
if let Some(home) = &home {
dirs.push(home.join(".icons"));
}
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
dirs.push(data.join("icons"));
}
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
dirs
}
/// One `[section]` of an `index.theme`, as (key, value) pairs.
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
let mut inside = false;
let mut pairs = vec![];
for line in index.lines().map(str::trim) {
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
if inside {
break;
}
inside = s == name;
} else if inside && let Some((k, v)) = line.split_once('=') {
pairs.push((k.trim(), v.trim()));
}
}
pairs
}
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
}
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
let size = num("Size", 0);
let scale = num("Scale", 1).max(1);
let d = match get("Type").unwrap_or("Threshold") {
"Fixed" => (size * scale - ICON_SIZE).abs(),
"Scalable" => {
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
}
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
};
(d, scale)
}
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
/// path order: scalable and symbolic ones first, as icons there fit any size.
fn subdirectories(root: &Path) -> Vec<PathBuf> {
let mut found = vec![];
let mut level = vec![root.to_path_buf()];
for _ in 0..SCAN_DEPTH {
let mut next = vec![];
for dir in &level {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten().take(MAX_DIRS) {
let path = entry.path();
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
next.push(path);
}
}
}
found.extend(next.iter().cloned());
level = next;
}
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
found.sort_by_key(|p| (vector(p), p.clone()));
found
}
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
struct Theme {
roots: Vec<PathBuf>,
listed: Vec<PathBuf>,
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
}
impl Theme {
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
/// often add `symbolic/ui` without listing it.
fn unlisted(&self) -> &[PathBuf] {
self.unlisted.get_or_init(|| {
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
})
}
}
/// A theme and the themes it inherits, in search order.
pub struct IconTheme {
themes: Vec<Theme>,
}
impl IconTheme {
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
/// `index.theme`.
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
let mut chain: Vec<String> = vec![name.to_string()];
let mut themes = vec![];
let mut i = 0;
loop {
let Some(theme) = chain.get(i).cloned() else {
// Every chain ends in hicolor, whether or not the themes before it exist.
if chain.iter().any(|t| t == "hicolor") {
break;
}
chain.push("hicolor".into());
continue;
};
i += 1;
if !valid_name(&theme) {
continue;
}
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
// The first index wins; a theme can still be spread over several base directories.
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
let index = String::from_utf8_lossy(&index);
let head = section(&index, "Icon Theme");
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
.chain(list(get("ScaledDirectories")))
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
.take(MAX_DIRS)
.map(|d| (distance(&section(&index, d)), d))
.collect();
subdirs.sort_by_key(|(key, _)| *key);
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
for parent in list(get("Inherits")) {
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
chain.push(parent.to_string());
}
}
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
}
(!themes.is_empty()).then_some(Self { themes })
}
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
/// ones, before its parent.
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
if !valid_name(icon) {
return None;
}
let file = format!("{icon}.svg");
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
}
}
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
/// unreadable SVG or an absurd size.
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
use resvg::{tiny_skia, usvg};
if !(1..=256).contains(&px) {
return None;
}
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
// (`/dev/zero`, a huge picture) or nest an SVG.
let opts = usvg::Options {
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
let size = tree.size();
let side = size.width().max(size.height());
if !side.is_finite() || side <= 0.0 {
return None;
}
let s = px as f32 / side;
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway icon tree: `files` are (path under the base, contents).
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for (path, text) in files {
let p = base.join(path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
base
}
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
#[test]
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
let base = tree(
"chain",
&[
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
("Child/48/a-symbolic.svg", "big"),
// Not in the index: still found, and before the parent's copy.
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
("Parent/ui/d-symbolic.svg", SQUARE),
("Child/16/a-symbolic.svg", SQUARE),
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
("Parent/ui/b-symbolic.svg", SQUARE),
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
("hicolor/s/c-symbolic.svg", SQUARE),
],
);
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
assert_eq!(theme.find_svg("missing"), None);
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
let _ = std::fs::remove_dir_all(base);
}
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
#[test]
fn cyclic_or_hostile_indexes_are_harmless() {
let base = tree(
"hostile",
&[
(
"A/index.theme",
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
),
("A/ok/x.svg", SQUARE),
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
],
);
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn distance_follows_the_spec() {
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
}
#[test]
fn masks_are_square_coverage() {
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
assert_eq!(mask.len(), 32 * 32);
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
assert_eq!(mask[0], 0, "outside it");
assert!(rasterize_mask(b"not svg", 16).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
}
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
#[test]
fn masks_load_no_images() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
<image href="/dev/zero" width="16" height="16"/>
<image xlink:href="data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'&gt;&lt;rect width='16' height='16'/&gt;&lt;/svg&gt;" width="16" height="16"/>
</svg>"##;
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
}
#[test]
fn base_dirs_follow_xdg() {
let var = |k: &str| match k {
"HOME" => Some("/home/u".to_string()),
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
_ => None,
};
let dirs = base_dirs(&var);
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
assert_eq!(dirs, want);
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
}
}
+42 -16
View File
@@ -26,11 +26,14 @@ mod appearance;
#[cfg(target_os = "macos")]
mod apple_events;
mod caps_lock;
mod caption_style;
mod control_server;
mod crash_guard;
mod cursor;
mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -72,7 +75,8 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). Wayland compositors place windows themselves.
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
@@ -90,11 +94,21 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
window_builder: Some(Box::new(at_least_min_size)),
window_builder: Some(Box::new(restored_window)),
..Default::default()
}
}
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
/// Full Screen did nothing until `F` had cycled through Standard.
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
at_least_min_size(builder).with_fullscreen(false)
}
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
@@ -317,6 +331,9 @@ fn main() -> eframe::Result {
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Centre only the first window: eframe centres after restoring the saved position, so a
// window closed on another monitor reopened in the middle of the main one (#4826).
options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
@@ -436,24 +453,13 @@ fn main() -> eframe::Result {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
// CPU rendering after a failed GPU start is temporary. Only the
// recovery-dialog choice may persist it (#3483).
let remember = gpu_startup::backend_to_remember(&plan);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
@@ -482,6 +488,10 @@ fn main() -> eframe::Result {
// The custom title bar's undecorated window must stay borderless, or its caption and
// border offset the content and the pointer on some Windows systems (#2246).
app.services.window_frame = window_frame::service(cc, custom_titlebar);
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
if custom_titlebar {
app.services.caption_style = caption_style::service();
}
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
// precise crosshair for painting tools while it is toggled on (#1758).
app.services.caps_lock = caps_lock::service(cc);
@@ -624,6 +634,22 @@ mod tests {
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
let saved = super::native_options(super::CUSTOM_TITLEBAR)
.viewport
.with_inner_size([2560.0, 1392.0])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true);
let opened = hook(saved);
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
+184 -26
View File
@@ -16,13 +16,13 @@ use std::sync::Arc;
#[cfg(test)]
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
"pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
"abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -43,7 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
@@ -60,8 +60,13 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
("BMP", &["bmp"]),
("GIF", &["gif"]),
("ICO", &["ico"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -77,8 +82,11 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
Some(i) => {
let f = v.remove(i);
v.insert(0, f);
let (name, mut exts) = v.remove(i);
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
exts.retain(|e| *e != ext);
exts.insert(0, ext);
v.insert(0, (name, exts));
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
@@ -86,6 +94,21 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
@@ -127,8 +150,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = Path::new(&suggested).file_name() {
dialog = dialog.set_file_name(name.to_string_lossy());
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
dialog = dialog.set_file_name(name);
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
dialog = dialog.set_directory(dir);
@@ -205,19 +228,7 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts
settings.io_options()
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
@@ -324,6 +335,43 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
})
}
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
#[cfg(target_os = "linux")]
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
#[cfg(target_os = "linux")]
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
image_from_offers(&offers, |mime| {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut bytes = Vec::new();
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
})
}
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
#[cfg(any(target_os = "linux", test))]
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
let bytes = fetch(mime)?;
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
return None;
}
let img = photocraft_codecs::decode(&bytes).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
/// The shell command that starts this install under XWayland, where winit delivers file drops
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
@@ -424,8 +472,13 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
@@ -663,11 +716,66 @@ mod tests {
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
for (name, ext) in asked.iter().zip(exts) {
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
// The first type's first extension is the one Windows adds to the bare name.
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
}
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
let layered = ["psd", "psb", "pcraft", "ora"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
// What Save As writes there: an icon up to 256×256, and a clear error past that.
let doc = |side: u32| {
let mut s = Session::new();
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
s.active().unwrap().doc.clone()
};
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("256"), "{err}");
}
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
#[test]
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
for (suggested, name, first) in [
("/pics/image.psd", "image", "psd"),
("/pics/image.psb", "image", "psb"),
("/pics/image.pcraft", "image", "pcraft"),
("/pics/sky.fit", "sky", "fit"),
("cat.jpeg", "cat", "jpeg"),
("swatches.aco", "swatches", "aco"),
] {
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
}
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
// extension, and a dotfile.
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
}
assert_eq!(dialog_file_name("", true), None);
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
@@ -786,6 +894,7 @@ mod tests {
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
autosave(&mut app, &ctx);
let mut failed = false;
@@ -806,12 +915,16 @@ mod tests {
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
// The worker commits the bundle's manifest before it publishes the sidecar, and
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
// the first non-empty listing can come from the middle of the write. Wait for the write
// to finish: the entry then carries the sidecar's revision.
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if !recovered.is_empty() {
if recovered.iter().any(|e| e.info.revision == revision) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
@@ -886,6 +999,51 @@ mod tests {
assert!(image_from_files(&[bad]).is_none());
}
fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
}
#[test]
fn non_png_clipboard_images_paste() {
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
let mut asked = Vec::new();
let got = image_from_offers(&offers, |mime| {
asked.push(mime.to_string());
Some(bmp.clone())
});
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
let offers = vec!["image/jpeg".to_string()];
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
}
#[test]
fn undecodable_clipboard_offers_fall_through_to_the_next() {
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
let got = image_from_offers(&offers, |mime| match mime {
"image/webp" => None,
"image/png" => Some(cut.clone()),
"image/x-tga" => Some(b"not an image".to_vec()),
_ => Some(bmp.clone()),
});
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
}
#[test]
fn clipboard_tga_is_trusted_only_by_its_type() {
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
+67 -6
View File
@@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
#[cfg(any(target_os = "linux", test))]
const BTN_TOOL_PEN: u32 = 0x140;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
#[cfg(any(target_os = "linux", test))]
const ABS_X: u32 = 0x00;
#[cfg(any(target_os = "linux", test))]
const ABS_Y: u32 = 0x01;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
#[cfg(any(target_os = "linux", test))]
pub fn has_pen(devices: &str) -> bool {
let bits = usize::BITS;
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
#[cfg(any(target_os = "linux", test))]
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
}
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
/// X app would outlive a mouse stroke here), so nothing starts.
@@ -238,6 +256,49 @@ mod tests {
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(keys), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
/// the device's own, from `/proc/bus/input/devices`.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"RustDesk UInput Keyboard\"\n\
P: Phys=\n\
S: Sysfs=/devices/virtual/input/input54\n\
U: Uniq=\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: PROP=0\n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
B: MSC=10\n\
B: LED=7\n";
#[test]
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
assert!(
bit_set(
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
BTN_TOOL_PEN
),
"the fixture claims the key"
);
assert!(!has_pen(VIRTUAL_KEYBOARD));
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
// pen's device and the pen's axes never into the keyboard's.
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
assert!(has_pen(&both));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
assert!(has_pen(pen_display));
// Axes without `ABS_X` or `ABS_Y` don't count.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
}
#[test]
+33 -1
View File
@@ -27,6 +27,23 @@ pub fn sanitize(path: Option<&Path>) {
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
// eframe restores the inner position on macOS and the outer position elsewhere
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
@@ -78,7 +95,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::check;
use super::{check, saved_position};
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -105,6 +122,21 @@ mod tests {
}
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(!saved_position(&file(no_position, "1.0")));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
}
assert!(!super::has_saved_position(None));
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
+77 -41
View File
@@ -81,17 +81,16 @@ mod platform {
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
/// parked but being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
@@ -100,34 +99,58 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
let Some((left, top, right, bottom)) =
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
else {
return;
};
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
return;
};
// Moving the outer frame while winit pins the client rect to the work area must
// not copy the old frame's pixels into the custom title bar.
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
POINT { x: left, y: top },
SIZE { cx: width, cy: height },
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
let _ = hwnd.InvalidateRect(None, false);
}
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
/// both makes the guard idempotent and handles Windows already correcting some edges.
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
let (l, t, r, b) = rect;
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
let (ml, mt, mr, mb) = monitor;
let (wl, wt, wr, wb) = work;
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
return None;
}
if b <= wb + side {
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
let border = [
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
]
.into_iter()
.flatten()
.filter(|n| (0..=FRAME).contains(n))
.min()?;
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
{
return None;
}
Some(wb + side - t)
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
(corrected != rect).then_some(corrected)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
@@ -139,7 +162,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_height};
use super::{borderless, clamped_rect};
use winsafe::co;
#[test]
@@ -156,34 +179,47 @@ mod platform {
}
#[test]
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
// plus the frame the window was parked with.
fn a_maximize_respects_taskbars_on_every_edge() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
let rect = (-8, -8, 1928, 1088);
for (work, expected) in [
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
] {
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
}
}
#[test]
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
let monitor = (-1920, -1080, 0, 0);
let work = (-1920, -1032, 0, 0);
for border in [0, 8, 16, 32] {
let rect = (-1920 - border, -1080 - border, border, border);
let expected = (-1920 - border, -1032 - border, border, border);
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None);
}
}
#[test]
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom
// is still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_away_is_left_alone() {
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
}
#[test]
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 48, 1920, 1080);
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
}
}
+1 -1
View File
@@ -10,5 +10,5 @@
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 18a6 6 0 0 0 0-12v12z" />
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
</svg>

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 317 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<defs>
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
</svg>

After

Width:  |  Height:  |  Size: 549 B

+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
</svg>

After

Width:  |  Height:  |  Size: 466 B

+9
View File
@@ -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:
+38
View File
@@ -124,3 +124,41 @@ sync_github_name = true
[people.arthurdeka]
sync_github_name = true
[people.aweussom]
real_name = "Tommy Leonhardsen"
[people.Furash]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
[people.Hevarh1]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.deadmeu]
sync_github_name = true
[people.megaDon101]
display_name = "megadon"
sync_github_name = false
[people.sssehnsuchttt]
sync_github_name = true
[people.VanTan666]
display_name = "VanTan"
sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
+6 -4
View File
@@ -16,7 +16,7 @@ Affinity files.
| 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 |
| Vector masks (`AdCh` curves and shapes) | vector masks |
@@ -34,7 +34,7 @@ Affinity files.
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
parametric shapes (imported as their bounding ellipses), corner types other than round, stars with
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
@@ -132,8 +132,10 @@ test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
+1 -1
View File
@@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = photocraft_affinity::container::header(data);
let _ = photocraft_affinity::preview(data);
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
});
+1 -1
View File
@@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|stream: &[u8]| {
let _ = photocraft_affinity::stream::parse(stream);
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
});
+6 -1
View File
@@ -26,11 +26,16 @@ pub struct Limits {
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
/// temporary planes the layer being read decodes through. The entry limits bound what is
/// extracted, not what the layers materialized out of it cost (#4960).
pub max_pixel_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
}
}
+19 -3
View File
@@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
Reader::new(&s, &mut archive, limits).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
/// output, which stays with the document; `planes` is the working set it decodes through and
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
/// three together.
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
let peak = rgba.saturating_add(planes);
if peak > self.pixels_left {
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
}
self.pixels_left -= rgba;
Ok(())
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
+5
View File
@@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
};
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
// and the planes it decodes through before either is allocated (#4960).
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
+74 -41
View File
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
@@ -115,13 +131,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
}))?
}
b"ShPi" => {
let start = num(r, shape, b"AngS").unwrap_or(0.0);
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
while end < start {
end += TAU;
}
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
// large enough that floating-point addition no longer changes the endpoint.
let start = raw_start.rem_euclid(TAU);
let delta = raw_end - raw_start;
let sweep = if raw_end >= raw_start {
delta.min(TAU)
} else if delta.is_finite() {
delta.rem_euclid(TAU)
} else {
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
};
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
pie(cx, cy, rx, ry, start, start + sweep, inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
@@ -346,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
+191 -2
View File
@@ -251,9 +251,9 @@ mod archive {
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
@@ -349,3 +349,192 @@ mod archive {
}
}
}
mod shapes {
use crate::container::Limits;
use crate::model::{Document, Kind, Path, Point, SubPath};
use crate::stream::Tag;
use crate::synth::{self, F, Method, tag};
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
)
}
/// Reads a document whose one page holds the object `top` from `definitions`, on a thread of
/// its own, and fails the test when reading takes more than 20 s.
fn read(definitions: Vec<F>, top: u32) -> Document {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
)]),
)],
),
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
other => panic!("expected shape, got {other:?}"),
}
}
#[test]
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
for subpath in &path(&document).subpaths {
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
assert!(subpath.segments.len() <= 20);
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
}
}
#[test]
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
let node =
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]);
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
(path.subpaths[0].clone(), doc.warnings)
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
rectangle(fields)
};
let (sub, warnings) = corners(Some(false));
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let (sub, _) = corners(None);
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
}
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
mod pixels {
use crate::Error;
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::synth::{self, F, Method, tag};
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
fn layer(id: u32, w: i32, h: i32) -> F {
F::Def(
id,
vec![tag(b"Rstr")],
vec![
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
],
)
}
/// A document whose one page holds a pixel layer per `sizes` entry.
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
let data = synth::stream(&[(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
)]),
)],
),
)]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn imported_layers(d: &Document) -> usize {
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
}
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
const ONE_LAYER: usize = 40 << 10;
#[test]
fn a_pixel_layer_within_the_budget_imports() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
}
#[test]
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
let bytes = document(&[(64, 64), (64, 64)]);
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
// Two layers fit once the budget covers their pixels together.
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
}
#[test]
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
assert!(
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
"16 MiB of declared pixels materialized under a 1 MiB budget"
);
}
}
+21 -6
View File
@@ -427,7 +427,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
F::DarkStrokes => 9,
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
F::Spatter => g("sprayRadius").ceil() as i32 + 2,
F::Spatter => (g("sprayRadius") * 1.5).ceil() as i32 + 2,
F::SprayedStrokes => g("sprayRadius").ceil() as i32 + (g("strokeLength") / 2.0) as i32 + 3,
F::SumiE => gs(g("strokeWidth") * 0.2) + (g("strokeWidth") / 2.0) as i32 + 2,
F::Glass => (g("distortion") * 2.0).ceil() as i32 + 2,
@@ -768,9 +768,26 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
win.c[i] = map(win.c[i], |v| (v + (1.0 - v) * lt) * (1.0 - dk) * (1.0 - clamp01(ink)));
}
}
F::Spatter | F::SprayedStrokes => {
F::Spatter => {
// Smoothness smooths the random displacement: a larger feature size and, as with any
// averaged noise, a smaller reach. Low smoothness sprays specks out to the full
// radius; high smoothness leaves a torn but attached, hard edge (what Photoshop
// shows at Smoothness 15).
let rad = g("sprayRadius");
let sc = if e.filter == F::Spatter { 0.6 + g("smoothness") * 0.25 } else { 1.2 };
let feature = 1.0 + g("smoothness") * 0.35;
let reach = (rad * 1.2 / feature).min(rad);
let src = Win { r: win.r, w, h, c: win.c.clone() };
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 / feature, y as f32 / feature);
let dx = (fbm(fx, fy, 91) - 0.5) * 3.0 * reach;
let dy = (fbm(fx, fy, 92) - 0.5) * 3.0 * reach;
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
}
F::SprayedStrokes => {
let rad = g("sprayRadius");
let sc = 1.2;
let src = Win { r: win.r, w, h, c: win.c.clone() };
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
@@ -779,9 +796,7 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let dy = (vnoise(fx, fy, 92) - 0.5) * 2.0 * rad;
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
if e.filter == F::SprayedStrokes {
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
}
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
}
F::SumiE => {
let sw = g("strokeWidth");
+20
View File
@@ -662,6 +662,26 @@ mod tests {
assert!(err < 0.12, "mean error {err}");
}
/// A vertical sky gradient (each row one colour) filled at every adaptation level stays the
/// gradient: no tinted blotch where the copied rows' colours were shifted per channel.
#[test]
fn color_adaptation_leaves_a_smooth_gradient_untinted() {
let (w, h) = (120, 60);
let row = |y: usize| {
let t = y as f32 / (h - 1) as f32;
[0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t]
};
let img: Vec<f32> = (0..w * h).flat_map(|i| row(i / w)).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect();
let src = vec![true; w * h];
for level in ["none", "default", "high", "veryHigh"] {
let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() });
let (out, img) = (&out, &img);
let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max);
assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0);
}
}
/// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied.
fn noise(w: usize, h: usize, seed: u64) -> Vec<f32> {
let mut z = seed;
+990
View File
@@ -0,0 +1,990 @@
//! Depth from focus for Edit › Auto-Blend Layers › Stack Images: a dense, sub-layer depth map
//! from the aligned stack, the index of the layer each pixel is sharpest in.
//!
//! The pipeline is the non-learned "focus volume" recipe, written from the papers:
//!
//! 1. **Focus measure** per layer on luminance at full resolution: the *ring difference filter*
//! of Jeon, Surh, Im & Kweon, *Ring Difference Filter for Fast and Noise Robust Depth From
//! Focus*, IEEE TIP 29 (2019), the magnitude of the difference between the mean of a small
//! disk and the mean of the ring around it, a band-pass whose support averages sensor noise
//! away while staying local. Block-averaged to a working grid (half resolution).
//! 2. **Cost aggregation**: each slice of the focus volume is filtered with the *guided filter*
//! (He, Sun & Tang, PAMI 2013) using the all-in-focus luminance as guide, edge-aware
//! aggregation as in fast cost-volume filtering (Hosni et al., PAMI 2013).
//! 3. **Peak search** per pixel over the layer axis, one slice at a time: the global peak and its
//! two neighbours for the *Gaussian interpolation* of Nayar & Nakagawa, *Shape from Focus*,
//! PAMI 1994 (sub-layer depth), the second-best local maximum for a peak-ratio confidence,
//! the profile mean for a prominence term and the profile minimum for a noise gate.
//! 4. **Regularisation**: the sub-layer depth is smoothed and holes filled by an edge-aware
//! weighted least squares energy (Farbman, Fattal, Lischinski & Szeliski, SIGGRAPH 2008)
//! whose data term is the confidence, solved by conjugate gradients preconditioned by a
//! multigrid V-cycle from the separable sweeps of the *fast global smoother* (Min, Choi, Lu,
//! Ham, Sohn & Do, IEEE TIP 2014) as the start, with one Huber reweighting so isolated
//! outliers stop pulling.
//! 5. **Upsampling** to full resolution with the guided filter's linear coefficients.
//!
//! Everything is deterministic; the slices are folded one at a time, so memory is one working
//! grid per layer plus a few planes.
use crate::photo_util::{par_map, par_rows, par_rows2};
/// Depth-from-focus settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DepthParams {
/// The working grid is `1 / 2^scale` of full resolution (1 = half).
pub scale: usize,
/// Ring difference filter radii: the disk `r ≤ r_in`, the ring `r_in < r ≤ r_out`.
pub r_in: usize,
pub r_out: usize,
/// Guided-filter aggregation radius (working-grid pixels) and regulariser.
pub agg_radius: usize,
pub agg_eps: f32,
/// Smoothness weight and guide-edge sensitivity (luminance units) of the regularisation.
pub lambda: f32,
pub sigma_c: f32,
/// Noise gate: full confidence needs a peak ≥ `(1 + gate) ×` the noise floor (the median
/// over pixels of the profile minimum). 0 = off.
pub gate: f32,
/// Robust data term: after the first solve the data weights are scaled by the Huber factor
/// `min(1, robust / |d − u|)` (in layers) and the system is solved once more. 0 = off.
pub robust: f32,
}
impl Default for DepthParams {
fn default() -> Self {
DepthParams { scale: 1, r_in: 1, r_out: 3, agg_radius: 3, agg_eps: 1e-4, lambda: 3.0, sigma_c: 0.04, gate: 1.0, robust: 1.0 }
}
}
/// The result of [`depth_from_focus`].
#[derive(Clone, Debug, PartialEq)]
pub struct DepthMap {
/// Full-resolution fractional layer index per pixel, in `0 ..= n − 1`.
pub depth: Vec<f32>,
/// Full-resolution confidence in `0 ..= 1`.
pub conf: Vec<f32>,
pub w: usize,
pub h: usize,
}
/// Mean over `k × k` blocks (partial blocks at the far edges use their own count).
pub fn block_mean(src: &[f32], w: usize, h: usize, k: usize) -> (Vec<f32>, usize, usize) {
let k = k.max(1);
let (dw, dh) = (w.div_ceil(k), h.div_ceil(k));
if k == 1 {
return (src.to_vec(), dw, dh);
}
let mut out = vec![0.0f32; dw * dh];
par_rows(&mut out, dw, 1, |oy, row| {
let (y0, y1) = (oy * k, (oy * k + k).min(h));
for y in y0..y1 {
let s = &src[y * w..y * w + w];
for (ox, o) in row.iter_mut().enumerate() {
*o += s[ox * k..(ox * k + k).min(w)].iter().sum::<f32>();
}
}
for (ox, o) in row.iter_mut().enumerate() {
*o /= ((y1 - y0) * ((ox * k + k).min(w) - ox * k)) as f32;
}
});
(out, dw, dh)
}
/// Bilinear resampling of a working-grid plane (block size `k`, samples at block centres) to
/// `w × h`.
pub fn upsample_bilinear(g: &[f32], dw: usize, dh: usize, w: usize, h: usize, k: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
if dw == 0 || dh == 0 {
return out;
}
let inv = 1.0 / k.max(1) as f32;
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) * inv - 0.5).clamp(0.0, (dh - 1) as f32);
let y0 = fy as usize;
let y1 = (y0 + 1).min(dh - 1);
let ty = fy - y0 as f32;
let (r0, r1) = (&g[y0 * dw..y0 * dw + dw], &g[y1 * dw..y1 * dw + dw]);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) * inv - 0.5).clamp(0.0, (dw - 1) as f32);
let x0 = fx as usize;
let x1 = (x0 + 1).min(dw - 1);
let tx = fx - x0 as f32;
let top = r0[x0] + (r0[x1] - r0[x0]) * tx;
let bot = r1[x0] + (r1[x1] - r1[x0]) * tx;
*o = top + (bot - top) * ty;
}
});
out
}
/// Box means over a `(2r + 1)²` window clamped to the plane (partial windows at the borders use
/// their own count): a horizontal moving sum per row in parallel, then a vertical moving sum
/// carried down the rows as one accumulator row.
struct BoxFilter {
w: usize,
h: usize,
r: usize,
inv_count: Vec<f32>,
}
impl BoxFilter {
fn new(w: usize, h: usize, r: usize) -> BoxFilter {
let cnt = |n: usize, i: usize| ((i + r + 1).min(n) - i.saturating_sub(r)) as f32;
let mut inv_count = vec![0.0f32; w * h];
par_rows(&mut inv_count, w, 1, |y, row| {
let cy = cnt(h, y);
for (x, o) in row.iter_mut().enumerate() {
*o = 1.0 / (cy * cnt(w, x));
}
});
BoxFilter { w, h, r, inv_count }
}
fn mean(&self, src: &[f32]) -> Vec<f32> {
let (w, h, r) = (self.w, self.h, self.r);
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
let s = &src[y * w..y * w + w];
let mut acc: f64 = s[..(r + 1).min(w)].iter().map(|v| f64::from(*v)).sum();
for x in 0..w {
row[x] = acc as f32;
if x + r + 1 < w {
acc += f64::from(s[x + r + 1]);
}
if x >= r {
acc -= f64::from(s[x - r]);
}
}
});
let mut out = vec![0.0f32; w * h];
let mut acc = vec![0.0f64; w];
for y in 0..(r + 1).min(h) {
for (a, v) in acc.iter_mut().zip(&tmp[y * w..y * w + w]) {
*a += f64::from(*v);
}
}
for y in 0..h {
for ((o, a), c) in out[y * w..y * w + w].iter_mut().zip(&acc).zip(&self.inv_count[y * w..y * w + w]) {
*o = *a as f32 * c;
}
if y + r + 1 < h {
for (a, v) in acc.iter_mut().zip(&tmp[(y + r + 1) * w..(y + r + 2) * w]) {
*a += f64::from(*v);
}
}
if y >= r {
for (a, v) in acc.iter_mut().zip(&tmp[(y - r) * w..(y - r + 1) * w]) {
*a -= f64::from(*v);
}
}
}
out
}
}
/// Guided filter (He, Sun & Tang 2013) with a fixed grayscale guide: made once per guide and
/// radius, it keeps the guide's statistics so a slice costs only the arithmetic.
struct GuidedFilter {
guide: Vec<f32>,
mean_i: Vec<f32>,
var_i: Vec<f32>,
bf: BoxFilter,
eps: f32,
}
impl GuidedFilter {
fn new(guide: Vec<f32>, w: usize, h: usize, r: usize, eps: f32) -> GuidedFilter {
let bf = BoxFilter::new(w, h, r);
let mean_i = bf.mean(&guide);
let ii: Vec<f32> = guide.iter().map(|v| v * v).collect();
let var_i: Vec<f32> = bf.mean(&ii).iter().zip(&mean_i).map(|(v, m)| (v - m * m).max(0.0)).collect();
GuidedFilter { guide, mean_i, var_i, bf, eps }
}
/// The box-averaged linear coefficients `(ā, b̄)` such that `q ≈ ā·I + b̄`.
fn coeffs(&self, p: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mean_p = self.bf.mean(p);
let ip: Vec<f32> = self.guide.iter().zip(p).map(|(i, p)| i * p).collect();
let corr_ip = self.bf.mean(&ip);
let (mut a, mut b) = (vec![0.0f32; p.len()], vec![0.0f32; p.len()]);
for k in 0..p.len() {
let cov = corr_ip[k] - self.mean_i[k] * mean_p[k];
a[k] = cov / (self.var_i[k] + self.eps);
b[k] = mean_p[k] - a[k] * self.mean_i[k];
}
(self.bf.mean(&a), self.bf.mean(&b))
}
fn filter(&self, p: &[f32]) -> Vec<f32> {
let (a, b) = self.coeffs(p);
(0..p.len()).map(|k| a[k] * self.guide[k] + b[k]).collect()
}
}
/// Ring difference filter taps `(dy, dx, weight)`: `+1/|disk|` for `r ≤ r_in`, `−1/|ring|` for
/// `r_in < r ≤ r_out`.
fn rdf_taps(r_in: usize, r_out: usize) -> Vec<(i64, i64, f32)> {
let (ri2, ro2) = ((r_in * r_in) as i64, (r_out * r_out) as i64);
let (mut disk, mut ring) = (Vec::new(), Vec::new());
let r = r_out as i64;
for dy in -r..=r {
for dx in -r..=r {
let d2 = dx * dx + dy * dy;
if d2 <= ri2 {
disk.push((dy, dx));
} else if d2 <= ro2 {
ring.push((dy, dx));
}
}
}
let (wd, wr) = (1.0 / disk.len().max(1) as f32, -1.0 / ring.len().max(1) as f32);
disk.into_iter().map(|(y, x)| (y, x, wd)).chain(ring.into_iter().map(|(y, x)| (y, x, wr))).collect()
}
/// Per-pixel focus measure of a luminance plane: the magnitude of the ring difference filter,
/// clamped borders.
pub fn focus_measure(y: &[f32], w: usize, h: usize, r_in: usize, r_out: usize) -> Vec<f32> {
let taps = rdf_taps(r_in, r_out.max(r_in + 1));
let mut out = vec![0.0f32; w * h];
if w == 0 || h == 0 {
return out;
}
par_rows(&mut out, w, 1, |yy, row| {
for &(dy, dx, wt) in &taps {
let sy = (yy as i64 + dy).clamp(0, h as i64 - 1) as usize;
let s = &y[sy * w..sy * w + w];
for (x, o) in row.iter_mut().enumerate() {
*o += wt * s[(x as i64 + dx).clamp(0, w as i64 - 1) as usize];
}
}
row.iter_mut().for_each(|v| *v = v.abs());
});
out
}
/// Per-pixel state of the focus profile, updated one slice at a time.
#[derive(Clone, Copy)]
struct Px {
/// Best local maximum (value, left neighbour, right neighbour; −1 = none) and its index.
c1: f32,
l1: f32,
r1: f32,
i1: u32,
/// Second-best local maximum (−1 = none).
c2: f32,
/// The last two slice values.
prev: f32,
prev2: f32,
sum: f32,
cmin: f32,
}
impl Px {
fn register(&mut self, val: f32, idx: u32, l: f32, r: f32) {
if val > self.c1 {
if self.c1 >= 0.0 {
self.c2 = self.c1;
}
(self.c1, self.i1, self.l1, self.r1) = (val, idx, l, r);
} else if val > self.c2 {
self.c2 = val;
}
}
}
/// The streaming peak search over the layer axis.
struct PeakTracker {
px: Vec<Px>,
m: usize,
}
impl PeakTracker {
fn new(n: usize) -> PeakTracker {
PeakTracker { px: vec![Px { c1: -1.0, l1: -1.0, r1: -1.0, i1: 0, c2: -1.0, prev: 0.0, prev2: 0.0, sum: 0.0, cmin: f32::INFINITY }; n], m: 0 }
}
/// Fold the next slice (aggregated focus values ≥ 0).
fn push(&mut self, c: &[f32]) {
let m = self.m;
for (p, &v) in self.px.iter_mut().zip(c) {
if m >= 1 && (m == 1 || p.prev >= p.prev2) && p.prev > v {
let l = if m >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (m - 1) as u32, l, v);
}
p.sum += v;
p.cmin = p.cmin.min(v);
(p.prev2, p.prev) = (p.prev, v);
}
self.m += 1;
}
/// Close the profiles: the sub-layer depth and the raw confidence per pixel.
fn finish(mut self, gate: f32) -> (Vec<f32>, Vec<f32>) {
let n = self.m;
if n == 0 {
return (vec![0.0; self.px.len()], vec![0.0; self.px.len()]);
}
for p in self.px.iter_mut() {
if n == 1 || p.prev >= p.prev2 {
let l = if n >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (n - 1) as u32, l, -1.0);
}
}
// The noise floor: the median of the per-pixel profile minimum (subsampled).
let mut mins: Vec<f32> = self.px.iter().step_by(7).map(|p| if p.cmin.is_finite() { p.cmin } else { 0.0 }).collect();
let floor = if mins.is_empty() {
0.0
} else {
let mid = mins.len() / 2;
*mins.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1
};
let inv_n = 1.0 / n as f32;
let out: Vec<(f32, f32)> = par_map(self.px.len(), |i| {
let p = &self.px[i];
let (c1, l, r) = (p.c1, p.l1, p.r1);
let mut delta = 0.0f32;
if l >= 0.0 && r >= 0.0 && c1 > 0.0 {
// Gaussian interpolation (Nayar & Nakagawa 1994): a parabola in the log domain.
let (ll, lc, lr) = (l.max(1e-12).ln(), c1.ln(), r.max(1e-12).ln());
let den = ll - 2.0 * lc + lr;
if den < 0.0 {
delta = (0.5 * (ll - lr) / den).clamp(-0.5, 0.5);
}
}
let conf = if c1 > 0.0 {
let prom = (1.0 - p.sum * inv_n / c1).clamp(0.0, 1.0);
// A rival local maximum of similar height means an ambiguous profile.
let pkr = if p.c2 >= 0.0 { (1.0 - p.c2 / c1).clamp(0.0, 1.0) } else { 1.0 };
let g = if gate > 0.0 && floor > 0.0 { ((c1 - floor) / (gate * floor)).clamp(0.0, 1.0) } else { 1.0 };
prom * pkr * g
} else {
0.0
};
(p.i1 as f32 + delta, conf)
});
out.into_iter().unzip()
}
}
/// Scale the confidence so its 90th percentile maps to 1: `lambda` then means the same on every
/// stack.
fn normalize_conf(conf: &mut [f32]) {
let mut sample: Vec<f32> = conf.iter().step_by(7).copied().collect();
if sample.is_empty() {
return;
}
let k = (sample.len() * 9 / 10).min(sample.len() - 1);
let p90 = *sample.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
if p90 > 1e-6 {
conf.iter_mut().for_each(|c| *c = (*c / p90).min(1.0));
}
}
/// 3×3 median, clamped borders.
fn median3(src: &[f32], w: usize, h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, w, 1, |y, row| {
let ys = [y.saturating_sub(1), y, (y + 1).min(h - 1)];
let mut v = [0.0f32; 9];
for (x, o) in row.iter_mut().enumerate() {
let xs = [x.saturating_sub(1), x, (x + 1).min(w - 1)];
for (k, (yy, xx)) in ys.iter().flat_map(|yy| xs.iter().map(move |xx| (*yy, *xx))).enumerate() {
v[k] = src[yy * w + xx];
}
v.sort_unstable_by(|a, b| a.total_cmp(b));
*o = v[4];
}
});
out
}
/// Edge weights between horizontal and vertical neighbours: `exp(−|ΔI| / σ)`; `ax[i]` sits
/// between `(x, y)` and `(x + 1, y)`, `ay[i]` between `(x, y)` and `(x, y + 1)`.
fn edge_weights(guide: &[f32], w: usize, h: usize, sigma: f32) -> (Vec<f32>, Vec<f32>) {
let inv = -1.0 / sigma.max(1e-6);
let (mut ax, mut ay) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
par_rows2(&mut ax, &mut ay, w, |y, rx, ry| {
let g = &guide[y * w..y * w + w];
for x in 0..w.saturating_sub(1) {
rx[x] = ((g[x] - g[x + 1]).abs() * inv).exp();
}
if y + 1 < h {
let g1 = &guide[(y + 1) * w..(y + 2) * w];
for x in 0..w {
ry[x] = ((g[x] - g1[x]).abs() * inv).exp();
}
}
});
(ax, ay)
}
/// One 1-D weighted-least-squares line `(wd_i + λ(a_{i−1} + a_i)) u_i − λ a_{i−1} u_{i−1} −
/// λ a_i u_{i+1} = wd_i f_i` by the Thomas algorithm; `a[i]` is the weight between `i` and
/// `i + 1`; `scratch` holds the sweep's two coefficient lines.
fn solve_line(u: &mut [f32], f: &[f32], wd: &[f32], a: &[f32], lam: f32, scratch: &mut [f32]) {
let n = u.len();
if n == 0 || scratch.len() < 2 * n {
return;
}
let (cp, dp) = scratch.split_at_mut(n);
let mut prev_a = 0.0f32;
for i in 0..n {
let ai = if i + 1 < n { a[i] } else { 0.0 };
let diag = wd[i] + lam * (prev_a + ai);
let lower = -lam * prev_a;
let upper = -lam * ai;
let (c0, d0) = if i > 0 { (cp[i - 1], dp[i - 1]) } else { (0.0, 0.0) };
let m = diag - lower * c0;
let m = if m.abs() < 1e-12 { 1e-12 } else { m };
cp[i] = upper / m;
dp[i] = (wd[i] * f[i] - lower * d0) / m;
prev_a = ai;
}
u[n - 1] = dp[n - 1];
for i in (0..n - 1).rev() {
u[i] = dp[i] - cp[i] * u[i + 1];
}
}
/// The row sweeps of the fast global smoother, in parallel over rows.
fn solve_rows(u: &mut [f32], f: &[f32], wd: &[f32], ax: &[f32], w: usize, lam: f32) {
par_rows(u, w, 1, |y, row| {
let o = y * w;
let mut scratch = vec![0.0f32; 2 * w];
solve_line(row, &f[o..o + w], &wd[o..o + w], &ax[o..o + w], lam, &mut scratch);
});
}
/// The column sweeps: the planes are transposed, swept as rows, and transposed back.
fn solve_cols(u: &mut [f32], f: &[f32], wd: &[f32], ay: &[f32], w: usize, h: usize, lam: f32) {
let t = |p: &[f32]| -> Vec<f32> {
let mut o = vec![0.0f32; w * h];
par_rows(&mut o, h, 1, |x, col| {
for (y, v) in col.iter_mut().enumerate() {
*v = p[y * w + x];
}
});
o
};
let (ft, wdt, ayt) = (t(f), t(wd), t(ay));
let mut ut = vec![0.0f32; w * h];
solve_rows(&mut ut, &ft, &wdt, &ayt, h, lam);
par_rows(u, w, 1, |y, row| {
for (x, v) in row.iter_mut().enumerate() {
*v = ut[x * h + y];
}
});
}
/// The fast global smoother's three separable passes with the `λ_t` schedule of Min et al.
/// 2014, the first pass carrying the data weights so holes are filled by interpolation, the
/// later ones the previous output: the initial guess of [`wls_solve`].
fn sweeps(d: &[f32], wd: &[f32], ex: &[f32], ey: &[f32], w: usize, h: usize, lambda: f32) -> Vec<f32> {
const T: i32 = 3;
let ones = vec![1.0f32; w * h];
let mut u = vec![0.0f32; w * h];
let mut f = d.to_vec();
for t in 1..=T {
let lam_t = 1.5 * lambda * 4f32.powi(T - t) / (4f32.powi(T) - 1.0);
let wt: &[f32] = if t == 1 { wd } else { &ones };
solve_rows(&mut u, &f, wt, ex, w, lam_t);
f.copy_from_slice(&u);
solve_cols(&mut u, &f, &ones, ey, w, h, lam_t);
f.copy_from_slice(&u);
}
u
}
/// The WLS operator `A = W + Σ λ a_pq` on one grid of the multigrid hierarchy, with the planes
/// a V-cycle needs there.
struct MgLevel {
w: usize,
h: usize,
/// Data weight per cell; on the coarse grids the sum over the block.
wd: Vec<f32>,
/// `λ·a` between `(x, y)` and `(x + 1, y)`, and between `(x, y)` and `(x, y + 1)`; on the
/// coarse grids the sum of the fine edges the block boundary cuts.
ax: Vec<f32>,
ay: Vec<f32>,
/// `1 / diag(A)`.
dinv: Vec<f32>,
x: Vec<f32>,
b: Vec<f32>,
r: Vec<f32>,
}
/// Damped-Jacobi weight (4/5 is the smoothing optimum of the 5-point stencil).
const MG_OMEGA: f32 = 0.8;
/// Smoothing sweeps before and after the coarse correction, and on the coarsest grid (where
/// they are the solve).
const MG_PRE: usize = 1;
const MG_POST: usize = 1;
const MG_COARSE: usize = 32;
/// Coarsen until the grid is this small on its longer side.
const MG_MIN: usize = 16;
impl MgLevel {
fn new(w: usize, h: usize) -> MgLevel {
let n = w * h;
MgLevel { w, h, wd: vec![0.0; n], ax: vec![0.0; n], ay: vec![0.0; n], dinv: vec![0.0; n], x: vec![0.0; n], b: vec![0.0; n], r: vec![0.0; n] }
}
/// `out_i = f(i, (A x)_i)` over the grid.
fn apply<F: Fn(usize, f32) -> f32 + Sync + Send>(&self, x: &[f32], out: &mut [f32], f: F) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(out, w, 1, |y, row| {
let o = y * w;
for (i, r) in row.iter_mut().enumerate() {
let k = o + i;
let xk = x[k];
let mut v = wd[k] * xk;
if i > 0 {
v += ax[k - 1] * (xk - x[k - 1]);
}
if i + 1 < w {
v += ax[k] * (xk - x[k + 1]);
}
if y > 0 {
v += ay[k - w] * (xk - x[k - w]);
}
if y + 1 < h {
v += ay[k] * (xk - x[k + w]);
}
*r = f(k, v);
}
});
}
fn set_dinv(&mut self) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(&mut self.dinv, w, 1, |y, row| {
for (i, r) in row.iter_mut().enumerate() {
let k = y * w + i;
let mut v = wd[k];
if i > 0 {
v += ax[k - 1];
}
if i + 1 < w {
v += ax[k];
}
if y > 0 {
v += ay[k - w];
}
if y + 1 < h {
v += ay[k];
}
*r = 1.0 / v.max(1e-12);
}
});
}
/// One damped-Jacobi sweep on `x` for `b`: `x += ω D⁻¹ (b − A x)`.
fn jacobi(&mut self, zero_start: bool) {
if zero_start {
for ((x, b), d) in self.x.iter_mut().zip(&self.b).zip(&self.dinv) {
*x = MG_OMEGA * d * b;
}
return;
}
let mut r = std::mem::take(&mut self.r);
{
let (x, b, dinv) = (&self.x, &self.b, &self.dinv);
self.apply(x, &mut r, |k, ax| x[k] + MG_OMEGA * dinv[k] * (b[k] - ax));
}
self.r = std::mem::replace(&mut self.x, r);
}
/// The 2×2 aggregation of this grid: the Galerkin operator of a piecewise-constant
/// prolongation (data weights summed over the block, the fine edges cut by a block boundary
/// summed into the coarse edge).
fn coarsen(&self) -> MgLevel {
let (w, h) = (self.w, self.h);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut c = MgLevel::new(cw, ch);
self.coarsen_wd_into(&mut c);
let (ax, ay) = (&self.ax, &self.ay);
par_rows(&mut c.ax, cw, 1, |yy, row| {
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
if xx + 1 < cw {
*o += ax[y * w + 2 * xx + 1];
}
}
}
});
par_rows(&mut c.ay, cw, 1, |yy, row| {
if yy + 1 < ch {
let y = 2 * yy + 1;
for (xx, o) in row.iter_mut().enumerate() {
*o = ay[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
c
}
/// The data weights of the coarse grid `c` from this grid's (after a reweighting; the edges
/// do not change).
fn coarsen_wd_into(&self, c: &mut MgLevel) {
let (w, h, cw) = (self.w, self.h, c.w);
let wd = &self.wd;
par_rows(&mut c.wd, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
*o += wd[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
}
/// `x += P x_c`: the coarse correction injected (piecewise constant).
fn prolong_add(x: &mut [f32], w: usize, xc: &[f32], cw: usize) {
par_rows(x, w, 1, |y, row| {
let c = &xc[(y / 2) * cw..(y / 2) * cw + cw];
for (i, o) in row.iter_mut().enumerate() {
*o += c[i / 2];
}
});
}
/// `b_c = Pᵀ r`: the residual summed over each block.
fn restrict(r: &[f32], w: usize, h: usize, bc: &mut [f32], cw: usize) {
par_rows(bc, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
let s = &r[y * w..y * w + w];
for (xx, o) in row.iter_mut().enumerate() {
*o += s[2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
/// One V-cycle from `levels[0]` (whose `b` is set) into its `x`.
fn vcycle(levels: &mut [MgLevel]) {
let Some((top, rest)) = levels.split_first_mut() else { return };
let Some(next) = rest.first_mut() else {
for k in 0..MG_COARSE {
top.jacobi(k == 0);
}
return;
};
for k in 0..MG_PRE {
top.jacobi(k == 0);
}
{
let mut r = std::mem::take(&mut top.r);
let (x, b) = (&top.x, &top.b);
top.apply(x, &mut r, |k, ax| b[k] - ax);
top.r = r;
}
restrict(&top.r, top.w, top.h, &mut next.b, next.w);
vcycle(rest);
let Some(next) = rest.first() else { return };
prolong_add(&mut top.x, top.w, &next.x, next.w);
for _ in 0..MG_POST {
top.jacobi(false);
}
}
/// Edge-aware WLS: `argmin_u Σ w_p (u_p − d_p)² + λ Σ a_pq (u_p − u_q)²`, the 2-D system
/// `(W + λL) u = W d` solved by conjugate gradients from the separable sweeps' guess,
/// preconditioned by one multigrid V-cycle (2×2 aggregation, damped Jacobi), so the low modes
/// converge where the confidence is low over a large area (a flat wall). The solver keeps its
/// hierarchy between solves, so a reweighting costs no rebuild.
struct WlsSolver {
w: usize,
h: usize,
lambda: f32,
/// Edge weights `exp(−|ΔI| / σ)` (without λ), for the separable sweeps.
ex: Vec<f32>,
ey: Vec<f32>,
levels: Vec<MgLevel>,
}
impl WlsSolver {
fn new(guide: &[f32], w: usize, h: usize, lambda: f32, sigma_c: f32) -> WlsSolver {
let (ex, ey) = edge_weights(guide, w, h, sigma_c);
let mut top = MgLevel::new(w, h);
for (a, e) in top.ax.iter_mut().zip(&ex) {
*a = lambda * e;
}
for (a, e) in top.ay.iter_mut().zip(&ey) {
*a = lambda * e;
}
let mut levels = vec![top];
while levels.last().is_some_and(|l| l.w.max(l.h) > MG_MIN) {
let Some(last) = levels.last() else { break };
let c = last.coarsen();
levels.push(c);
}
WlsSolver { w, h, lambda, ex, ey, levels }
}
/// The data weights: the confidence (plus a floor so the system is definite where nothing
/// is known), aggregated down the hierarchy.
fn set_weights(&mut self, conf: &[f32]) {
const EPS_DATA: f32 = 1e-4;
if let Some(top) = self.levels.first_mut() {
for (w, c) in top.wd.iter_mut().zip(conf) {
*w = c.max(0.0) + EPS_DATA;
}
}
for l in 0..self.levels.len() {
if l > 0 {
let (fine, coarse) = self.levels.split_at_mut(l);
if let (Some(f), Some(c)) = (fine.last(), coarse.first_mut()) {
f.coarsen_wd_into(c);
}
}
self.levels[l].set_dinv();
}
}
/// Solve for the data `d`: the solution and its final relative residual.
fn solve(&mut self, d: &[f32], max_iters: usize) -> (Vec<f32>, f32) {
let (w, h, lambda) = (self.w, self.h, self.lambda);
let n = w * h;
let Some(top) = self.levels.first() else { return (vec![0.0; n], 0.0) };
let mut u = sweeps(d, &top.wd, &self.ex, &self.ey, w, h, lambda);
if w == 1 || h == 1 || n == 0 {
return (u, 0.0);
}
let dot = |a: &[f32], b: &[f32]| -> f64 { a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum() };
let b: Vec<f32> = top.wd.iter().zip(d).map(|(w, d)| w * d).collect();
let bnorm = dot(&b, &b).sqrt().max(1e-30);
let mut r = vec![0.0f32; n];
top.apply(&u, &mut r, |k, au| b[k] - au);
let precond = |levels: &mut [MgLevel], r: &[f32], z: &mut [f32]| {
if let Some(top) = levels.first_mut() {
top.b.copy_from_slice(r);
}
vcycle(levels);
if let Some(top) = levels.first() {
z.copy_from_slice(&top.x);
}
};
let mut z = vec![0.0f32; n];
precond(&mut self.levels, &r, &mut z);
let mut p = z.clone();
let mut ap = vec![0.0f32; n];
let mut rz = dot(&r, &z);
let mut rel = (dot(&r, &r).sqrt() / bnorm) as f32;
for _ in 0..max_iters {
if rel < 1e-5 {
break;
}
let Some(top) = self.levels.first() else { break };
top.apply(&p, &mut ap, |_, v| v);
let pap = dot(&p, &ap);
if pap <= 0.0 {
break;
}
let alpha = (rz / pap) as f32;
for (u, p) in u.iter_mut().zip(&p) {
*u += alpha * p;
}
for (r, ap) in r.iter_mut().zip(&ap) {
*r -= alpha * ap;
}
rel = (dot(&r, &r).sqrt() / bnorm) as f32;
if rel < 1e-5 {
break;
}
precond(&mut self.levels, &r, &mut z);
let rz_new = dot(&r, &z);
let beta = (rz_new / rz) as f32;
rz = rz_new;
for (p, z) in p.iter_mut().zip(&z) {
*p = z + beta * *p;
}
}
(u, rel)
}
}
/// Conjugate-gradient iteration cap of the regularisation.
const CG_ITERS: usize = 200;
/// Depth from focus over the `n` layers' luminance planes `luma` (`w × h`, aligned), guided by
/// the all-in-focus luminance `guide` of the same size. Without layers, or when `guide` or a
/// plane is not `w × h` long, the map is empty: depth 0 and confidence 0 everywhere.
pub fn depth_from_focus(luma: &[&[f32]], guide: &[f32], w: usize, h: usize, p: &DepthParams) -> DepthMap {
let n = luma.len();
let empty = n == 0 || w == 0 || h == 0 || guide.len() != w * h || luma.iter().any(|l| l.len() != w * h);
if empty {
return DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
}
let k = 1usize << p.scale.min(8);
let (g, dw, dh) = block_mean(guide, w, h, k);
let gf = GuidedFilter::new(g.clone(), dw, dh, p.agg_radius, p.agg_eps);
let mut tracker = PeakTracker::new(dw * dh);
for l in luma {
let slice = block_mean(&focus_measure(l, w, h, p.r_in, p.r_out), w, h, k).0;
if p.agg_radius > 0 {
// The guided filter can undershoot; the profile statistics assume ≥ 0.
let agg: Vec<f32> = gf.filter(&slice).into_iter().map(|v| v.max(0.0)).collect();
tracker.push(&agg);
} else {
tracker.push(&slice);
}
}
let (depth_w, mut conf) = tracker.finish(p.gate);
let depth_w = median3(&depth_w, dw, dh);
normalize_conf(&mut conf);
let depth_w = if p.lambda > 0.0 {
let mut solver = WlsSolver::new(&g, dw, dh, p.lambda, p.sigma_c);
solver.set_weights(&conf);
let (u, _) = solver.solve(&depth_w, CG_ITERS);
if p.robust > 0.0 {
// One reweighted step with a Huber loss on the data residual.
let wd2: Vec<f32> = conf.iter().zip(&depth_w).zip(&u).map(|((c, d), u)| c * (p.robust / (d - u).abs().max(1e-6)).min(1.0)).collect();
solver.set_weights(&wd2);
solver.solve(&depth_w, CG_ITERS).0
} else {
u
}
} else {
depth_w
};
let max_d = (n - 1) as f32;
let up = GuidedFilter::new(g, dw, dh, 2, 1e-2);
let depth: Vec<f32> = if k == 1 {
up.filter(&depth_w)
} else {
let (a, b) = up.coeffs(&depth_w);
let a = upsample_bilinear(&a, dw, dh, w, h, k);
let b = upsample_bilinear(&b, dw, dh, w, h, k);
(0..w * h).map(|i| a[i] * guide[i] + b[i]).collect()
};
let depth = depth.into_iter().map(|v| v.clamp(0.0, max_d)).collect();
let conf = if k == 1 { conf } else { upsample_bilinear(&conf, dw, dh, w, h, k) };
DepthMap { depth, conf, w, h }
}
#[cfg(test)]
mod tests {
use super::*;
fn lcg(seed: &mut u64) -> f32 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((*seed >> 33) % 10000) as f32 / 10000.0
}
#[test]
fn box_filter_matches_the_naive_window() {
let (w, h, r) = (23, 17, 3);
let mut s = 5u64;
let src: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let m = BoxFilter::new(w, h, r).mean(&src);
for y in 0..h {
for x in 0..w {
let (mut acc, mut cnt) = (0.0, 0.0);
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
acc += src[yy * w + xx];
cnt += 1.0;
}
}
assert!((m[y * w + x] - acc / cnt).abs() < 1e-4, "({x},{y})");
}
}
}
#[test]
fn guided_filter_with_a_flat_guide_is_a_double_box_mean() {
let (w, h) = (40, 30);
let mut s = 9u64;
let p: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let q = GuidedFilter::new(vec![0.5; w * h], w, h, 2, 1e-3).filter(&p);
let bf = BoxFilter::new(w, h, 2);
let m = bf.mean(&bf.mean(&p));
assert!(q.iter().zip(&m).all(|(a, b)| (a - b).abs() < 1e-4));
}
#[test]
fn ring_difference_is_zero_mean_and_sees_texture() {
let taps = rdf_taps(1, 3);
assert!(taps.iter().map(|t| t.2).sum::<f32>().abs() < 1e-6);
let (w, h) = (32, 32);
assert!(focus_measure(&vec![0.4; w * h], w, h, 1, 3).iter().all(|v| v.abs() < 1e-6));
let check: Vec<f32> = (0..w * h).map(|i| if ((i % w) / 2 + (i / w) / 2).is_multiple_of(2) { 0.2 } else { 0.8 }).collect();
assert!(focus_measure(&check, w, h, 1, 3).iter().cloned().fold(0.0, f32::max) > 0.1);
let (m, dw, dh) = block_mean(&check, w, h, 2);
assert_eq!((dw, dh, m.len()), (16, 16, 256));
assert!(m.iter().all(|v| (v - 0.2).abs() < 1e-6 || (v - 0.8).abs() < 1e-6));
let up = upsample_bilinear(&m, dw, dh, w, h, 2);
assert_eq!(up.len(), w * h);
}
#[test]
fn peak_search_interpolates_between_layers() {
// A profile peaking between layers 2 and 3 (log-parabola), one flat profile.
let mut t = PeakTracker::new(2);
for m in 0..6 {
let v = (-((m as f32 - 2.4).powi(2)) / 2.0).exp();
t.push(&[v, 0.1]);
}
let (d, c) = t.finish(0.0);
assert!((d[0] - 2.4).abs() < 0.05, "{d:?}");
assert!(c[0] > 0.5 && c[1] < 1e-6, "{c:?}");
}
#[test]
fn smoothing_fills_holes_and_respects_edges() {
let (w, h) = (40, 20);
// A guide with a hard edge at x = 20; data known only at two pixels, one per side.
let guide: Vec<f32> = (0..w * h).map(|i| if i % w < 20 { 0.0 } else { 1.0 }).collect();
let mut d = vec![0.0f32; w * h];
let mut wd = vec![1e-4f32; w * h];
(d[10 * w + 5], wd[10 * w + 5]) = (1.0, 1.0);
(d[10 * w + 35], wd[10 * w + 35]) = (5.0, 1.0);
let mut solver = WlsSolver::new(&guide, w, h, 3.0, 0.04);
solver.set_weights(&wd);
let (u, rel) = solver.solve(&d, CG_ITERS);
assert!(rel < 1e-4, "residual {rel}");
// Each side follows its own sample (the data floor on the unknown pixels pulls a little
// toward zero), and the edge keeps them apart.
assert!((u[3 * w + 15] - 1.0).abs() < 0.15, "left side follows its sample: {}", u[3 * w + 15]);
assert!((u[15 * w + 25] - 5.0).abs() < 0.6, "right side follows its sample: {}", u[15 * w + 25]);
assert!(u[10 * w + 19] < 1.5 && u[10 * w + 20] > 4.0, "the edge holds: {} | {}", u[10 * w + 19], u[10 * w + 20]);
// The sweeps alone leave the holes near zero; the solve fills them.
let guess = sweeps(&d, &wd, &solver.ex, &solver.ey, w, h, 3.0);
assert!(guess[3 * w + 15] < 0.5);
assert_eq!(median3(&[0.5; 9], 3, 3)[4], 0.5);
}
/// Three layers: the left third sharp in layer 0, the middle in 1, the right in 2. The depth
/// map says so, sub-layer in between, with high confidence on the texture.
#[test]
fn depth_from_focus_finds_the_sharp_layer() {
let (w, h) = (96, 48);
let texture = |x: usize, y: usize| if ((x / 2) + (y / 2)).is_multiple_of(2) { 0.2 } else { 0.8 };
let flat = 0.5;
let layers: Vec<Vec<f32>> = (0..3).map(|k| (0..w * h).map(|i| if (i % w) / 32 == k { texture(i % w, i / w) } else { flat }).collect()).collect();
let guide: Vec<f32> = (0..w * h).map(|i| texture(i % w, i / w)).collect();
let refs: Vec<&[f32]> = layers.iter().map(Vec::as_slice).collect();
let dm = depth_from_focus(&refs, &guide, w, h, &DepthParams::default());
assert_eq!((dm.w, dm.h, dm.depth.len()), (w, h, w * h));
for (x, want) in [(8, 0.0), (48, 1.0), (88, 2.0)] {
let d = dm.depth[24 * w + x];
assert!((d - want).abs() < 0.35, "x {x}: depth {d}, want {want}");
assert!(dm.conf[24 * w + x] > 0.3, "x {x}: conf {}", dm.conf[24 * w + x]);
}
assert!(dm.depth.iter().all(|v| (0.0..=2.0).contains(v)));
// Degenerate inputs are fine.
assert_eq!(depth_from_focus(&[], &guide, w, h, &DepthParams::default()).depth, vec![0.0; w * h]);
// A guide or a plane of the wrong length is no input: the empty map, never a panic.
let empty = DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
assert_eq!(depth_from_focus(&refs, &guide[1..], w, h, &DepthParams::default()), empty);
let short = &refs[0][..w * h - 1];
assert_eq!(depth_from_focus(&[refs[0], short], &guide, w, h, &DepthParams::default()), empty);
assert_eq!(depth_from_focus(&refs, &guide, w + 1, h, &DepthParams::default()).w, w + 1);
let one = depth_from_focus(&refs[..1], &guide, w, h, &DepthParams::default());
assert!(one.depth.iter().all(|v| *v == 0.0));
}
}
+18 -7
View File
@@ -160,19 +160,30 @@ pub(crate) fn polar(src: &Image, out: Rect, ctx: &Ctx, mode: PolarMode) -> Vec<f
let (w, h) = (b.width().max(1) as f32, b.height().max(1) as f32);
let (cx, cy) = (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0);
let rmax = w.min(h) / 2.0;
remap(src, out, ctx, Edge::Transparent, |x, y| match mode {
// Photoshop 27.11: the angle runs counter-clockwise from 12 o'clock, and points past the
// source's edge repeat its edge pixels (the corners outside the circle are filled, not empty).
// Photoshop evaluates the map at pixel corners and reads the source at the resulting index
// (half a pixel from `remap`'s centres): within 2 levels on a whole test chart in both directions.
remap(src, out, ctx, Edge::Repeat, |x, y| polar_map(x - 0.5, y - 0.5, mode, b, (w, h), (cx, cy), rmax))
}
fn polar_map(x: f32, y: f32, mode: PolarMode, b: Rect, (w, h): (f32, f32), (cx, cy): (f32, f32), rmax: f32) -> (f32, f32) {
let (sx, sy) = match mode {
PolarMode::RectangularToPolar => {
// Output is polar: angle from 12 o'clock clockwise → source x,
// radius → source y.
// Output is polar: angle from 12 o'clock counter-clockwise → source x, radius → source y.
let (dx, dy) = (x - cx, y - cy);
let theta = dx.atan2(-dy).rem_euclid(std::f32::consts::TAU);
// The 12 o'clock seam itself reads the source's right edge (360°, not 0°).
let theta = (-dx).atan2(-dy);
let theta = if theta <= 0.0 { theta + std::f32::consts::TAU } else { theta };
let r = (dx * dx + dy * dy).sqrt();
(b.x0 as f32 + theta / std::f32::consts::TAU * w, b.y0 as f32 + r / rmax * h)
}
PolarMode::PolarToRectangular => {
let theta = (x - b.x0 as f32) / w * std::f32::consts::TAU;
// Photoshop's angle is one column ahead of its radius row (fitted exactly).
let theta = (x + 1.0 - b.x0 as f32) / w * std::f32::consts::TAU;
let r = (y - b.y0 as f32) / h * rmax;
(cx + r * theta.sin(), cy - r * theta.cos())
(cx - r * theta.sin(), cy - r * theta.cos())
}
})
};
(sx + 0.5, sy + 0.5)
}
+12 -1
View File
@@ -18,7 +18,8 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
let bbox = region.bbox;
let bw = bbox.width() as usize;
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY };
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels {
return Rect::EMPTY;
}
let fmt = target.format();
@@ -147,6 +148,16 @@ mod tests {
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn oversized_region_mask_is_rejected_without_overflow() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
let before = s.pixel(0, 0);
let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] };
assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY);
assert_eq!(s.pixel(0, 0), before);
}
#[test]
fn degenerate_inputs_do_nothing() {
let mut s = Surface::new(PixelFormat::RGBA8);
+32 -1
View File
@@ -508,6 +508,9 @@ struct Solver<'a> {
ctl: photocraft_raster::Interrupt<'a>,
}
/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255).
const FLAT: f32 = 0.02;
/// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)`
/// over `n` samples, within the limits.
#[inline]
@@ -516,7 +519,20 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3
let vt = (stt / n - mt * mt).max(0.0);
let vs = (sss / n - ms * ms).max(0.0);
let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 };
let g = g.clamp(a.gain.0, a.gain.1);
let (mut lo, mut hi) = a.gain;
// Nearly flat patches of about the same colour (their means within the bias of each other):
// the ratio of their deviations is noise, so match the mean first, keeping the gain where the
// bias can still bring the source's mean to the target's. Gained about zero, a gain at its
// limit moved bright means further than the bias could bring back, and each channel by a
// different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones
// whose colours differ more (Content-Aware Move's Color), are fitted as before.
if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 {
let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms);
if glo.max(lo) <= ghi.min(hi) {
(lo, hi) = (glo.max(lo), ghi.min(hi));
}
}
let g = g.clamp(lo, hi);
let b = (mt - g * ms).clamp(-a.bias, a.bias);
(g, b)
}
@@ -1959,6 +1975,21 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve
mod tests {
use super::*;
/// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the
/// gain stays where the bias can still reach the target's mean.
#[test]
fn fit_matches_the_mean_before_the_contrast() {
let a = Adapt { gain: (0.9, 1.1), bias: 0.04 };
// Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5).
let n = 25.0;
let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32);
let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n);
assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b);
// Where the mean can't be reached anyway, the limits hold as before.
let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n);
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
}
/// Smooth gradient plus deterministic fine noise.
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut rng = Rng::new(7);
+58 -16
View File
@@ -26,6 +26,7 @@ pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod denoise;
pub mod dff;
pub mod direct;
mod distort;
mod distort2;
@@ -552,7 +553,8 @@ impl FilterParams {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::BoxBlur { radius }
| FilterParams::SurfaceBlur { radius, .. }
| FilterParams::Median { radius }
@@ -598,6 +600,13 @@ impl FilterParams {
self.halo() == Halo::Bounds
}
/// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
/// empty layer, while still reading no neighbourhood.
pub fn renders_from_nothing(&self) -> bool {
matches!(self, FilterParams::Fibers { .. })
}
/// Human-readable name.
pub fn label(&self) -> &'static str {
match self {
@@ -740,7 +749,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
FilterParams::FindEdges => stylize::find_edges(src, out, ctx),
FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }),
FilterParams::Solarize => stylize::solarize(src, out, ctx),
FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v),
FilterParams::Desaturate => stylize::desaturate(src, out, ctx),
FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle),
@@ -903,12 +912,13 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
pub const TILE: i32 = 256;
/// The area a filter writes for a layer whose pixels cover `content`:
/// global filters write the reference bounds; others the content grown by
/// the halo (blurs spread into transparent areas). Clipped to the
/// selection's bounds when there is one.
/// global filters and renders that ignore the layer's pixels (Fibers) write the reference
/// bounds; others the content grown by the halo (blurs spread into transparent areas).
/// Clipped to the selection's bounds when there is one.
pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option<Rect>) -> Rect {
let mut area = match params.halo_for(bounds) {
Halo::Bounds => bounds,
Halo::Radius(_) if params.renders_from_nothing() => bounds,
Halo::Radius(r) => {
if content.is_empty() {
Rect::EMPTY
@@ -937,7 +947,8 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
}
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
/// group. `None` when cancelled, or when a checked filter rejects geometry/allocation;
/// the input surface is never modified.
pub fn apply_in_with(
surface: &Surface,
params: &FilterParams,
@@ -969,13 +980,13 @@ pub fn apply_tiled(
tile: i32,
extent: Option<Rect>,
) -> Surface {
// Preserve the input if a checked Shape Blur allocation cannot be completed.
// Preserve the input if a checked filter allocation cannot be completed.
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
}
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
/// one tile's work) and progress (after each group of tiles). `None` when cancelled,
/// or when Shape Blur cannot allocate a tile.
/// or when a checked filter rejects geometry or cannot allocate a tile.
#[allow(clippy::too_many_arguments)]
pub fn apply_tiled_with(
surface: &Surface,
@@ -1000,15 +1011,27 @@ fn apply_tiled_with_impl(
tile: i32,
extent: Option<Rect>,
ctl: &photocraft_raster::Interrupt,
prepare_radial: bool,
prepare_fast_paths: bool,
) -> Option<Surface> {
if ctl.cancelled() {
return None;
}
if let FilterParams::UnsharpMask { amount, radius, threshold } = *params
&& (!sharpen::exact::valid_params(amount, radius, threshold)
|| !sharpen::exact::valid_area(area)
|| !sharpen::exact::valid_area(bounds)
|| extent.is_some_and(|e| !sharpen::exact::valid_area(e)))
{
return None;
}
let mut out = surface.clone();
if area.is_empty() {
return Some(out);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
}
if let Some(boxes) = blur::box_widths(params) {
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
}
@@ -1016,15 +1039,15 @@ fn apply_tiled_with_impl(
return result;
}
// The public tile override must not create a zero-step loop or an
// unbounded Shape Blur scratch buffer. Standard automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. }) { tile.clamp(1, 2048) } else { tile };
// unbounded scratch buffer for these checked filters. Automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile };
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
// Prepare the immutable sample transforms once for all output tiles. If the
// optional table allocation fails, use the existing sampler; only a real
// cancellation returns None. Source reads keep their upstream semantics.
let radial = match params {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_radial => {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_fast_paths => {
blur::radial::Plan::for_area(bounds, *amount, *method, *quality, (*center_x, *center_y), area, ctl)
}
_ => None,
@@ -1033,6 +1056,17 @@ fn apply_tiled_with_impl(
return None;
}
let halo = params.halo_for(bounds);
if matches!(params, FilterParams::UnsharpMask { .. }) {
let Halo::Radius(reach) = halo else { return None };
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
// Reject hostile source-window sizes before Image::read allocates them.
// Ordinary filter parameters and automatic tiles fit these bounds.
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
return None;
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
@@ -1090,7 +1124,10 @@ fn apply_tiled_with_impl(
None if ctl.cancelled() => None,
None => Some(kernel(params, src, *t, &ctx)),
},
(None, None) => Some(kernel(params, src, *t, &ctx)),
(None, None) => match *params {
FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::exact::run(src, *t, &ctx, amount, radius, threshold, ctl),
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
};
@@ -1123,9 +1160,11 @@ fn apply_tiled_with_impl(
if ctl.cancelled() {
return None;
}
// Shape Blur can reject malformed geometry or an allocation failure.
// Checked filters can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if shape.is_some() && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) {
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
{
return None;
}
for (t, data) in results {
@@ -1206,9 +1245,12 @@ fn apply_box_blur(
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
let w = t.width() as usize;
// The selection over `t` read once, not a lookup and an allocation per pixel (#4533).
let mask = sel.read_region(t);
let sc = sel.channels().max(1);
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
let k = mask.get(i * sc).copied().unwrap_or(0.0).clamp(0.0, 1.0);
if k >= 1.0 {
continue;
}
+80 -2
View File
@@ -128,12 +128,32 @@ pub(crate) fn edge_of(u: UndefinedAreas) -> Edge {
/// Shifts the bounds' content by whole pixels.
pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec<f32> {
let n = src.ch;
let edge = edge_of(undefined);
// Subtraction can leave i32 even for valid command parameters. Apply the edge
// policy before narrowing: saturating first would change the wrapped pixel.
let coordinate = |position: i32, distance: i32, start: i32, end: i32| -> Option<i32> {
if start >= end {
return None;
}
let p = i64::from(position) - i64::from(distance);
let (start, end) = (i64::from(start), i64::from(end));
let mapped = match undefined {
UndefinedAreas::Wrap => start + (p - start).rem_euclid(end - start),
UndefinedAreas::Repeat => p.clamp(start, end - 1),
UndefinedAreas::Transparent if p >= start && p < end => p,
UndefinedAreas::Transparent => return None,
};
i32::try_from(mapped).ok()
};
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
let sy = coordinate(y, dy, ctx.bounds.y0, ctx.bounds.y1);
for x in out.x0..out.x1 {
let sx = coordinate(x, dx, ctx.bounds.x0, ctx.bounds.x1);
for c in 0..n {
res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds));
res.push(match (sx, sy) {
(Some(sx), Some(sy)) => src.get(sx, sy, c),
_ => 0.0,
});
}
}
}
@@ -186,3 +206,61 @@ mod tests {
}
}
}
#[cfg(test)]
mod offset_overflow_tests {
use super::*;
#[test]
fn offset_limits_match_equivalent_small_offsets_for_every_edge_policy() {
for bounds in [Rect::new(0, 0, 3, 5), Rect::new(-7, 11, -4, 16)] {
let mut src = Image::new(bounds, 4);
// Include signed/HDR values and varying alpha; offset must only move samples.
for (i, sample) in src.data.iter_mut().enumerate() {
*sample = i as f32 * 0.125 - 2.0;
}
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Rgb, alpha: true };
for policy in [UndefinedAreas::Wrap, UndefinedAreas::Repeat, UndefinedAreas::Transparent] {
for dx in [i32::MIN, i32::MAX, -8, 0, 7] {
for dy in [i32::MIN, i32::MAX, -9, 0, 6] {
let reduce = |d: i32, size: i32| match policy {
UndefinedAreas::Wrap => d.rem_euclid(size),
_ => d.clamp(-100, 100),
};
let (rx, ry) = (reduce(dx, 3), reduce(dy, 5));
let mut expected = Vec::new();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
for c in 0..4 {
expected.push(src.get_edge(x - rx, y - ry, c, edge_of(policy), bounds));
}
}
}
assert_eq!(offset(&src, bounds, &ctx, dx, dy, policy), expected, "{bounds:?} {policy:?} {dx} {dy}");
}
}
}
}
}
#[test]
fn offset_wrap_handles_bounds_at_signed_coordinate_limits() {
for start in [i32::MIN, i32::MAX - 3] {
let bounds = Rect::new(start, start, start + 3, start + 3);
let mut src = Image::new(bounds, 1);
src.data = (0..9).map(|i| i as f32).collect();
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
assert_eq!(offset(&src, bounds, &ctx, i32::MIN, i32::MIN, UndefinedAreas::Wrap), vec![8.0, 6.0, 7.0, 2.0, 0.0, 1.0, 5.0, 3.0, 4.0]);
}
}
#[test]
fn offset_wrap_accepts_minimum_signed_distance() {
let bounds = Rect::new(0, 0, 3, 1);
let mut src = Image::new(bounds, 1);
src.data = vec![0.1, 0.5, 0.9];
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
let got = offset(&src, bounds, &ctx, i32::MIN, 0, UndefinedAreas::Wrap);
assert_eq!(got, vec![0.9, 0.1, 0.5]);
}
}
+52 -2
View File
@@ -63,6 +63,7 @@ fn composite_area(
coverage: impl Fn(i32, i32) -> f32 + Sync,
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
lock_transparency: bool,
inks: Option<[f32; 4]>,
) {
// Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched,
// so their copy-on-write storage (and GPU residency) survives.
@@ -93,6 +94,14 @@ fn composite_area(
o[3] = d[3];
}
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
&& fmt.mode == photocraft_color::ColorMode::Cmyk
&& blend_mode == BlendMode::Normal
&& k >= 1.0
{
enc[..4].copy_from_slice(&i);
}
px.copy_from_slice(&enc[..n]);
}
}
@@ -144,6 +153,7 @@ pub fn paint_gradient(
c
},
lock_transparency,
None,
);
}
@@ -179,6 +189,25 @@ pub fn bucket_fill_src(
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) -> bool {
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
}
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_inks(
s: &mut Surface,
area: Rect,
seed: (i32, i32),
tolerance: f32,
contiguous: bool,
anti_alias: bool,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
) -> bool {
if !area.contains(seed.0, seed.1) {
return false;
@@ -186,7 +215,7 @@ pub fn bucket_fill_src(
let img = crate::selection::rgba8_image(s, area);
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
drop(img);
bucket_fill_region_src(s, &region, opacity, selection, lock_transparency, src);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks);
true
}
@@ -199,10 +228,31 @@ pub fn bucket_fill_region_src(
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) {
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
}
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
pub fn bucket_fill_region_inks(
s: &mut Surface,
region: &crate::selection::Region,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
) {
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
composite_area(
s,
b,
BlendMode::Normal,
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
src,
lock_transparency,
inks,
);
}
#[cfg(test)]
+13 -2
View File
@@ -115,8 +115,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
res
}
/// Crystallize: Voronoi cells around jittered points, each filled with the
/// (premultiplied) average colour of its pixels.
/// Crystallize: Voronoi cells around jittered points, each filled with the colour at its
/// point, as Photoshop's are: cells stay solid, so a black-and-white image (a layer mask
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
@@ -177,6 +180,14 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
continue;
}
let (cx, cy) = if win.contains(x, y) { owners[i] } else { owner(x, y) };
let (sx, sy) = cell_point(cx, cy, cell, seed, origin);
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
if src.rect.contains(sx, sy) && b.contains(sx, sy) {
for (c, v) in px.iter_mut().enumerate() {
*v = src.get(sx, sy, c);
}
continue;
}
if cx < c0.0 || cy < c0.1 || cx > c1.0 || cy > c1.1 {
continue;
}
+86 -2
View File
@@ -1277,11 +1277,18 @@ pub const MAX_WARP_PIXELS: f64 = (1u64 << 30) as f64;
/// Applies a puppet warp to `src` (content inside `bounds`). `None` when the deformed mesh would
/// span more than [`MAX_WARP_PIXELS`].
pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Option<Surface> {
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
}
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
/// result when none was given.
pub fn puppet_warp_with_mask(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp, mask: Option<&Surface>) -> Option<(Surface, Option<Surface>)> {
let (solver, v, order) = deform(src, bounds, w, ITERATIONS);
// No triangles (an expansion that shrinks every pixel away): nothing to warp. Rendering the
// empty mesh would drop every pixel of the layer.
if solver.mesh.tris.is_empty() {
return Some(src.clone());
return Some((src.clone(), mask.cloned()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &v {
@@ -1291,7 +1298,57 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp)
if area.is_nan() || area > MAX_WARP_PIXELS {
return None;
}
Some(render(src, bounds, &solver.mesh, &v, &order, interp))
let out = render(src, bounds, &solver.mesh, &v, &order, interp);
Some((out, mask.map(|m| render_mask(m, &solver.mesh, &v, &order, interp))))
}
/// Moves a one-channel layer mask with a deformed mesh: the mask under the mesh is lifted,
/// deformed like the layer's pixels and laid over the rest. The area the mesh leaves takes the
/// mask's default value (as [`crate::warp::warp_mesh_gray`] leaves it); outside the mesh the
/// mask keeps its values.
pub fn render_mask(mask: &Surface, mesh: &PuppetMesh, deformed: &[[f64; 2]], order: &[usize], interp: Interp) -> Surface {
let mut out = mask.clone();
if mesh.tris.is_empty() || deformed.len() != mesh.verts.len() || mask.format().channels() != 1 {
return out;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &mesh.verts {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
return out;
}
// The mesh's source area, a pixel wider so its edge samples stay inside.
let lim = f64::from(1 << 20);
let src = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
if src.is_empty() {
return out;
}
let default = mask.default_pixel().first().copied().unwrap_or(0.0);
let fmt = mask.format();
let mut tmp = Surface::new(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true));
tmp.write_region(src, &mask.read_region(src).iter().flat_map(|g| [*g, 1.0]).collect::<Vec<f32>>());
let tris: Vec<[usize; 3]> = order.iter().filter_map(|&t| mesh.tris.get(t).copied()).collect();
// Lift: where the undeformed mesh covers the mask, the mask takes its default value.
let still: Vec<([f64; 2], [f64; 2])> = mesh.verts.iter().map(|s| (*s, *s)).collect();
let lifted = crate::warp::warp_triangles(&tmp, src, &still, &tris, Interp::Nearest);
let b = lifted.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = lifted.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| default * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
// Lay the deformed mask over what is left.
let moved: Vec<([f64; 2], [f64; 2])> = deformed.iter().zip(&mesh.verts).map(|(d, s)| (*d, *s)).collect();
let warped = crate::warp::warp_triangles(&tmp, src, &moved, &tris, interp);
let b = warped.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = warped.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
out.prune();
out
}
#[cfg(test)]
@@ -1434,6 +1491,33 @@ mod tests {
assert!(!near.content_bounds().is_empty());
}
#[test]
fn a_linked_mask_moves_with_the_mesh_and_leaves_its_default_behind() {
// #4910. A hide-all mask (default 0) that reveals the bar's top (y 10..30): moved down by
// 8, the revealed patch moves along, what it left is hidden again, and the mask away
// from the puppet keeps its values.
let s = blob(SampleType::U8);
let b = s.content_bounds();
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut mask = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.0]);
mask.fill_rect(Rect::new(20, 10, 36, 30), &[1.0]);
mask.fill_rect(Rect::new(90, 90, 94, 94), &[0.5]);
let w = PuppetWarp {
pins: vec![PuppetPin { src: [28.0, 30.0], dst: [28.0, 38.0], rotate: None, depth: 0 }],
mode: PuppetMode::Rigid,
density: PuppetDensity::Normal,
expansion: 2.0,
};
let (_, moved) = puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, Some(&mask)).expect("warps");
let m = moved.expect("a mask in, a mask out");
let at = |x, y| m.sample_channel(x, y, 0);
assert!(at(28, 20) > 0.99 && at(28, 36) > 0.99, "{sample:?}: the revealed patch moved down");
assert!(at(28, 15) < 0.01, "{sample:?}: its old top is hidden again");
assert!((at(91, 91) - 0.5).abs() <= 1.0 / 255.0, "{sample:?}: away from the mesh nothing changes");
assert!(puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, None).expect("warps").1.is_none());
}
}
#[test]
fn a_single_pin_translates_rigidly() {
let s = blob(SampleType::U8);
+33 -29
View File
@@ -117,49 +117,53 @@ pub fn sponge(c: [f32; 3], amount: f32, saturate: bool, vibrance: bool) -> [f32;
pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
if ow == 0 || oh == 0 || ch == 0 {
return Vec::new();
}
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize;
let i = (y * w + x) * ch;
px.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = px[a];
for (c, v) in px.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
// Contiguous accumulation can be vectorized for any channel layout. Each output still
// sums the same products in the same kernel order as the scalar convolution.
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) {
*d += s * kv;
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
*d += s * kv;
}
}
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
+161 -4
View File
@@ -7,9 +7,10 @@
//! `γ e^{−β‖z_m − z_n‖²} / dist(m, n)` (γ = 50, β from the mean squared neighbour difference).
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::{DEFAULT_REG, Gmm};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample};
/// Definite background.
pub const BG: u8 = 0;
@@ -33,6 +34,13 @@ fn is_fg(t: u8) -> bool {
/// Runs `iters` GrabCut iterations, updating the probable labels of `trimap` in place. Returns
/// the final (foreground, background) models, or `None` if either side has no pixels.
pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, Gmm)> {
grabcut_with(img, trimap, iters, &Interrupt::NONE).unwrap_or_default()
}
/// [`grabcut`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked each iteration
/// and inside its min cut. `trimap` is then partly updated.
pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Interrupt) -> Result<Option<(Gmm, Gmm)>, Cancelled> {
let cancel = || ctl.cancelled();
let beta = contrast_beta(img);
let fixed: Vec<u8> = trimap
.iter()
@@ -44,17 +52,22 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm,
.collect();
let mut models: Option<(Gmm, Gmm)> = None;
for _ in 0..iters.max(1) {
ctl.check()?;
let (mut fs, mut bs) = (Vec::new(), Vec::new());
for (p, t) in img.px.iter().zip(trimap.iter()) {
if is_fg(*t) { fs.push(*p) } else { bs.push(*p) }
}
let (fs, bs) = (subsample(&fs, MAX_SAMPLES), subsample(&bs, MAX_SAMPLES));
let (fg, bg) = match &models {
None => (Gmm::fit(&fs, K, DEFAULT_REG)?, Gmm::fit(&bs, K, DEFAULT_REG)?),
None => match (Gmm::fit(&fs, K, DEFAULT_REG), Gmm::fit(&bs, K, DEFAULT_REG)) {
(Some(f), Some(b)) => (f, b),
_ => return Ok(None),
},
Some((f, b)) => (f.refit(&fs).unwrap_or_else(|| f.clone()), b.refit(&bs).unwrap_or_else(|| b.clone())),
};
let (cf, cb) = data_costs(img, &fg, &bg);
let cut = grid_cut(img, &cf, &cb, &fixed, GAMMA, beta);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let mut changed = 0usize;
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
if *t == PR_BG || *t == PR_FG {
@@ -68,7 +81,7 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm,
break;
}
}
models
Ok(models)
}
/// Object Selection (rectangle mode): GrabCut initialised from `rect` (outside = background,
@@ -116,3 +129,147 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer
/// hovers.
pub const OBJECT_AT_WORK_PX: usize = 60_000;
/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`.
///
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
/// object's outline. A region that grows over most of the canvas is the background: nothing is
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// foreground, the cursor definite foreground, outside the box definite background), the part
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
/// rectangle mode does.
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
return None;
}
let (px, py) = (x.floor() as i32, y.floor() as i32);
if !canvas.contains(px, py) {
return None;
}
let step = super::scale_for(canvas, work_px.max(1024));
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
return None;
}
// Lightly smoothed, so noise and texture don't stop the growth; edges survive.
let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect();
let sx = (((px - canvas.x0) as usize) / step).min(w - 1);
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
let grown = grow_similar(&lab, w, h, sx, sy);
let count = grown.iter().filter(|g| **g).count();
// Background (or a flat image): no single object under the cursor.
if count * 10 > w * h * 6 || count < 4 {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, g) in grown.iter().enumerate() {
if *g {
let (gx, gy) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1));
}
}
let pad = ((x1 - x0).max(y1 - y0) / 6).max(2);
let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h));
let mut trimap = vec![BG; w * h];
let mut any_bg = false;
for yy in 0..h {
for xx in 0..w {
let i = yy * w + xx;
let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1;
trimap[i] = if grown[i] {
PR_FG
} else if inside {
PR_BG
} else {
any_bg = true;
BG
};
}
}
if !any_bg {
// The box is the whole canvas: the frame is background.
for yy in 0..h {
for xx in 0..w {
if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) {
trimap[yy * w + xx] = BG;
}
}
}
}
trimap[sy * w + sx] = FG;
let (fg, bg) = grabcut(&img, &mut trimap, 5)?;
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
if !low.iter().any(|v| *v) {
return None;
}
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
}
/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a
/// distance of the seed area's running mean and whose step from the neighbour it was reached
/// from is small, so it stops at edges.
fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec<bool> {
const MEAN_DIST: f32 = 32.0;
const EDGE_STEP: f32 = 10.0;
let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
let mut out = vec![false; w * h];
let Some(&seed) = lab.get(sy * w + sx) else { return out };
let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64);
let mut stack = vec![(sx, sy)];
out[sy * w + sx] = true;
while let Some((x, y)) = stack.pop() {
let c = lab[y * w + x];
let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32];
let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)];
for (nx, ny) in nbrs {
if nx >= w || ny >= h {
continue;
}
let i = ny * w + nx;
if out[i] {
continue;
}
let q = lab[i];
if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST {
out[i] = true;
sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64];
n += 1.0;
stack.push((nx, ny));
}
}
}
out
}
/// 3×3 box blur (clamped at the edges).
fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
let mut out = vec![[0.0f32; 3]; px.len()];
for y in 0..h {
for x in 0..w {
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
for yy in y.saturating_sub(1)..(y + 2).min(h) {
for xx in x.saturating_sub(1)..(x + 2).min(w) {
if let Some(p) = px.get(yy * w + xx) {
acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]];
n += 1.0;
}
}
}
if let Some(o) = out.get_mut(y * w + x) {
*o = acc.map(|v| v / n.max(1.0));
}
}
}
out
}
@@ -71,7 +71,10 @@ impl Graph {
let ai = a as usize;
let j = self.head[ai] as usize;
work += 1;
if self.r_cap[ai] > EPS && self.dist[i] == self.dist[j].saturating_add(1) {
// `checked_add`: two nodes a global relabel left unreachable (`u32::MAX`) must
// not count as admissible for each other, or float-stranded excess ping-pongs
// between them forever.
if self.r_cap[ai] > EPS && self.dist[j].checked_add(1) == Some(self.dist[i]) {
let delta = (excess[i] as f32).min(self.r_cap[ai]);
self.r_cap[ai] -= delta;
self.r_cap[ai ^ 1] += delta;
@@ -179,3 +179,28 @@ fn solver_workload_release_comparison() {
}
}
}
#[test]
fn stranded_excess_terminates_and_matches_bk() {
// Source capacities far beyond f32 precision (ulp 32 at 3e8) next to small links: rounding
// strands excess on nodes a global relabel finds reachable from neither terminal. Those
// nodes (height u32::MAX) used to count as admissible for each other and pushed the excess
// back and forth forever (Remove Background never finished on some photos).
let mut g = Graph::with_capacity(6, 7);
for (i, &(s, t)) in [(33123824.0, 0.0), (347622050.0, 0.0), (0.0, 5539047.5), (76592.22, 0.0), (3514.0022, 0.0), (0.0, 13670.268)].iter().enumerate() {
g.add_tweights(i, s, t);
}
for (i, j, c) in [(0, 1, 0.0), (0, 3, 0.0007301044), (1, 2, 2.803e-42), (1, 4, 8.160078), (2, 5, 3.1224866e-7), (3, 4, 28.512966), (4, 5, 4.099367)] {
g.add_edge(i, j, c, c);
}
let mut bk = g.clone();
let checks = std::cell::Cell::new(0);
let cancel = || {
checks.set(checks.get() + 1);
checks.get() > 1000
};
let flow = g.push_relabel(&cancel).expect("push/relabel did not terminate");
let bk_flow = bk.maxflow_bk();
assert!((flow - bk_flow).abs() <= 1e-6 * bk_flow, "{flow} vs {bk_flow}");
assert_eq!((0..6).map(|i| g.in_source(i)).collect::<Vec<_>>(), (0..6).map(|i| bk.in_source(i)).collect::<Vec<_>>());
}
+48 -9
View File
@@ -35,7 +35,7 @@ pub mod slic;
pub mod subject;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_raster::{Cancelled, Interrupt, Surface};
pub use crate::selection::Region;
use gmm::Gmm;
@@ -288,6 +288,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
if count == 0 {
return Some(out);
}
if cancel() {
return None;
}
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
for i in 0..n {
if idx[i] != u32::MAX {
@@ -330,6 +333,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
}
}
}
if cancel() {
return None;
}
if OPTIMIZED {
g.maxflow_with(cancel)?;
} else {
@@ -539,6 +545,22 @@ pub fn trim_region(r: Region) -> Option<Region> {
/// pixels wide around the upsampled boundary, using the given colour models, tile by tile.
/// The result is anti-aliased by one pixel.
pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], lw: usize, lh: usize, models: Option<(&Gmm, &Gmm)>) -> Option<Region> {
finish_region_with(sampler, window, step, low, lw, lh, models, &Interrupt::NONE).unwrap_or_default()
}
/// [`finish_region`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked before each
/// band tile and inside its min cut.
#[allow(clippy::too_many_arguments)]
pub fn finish_region_with(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
lw: usize,
lh: usize,
models: Option<(&Gmm, &Gmm)>,
ctl: &Interrupt,
) -> Result<Option<Region>, Cancelled> {
let (w, h) = (window.width() as usize, window.height() as usize);
let mut mask = vec![0u8; w * h];
if step <= 1 {
@@ -576,16 +598,28 @@ pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bo
#[cfg(target_arch = "wasm32")]
mask.chunks_mut(w).enumerate().for_each(|(y, r)| fill(y, r));
if let Some((fg, bg)) = models {
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask);
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask, ctl)?;
}
}
antialias_u8(&mut mask, w, h);
trim_region(Region { bbox: window, mask })
Ok(trim_region(Region { bbox: window, mask }))
}
/// Full-resolution graph cut restricted to the band blocks (see [`finish_region`]).
#[allow(clippy::too_many_arguments)]
fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], band: &[bool], lw: usize, lh: usize, fg: &Gmm, bg: &Gmm, mask: &mut [u8]) {
fn refine_band(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
band: &[bool],
lw: usize,
lh: usize,
fg: &Gmm,
bg: &Gmm,
mask: &mut [u8],
ctl: &Interrupt,
) -> Result<(), Cancelled> {
const T: i32 = 192;
const HALO: i32 = 12;
let w = window.width() as usize;
@@ -606,7 +640,9 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
ty += T;
}
let run = |t: &Rect| -> (Rect, Vec<bool>) {
let cancel = || ctl.cancelled();
let run = |t: &Rect| -> Result<(Rect, Vec<bool>), Cancelled> {
ctl.check()?;
let r = t.inflate(HALO).intersect(&window);
let img = sampler.rgb(r);
let (rw, rh) = (r.width() as usize, r.height() as usize);
@@ -629,7 +665,8 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
}
let beta = contrast_beta(&img);
let cut = grid_cut(&img, &cfg, &cbg, &fixed, 50.0, beta);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let (cw, ch) = (t.width() as usize, t.height() as usize);
let mut core = Vec::with_capacity(cw * ch);
for yy in 0..ch {
@@ -637,15 +674,16 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
let sx = (t.x0 - r.x0) as usize;
core.extend_from_slice(&cut[sy * rw + sx..sy * rw + sx + cw]);
}
(*t, core)
Ok((*t, core))
};
// A cancelled tile ends the collect; tiles not yet started see `ctl` and return at once.
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<bool>)> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect()
tiles.par_iter().map(run).collect::<Result<_, _>>()?
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect();
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect::<Result<_, _>>()?;
for (t, core) in results {
let cw = t.width() as usize;
for (yy, row) in core.chunks_exact(cw).enumerate() {
@@ -655,6 +693,7 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
}
}
Ok(())
}
/// Deterministic pseudo-random numbers (xorshift) for sampling and tests.
+119 -19
View File
@@ -88,6 +88,19 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3
}
pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option<Region> {
quick_select_batched::<OPTIMIZED>(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region)
}
/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also
/// returns how many window sizes, smallest first, it took to find the kept one.
pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
sampler: &dyn Sampler,
canvas: Rect,
points: &[(f32, f32)],
size: f32,
t: &Tuning,
batch: usize,
) -> Option<(Region, usize)> {
if points.is_empty() || canvas.is_empty() {
return None;
}
@@ -107,8 +120,15 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32;
let rw = r / step as f32;
// Work in a window around the stroke; grow it while the selection runs into its border.
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
loop {
// The window's sides are free like the canvas' edges, so a selection that stays clear of
// them is kept; one that reaches a side inside the canvas is recomputed in a larger window.
// Index of the smallest window kept so far in this batch: larger ones stop cutting.
let kept = std::sync::atomic::AtomicUsize::new(usize::MAX);
let attempt = |index: usize, margin: i32| -> Attempt {
let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index;
if cancel() {
return Attempt::Cancelled;
}
let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step;
let grown = bbox.inflate(margin).intersect(&canvas);
let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1);
@@ -118,13 +138,13 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect();
let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh);
if !seeds.iter().any(|v| *v) {
return None;
return Attempt::NoSeeds;
}
if cancel() {
return Attempt::Cancelled;
}
// The window's sides are free like the canvas' edges, so a selection that stays clear of
// them is the best over the whole canvas too (outside the window it could only add cost).
// One that reaches a side inside the canvas is recomputed in a larger window.
let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1];
let low = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t);
let Some(low) = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled };
let touches = |side: usize| match side {
0 => (0..lh).any(|y| low[y * lw]),
1 => (0..lw).any(|x| low[x]),
@@ -132,21 +152,87 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
_ => (0..lw).any(|x| low[(lh - 1) * lw + x]),
};
if (0..4).any(|k| inner[k] && touches(k)) {
margin = margin.saturating_mul(2);
continue;
return Attempt::Touches;
}
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
return super::trim_region(Region { bbox: window, mask });
kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed);
Attempt::Done { window, low, lw, lh }
};
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
let mut sizes = 0;
loop {
// The attempts don't depend on each other, so a few window sizes are cut at once and the
// first in growing order that stays clear of its sides is kept: the same result as trying
// them one after another, without waiting for the smaller ones to fail first. Once one is
// kept, the larger ones still cutting stop, so a stroke settled by a small window costs
// about what that window alone costs.
let batch = margins_to_try(margin, bbox, canvas, batch);
kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed);
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<Attempt> = {
use rayon::prelude::*;
batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<Attempt> = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect();
for result in results {
sizes += 1;
match result {
Attempt::NoSeeds => return None,
// Only a window larger than one already kept is cancelled, and that one comes
// first in this order, so a cancelled attempt is never reached.
Attempt::Touches | Attempt::Cancelled => continue,
Attempt::Done { window, low, lw, lh } => {
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes));
}
}
}
let &last = batch.last()?;
margin = last.saturating_mul(2);
}
}
/// One window size's outcome in [`quick_select_using`].
enum Attempt {
/// The stroke misses the window.
NoSeeds,
/// The selection reaches a side of the window inside the canvas.
Touches,
/// A smaller window of the same batch was kept first.
Cancelled,
Done {
window: Rect,
low: Vec<bool>,
lw: usize,
lh: usize,
},
}
/// Window sizes cut together: most strokes settle within this many doublings.
const SPECULATE: usize = 4;
/// The next `count` margins from `margin`, doubling, stopping at the first one whose window
/// already covers the whole canvas (a larger one would be the same window).
pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec<i32> {
let mut out = Vec::with_capacity(count.max(1));
let mut m = margin;
for _ in 0..count.max(1) {
out.push(m);
if bbox.inflate(m).intersect(&canvas) == canvas {
break;
}
m = m.saturating_mul(2);
}
out
}
/// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are
/// selected; returns the selection.
pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
select_working_using::<true>(img, seeds, radius, t)
select_working_using::<true>(img, seeds, radius, t, &|| false).unwrap_or_default()
}
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option<Vec<bool>> {
let (w, h) = (img.w, img.h);
let d = crate::selection::edt(seeds, w, h);
let grow = (t.grow + t.grow_frac * radius).max(0.0);
@@ -154,7 +240,10 @@ fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], r
let fixed: Vec<u8> = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect();
let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k);
let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k));
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024))
if cancel() {
return None;
}
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel)
}
/// Band half-width (pixels) re-cut at each finer level.
@@ -164,11 +253,19 @@ const BAND: f32 = 3.0;
/// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two
/// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds
/// stay foreground.
fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec<bool> {
fn cut_levels<const OPTIMIZED: bool>(
img: &RgbImage,
fixed: &[u8],
cost_fg: &[f32],
weight: &dyn Fn(f32) -> f32,
scale: f32,
direct_px: usize,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w, img.h);
let zeros = vec![0.0f32; w * h];
if w * h <= direct_px || w < 8 || h < 8 {
return super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2));
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel);
}
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let coarse_img = img.downsample(2);
@@ -183,7 +280,10 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
}
}
}
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px);
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?;
if cancel() {
return None;
}
let proj: Vec<bool> = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect();
// Band: within BAND of the projected boundary.
let edge: Vec<bool> = (0..w * h)
@@ -194,7 +294,7 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
})
.collect();
if !edge.iter().any(|e| *e) {
return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect();
return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect());
}
let near = crate::selection::edt(&edge, w, h);
let banded: Vec<u8> = (0..w * h)
@@ -210,7 +310,7 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
}
})
.collect();
super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2))
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel)
}
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
+34
View File
@@ -132,9 +132,31 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
}
}
let mask = clean_mask(&mask, w, h, 0.002, 0.01);
if is_backdrop(img, &mask, top) {
return None;
}
mask.iter().any(|v| *v).then_some(mask)
}
/// A neutral region that also covers most of the bottom rows is a studio or product backdrop,
/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one.
fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool {
let (w, h) = (img.w, img.h);
let bottom = (h - rows.min(h)) * w..h * w;
let Some(bottom_rows) = mask.get(bottom) else { return false };
let covered = bottom_rows.iter().filter(|m| **m).count();
if covered * 10 < bottom_rows.len() * 6 {
return false;
}
let (mut sat, mut n) = (0.0f32, 0usize);
for (p, _) in img.px.iter().zip(mask).filter(|(_, m)| **m) {
let (mx, mn) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2]));
sat += if mx > 1e-4 { (mx - mn) / mx } else { 0.0 };
n += 1;
}
n > 0 && sat / (n as f32) < 0.1
}
/// Selects the sky over `canvas`. `None` if no sky was found.
pub fn select_sky(sampler: &dyn Sampler, canvas: Rect, p: SkyParams) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 {
@@ -269,6 +291,18 @@ mod tests {
assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1);
}
/// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky
/// reaching the bottom (a photo taken upward) still is.
#[test]
fn a_white_backdrop_is_not_sky_but_a_blue_sky_down_to_the_bottom_is() {
let disc = |x: usize, y: usize| ((x as f32 - 75.0).powi(2) + (y as f32 - 50.0).powi(2)).sqrt() < 30.0;
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.12, 0.12] } else { [0.95, 0.95, 0.94] });
assert!(select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).is_none());
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.3, 0.1] } else { [0.35, 0.6, 0.95] });
let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).expect("blue sky");
assert!(r.at(5, 95) > 0.9 && r.at(75, 50) < 0.1);
}
#[test]
fn colour_prior_ranks_sky_over_foliage() {
assert!(colour_prior([0.4, 0.6, 0.95]) > 0.8);
+13 -5
View File
@@ -23,6 +23,7 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
@@ -35,13 +36,20 @@ const SIGMA_SPA: f32 = 0.25;
/// Selects the most salient object on the canvas.
pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
select_subject_with(sampler, canvas, &Interrupt::NONE).unwrap_or_default()
}
/// [`select_subject`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked between
/// stages and inside every min cut (the GrabCut iterations and the full-resolution re-cut).
pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) -> Result<Option<Region>, Cancelled> {
if canvas.width() < 4 || canvas.height() < 4 {
return None;
return Ok(None);
}
let step = super::scale_for(canvas, WORK_PX);
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
let sal = saliency(&img);
ctl.check()?;
let t = otsu(&sal).clamp(0.15, 0.85);
let mut trimap = vec![PR_BG; w * h];
let mut any_fg = false;
@@ -60,15 +68,15 @@ pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
}
}
if !any_fg {
return None;
return Ok(None);
}
let (fg, bg) = grabcut::grabcut(&img, &mut trimap, 6)?;
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
if !low.iter().any(|v| *v) {
return None;
return Ok(None);
}
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
/// Per-pixel saliency in `0..=1` (see the module docs).
+141
View File
@@ -344,6 +344,45 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() {
}
}
#[test]
fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() {
// Soft gradients and faint blobs let a selection spread, so most strokes need more than one
// window size; cutting several at once must keep exactly what one-after-another keeps.
let (w, h) = (1200usize, 900usize);
let img = RgbImage::from_fn(w, h, |x, y| {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06;
[0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob]
});
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let t = quick::Tuning::default();
let strokes: [(&[(f32, f32)], f32); 4] = [
(&[(300.0, 300.0)], 30.0),
(&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0),
(&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0),
(&[(900.0, 200.0)], 120.0),
];
let mut most = 0;
for (pts, size) in strokes {
let one = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 1);
let many = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 4);
assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}");
most = most.max(one.map_or(0, |(_, n)| n));
}
assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing");
}
#[test]
fn quick_select_window_sizes_stop_at_the_canvas() {
let canvas = Rect::new(0, 0, 1000, 800);
let bbox = Rect::new(400, 300, 460, 360);
assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]);
// 768 already covers the canvas: no larger (identical) window is cut.
assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]);
assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]);
}
#[test]
fn quick_select_grows_without_leaking() {
let img = two_regions(140, 100);
@@ -526,6 +565,87 @@ fn select_subject_finds_centred_object() {
assert!(v > 0.9, "IoU {v}");
}
/// Counts full-resolution reads; the working copy goes through `rgb_scaled` uncounted, so each
/// read is one band tile of the full-resolution re-cut.
struct TileReads<'a>(ImageSampler<'a>, std::sync::atomic::AtomicUsize);
impl Sampler for TileReads<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.0.rgba(r)
}
fn rgb_scaled(&self, r: Rect, step: usize) -> RgbImage {
self.0.rgb_scaled(r, step)
}
}
#[test]
fn finish_region_cancel_stops_inside_the_band_cut() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
// One 180-pixel window is one band tile, and nothing asks after the band re-cut, so a cancel
// seen after the tile's own check can only have been seen inside its min cut.
let (n, step) = (180, 3);
let (img, truth) = disc_scene(n, n, 90.0, 90.0, 55.0, 4);
let s = ImageSampler { img: &img, origin: (0, 0) };
let ln = n / step;
let low: Vec<bool> = (0..ln * ln).map(|i| truth[((i / ln) * step + 1) * n + (i % ln) * step + 1]).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&truth).partition(|(_, t)| **t);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 5, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let window = Rect::new(0, 0, n as i32, n as i32);
let polls = AtomicUsize::new(0);
let finish = |cancel_from: usize| {
polls.store(0, SeqCst);
let cancel = || polls.fetch_add(1, SeqCst) + 1 >= cancel_from;
finish_region_with(&s, window, step, &low, ln, ln, Some((&fg, &bg)), &Interrupt::cancel_only(&cancel))
};
let reg = finish(usize::MAX).unwrap().unwrap();
assert!(polls.load(SeqCst) >= 2, "the cut polls too ({} polls)", polls.load(SeqCst));
assert!(iou(&reg, &truth, n, n) > 0.95);
assert_eq!(finish_region(&s, window, step, &low, ln, ln, Some((&fg, &bg))), Some(reg));
// Cancelled at the cut's first poll (the second in all): no region.
assert_eq!(finish(2), Err(Cancelled));
assert_eq!(polls.load(SeqCst), 2);
assert_eq!(finish(1), Err(Cancelled), "cancelled before the tile");
}
#[test]
fn select_subject_cancelled_in_the_band_recut_reads_no_more_tiles() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
let (w, h) = (3000, 2000);
let (img, truth) = disc_scene(w, h, 1500.0, 1000.0, 700.0, 6);
let canvas = Rect::new(0, 0, w as i32, h as i32);
let sampler = || TileReads(ImageSampler { img: &img, origin: (0, 0) }, AtomicUsize::new(0));
// Not cancelled: the same region as `select_subject`, and the band cuts themselves ask
// (more polls once tiles are read than the one check before each tile).
let s = sampler();
let band_polls = AtomicUsize::new(0);
let count = || {
if s.1.load(SeqCst) > 0 {
band_polls.fetch_add(1, SeqCst);
}
false
};
let reg = subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&count)).unwrap().unwrap();
let tiles = s.1.load(SeqCst);
assert!(tiles > 4, "{tiles} band tiles");
assert!(band_polls.load(SeqCst) > tiles, "{} polls over {tiles} tiles", band_polls.load(SeqCst));
assert!(iou(&reg, &truth, w, h) > 0.9);
assert_eq!(subject::select_subject(&sampler(), canvas), Some(reg));
// Cancelled once the first tile is read: the run stops there instead of re-cutting every
// tile. One thread, so no other tile is already in flight.
let s = sampler();
let cancel = || s.1.load(SeqCst) > 0;
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let r = pool.install(|| subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&cancel)));
assert_eq!(r, Err(Cancelled));
assert_eq!(s.1.load(SeqCst), 1, "tiles read after the cancel");
}
#[test]
fn saliency_highlights_the_object() {
let (img, truth) = disc_scene(120, 90, 60.0, 45.0, 22.0, 8);
@@ -620,3 +740,24 @@ fn dump_quick_scenes() {
save("thin_line", &line);
save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0);
}
#[test]
fn object_at_finds_the_disc_under_the_cursor() {
let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 320, 240);
let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap();
let v = iou(&reg, &truth, 320, 240);
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 320, 240);
assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
+282 -35
View File
@@ -166,7 +166,8 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
/// around the seed. `px` covers `area`.
pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() };
let mut m = vec![0.0f32; n];
if !area.contains(seed.0, seed.1) {
return m;
}
@@ -245,7 +246,9 @@ impl Region {
if !self.bbox.contains(x, y) {
return 0.0;
}
self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0
let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize);
let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0);
f32::from(value) / 255.0
}
}
@@ -257,6 +260,9 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
return None;
}
let (w, h) = (area.width() as usize, area.height() as usize);
if w.checked_mul(h).is_none_or(|n| n > img.len()) {
return None;
}
let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize];
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
@@ -676,18 +682,57 @@ fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) {
/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60,
/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so
/// neutrals are never selected and muted colours only partly.
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma
/// (max − min), so neutrals are never selected and muted colours only partly. Darker colours
/// weigh more, by `2 − max`: measured against Photoshop 2025's presets on 126 colours each for
/// Reds, Yellows and Blues (hue ±40°, saturation 5..100 %, brightness 33..100 %), this matches
/// its selection to within one level, where chroma alone fell up to 61 levels short (#5243).
pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec<f32> {
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0)));
let rgb = [r, g, b].map(|v| v.clamp(0.0, 1.0));
let (h, chroma) = hue_chroma(rgb);
let max = rgb[0].max(rgb[1]).max(rgb[2]);
let dh = (h - center).rem_euclid(360.0);
let dh = dh.min(360.0 - dh);
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0)
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma * (2.0 - max)).clamp(0.0, 1.0)
})
.collect()
}
/// Color Range › Select: Skin Tones. Skin of every complexion falls in a compact region of the
/// YCbCr chroma plane (Chai & Ngan, "Face segmentation using skin-color map", 1999: Cb about
/// 77..127, Cr about 133..173, Rec. 601 on 0..255) that runs from pale (higher Cb, lower Cr) to
/// saturated (lower Cb, higher Cr). Pixels inside an ellipse along that run are fully selected;
/// outside, selection falls off linearly over `fuzziness` (0..200, as the dialog's Fuzziness: 100
/// is one ellipse radius). Pale neutrals such as cream or beige sit just off its pale end, so they
/// stay out. Near-black and near-white pixels, whose chroma is noise, are faded out.
pub fn skin_tones(px: &[[f32; 4]], fuzziness: f32) -> Vec<f32> {
const CB: f32 = 104.0;
const CR: f32 = 158.0;
/// Radii along the run and across it, and the run's angle from the Cb axis.
const ALONG: f32 = 22.0;
const ACROSS: f32 = 14.0;
const ANGLE: f32 = -40.0;
let (sin, cos) = ANGLE.to_radians().sin_cos();
let ramp = (fuzziness / 100.0).max(1e-3);
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let [r, g, b] = [r, g, b].map(|v| v.clamp(0.0, 1.0) * 255.0);
let y = 0.299 * r + 0.587 * g + 0.114 * b;
let cb = 128.0 - 0.168_736 * r - 0.331_264 * g + 0.5 * b - CB;
let cr = 128.0 + 0.5 * r - 0.418_688 * g - 0.081_312 * b - CR;
let d = ((cos * cb + sin * cr) / ALONG).hypot((cos * cr - sin * cb) / ACROSS);
let chroma = (1.0 - (d - 1.0) / ramp).clamp(0.0, 1.0);
let dark = ((y - 25.0) / 30.0).clamp(0.0, 1.0);
let bright = ((250.0 - y) / 20.0).clamp(0.0, 1.0);
chroma * dark * bright
})
.collect()
}
@@ -766,47 +811,84 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
}
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased).
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of
/// rows are filled in parallel, and each sub-row tests only the edges that
/// cross it (an active edge list over the edges sorted by their top).
pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
if points.len() < 3 || w == 0 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
let sample_y = |py: usize, s: usize| area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
// A sub-row at y crosses the edge a→b iff top <= y < bottom; horizontal and NaN edges
// never cross one.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
const BAND: usize = 64;
par_rows(&mut m, w, BAND, |bi, rows| {
let py0 = bi * BAND;
let (first, last) = (sample_y(py0, 0), sample_y(py0 + rows.len() / w - 1, sub - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let mut xs: Vec<f32> = Vec::new();
for (r, row) in rows.chunks_exact_mut(w).enumerate() {
for s in 0..sub {
// Sub-row ys never decrease, so an edge whose bottom is passed stays out.
let y = sample_y(py0 + r, s);
while let Some(e) = pending.next_if(|e| e.top <= y) {
active.push(e);
}
active.retain(|e| e.bottom > y);
if active.is_empty() {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
xs.clear();
xs.extend(active.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for (cx, v) in row.iter_mut().enumerate().take(c1).skip(c0) {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
*v += cov.max(0.0) / sub as f32;
}
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
for v in rows {
*v = v.min(1.0);
}
});
m
}
@@ -865,6 +947,28 @@ mod tests {
}
}
#[test]
fn skin_tones_selects_skin_and_not_cream_or_white() {
let px = |rgb: [u8; 3]| [rgb[0], rgb[1], rgb[2]].map(|v| f32::from(v) / 255.0);
let rgba = |rgb: [u8; 3]| {
let [r, g, b] = px(rgb);
[r, g, b, 1.0]
};
// Skin in light and shadow, then a cream shirt, a near-white wall, blue denim and black.
let skin =
[[183, 119, 81], [177, 132, 93], [180, 121, 84], [120, 76, 52], [173, 107, 75], [255, 219, 172], [194, 140, 104], [141, 85, 36], [92, 51, 23]]
.map(rgba);
let m = skin_tones(&skin, 40.0);
assert!(m.iter().all(|&v| v > 0.95), "skin {m:?}");
let other = [[231, 225, 202], [252, 253, 247], [40, 60, 110], [5, 5, 5], [128, 128, 128], [220, 40, 40], [200, 160, 40]].map(rgba);
let m = skin_tones(&other, 40.0);
assert!(m.iter().all(|&v| v < 0.05), "not skin {m:?}");
// Fuzziness widens the region; transparent pixels are never selected.
let pale = [rgba([230, 215, 200])];
assert!(skin_tones(&pale, 200.0)[0] > skin_tones(&pale, 0.0)[0]);
assert_eq!(skin_tones(&[[0.7, 0.45, 0.3, 0.0]], 40.0), vec![0.0]);
}
#[test]
fn color_range_falloff() {
let px = vec![[1.0, 0.0, 0.0, 1.0], [0.9, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.0]];
@@ -1081,6 +1185,17 @@ mod tests {
assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5);
}
#[test]
fn hue_families_weigh_dark_colours_like_photoshop() {
// Photoshop 2025's Blues on an 8-bit sRGB document (selection levels 0..255, #5243): the
// muted navy #3d3f54, a dark blue (0, 0, 84) and a mid blue (61, 61, 153).
let c = |r: u8, g: u8, b: u8| [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0];
let blues = hue_range(&[c(0x3d, 0x3f, 0x54), c(0, 0, 84), c(61, 61, 153)], 240.0);
for (got, want) in blues.iter().zip([35.0, 140.0, 129.0]) {
assert!((got * 255.0 - want).abs() <= 1.0, "{} vs Photoshop {want}", got * 255.0);
}
}
#[test]
fn tonal_ranges() {
let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0];
@@ -1161,6 +1276,107 @@ mod tests {
assert!(polygon(&[(0.0, 0.0), (1.0, 1.0)], a, true).iter().all(|v| *v == 0.0));
}
/// The previous polygon fill (every edge tested on every sub-row), kept as the oracle.
fn old_polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
#[test]
fn polygon_matches_the_every_edge_fill_bit_for_bit() {
let mut seed = 0x2545_f491_u32;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as f32 / u32::MAX as f32
};
// Taller than one 64-row band, offset, and a 1-pixel-wide area.
let areas = [Rect::new(0, 0, 90, 150), Rect::new(-13, 7, 60, 211), Rect::new(5, -40, 6, 30)];
let mut cases: Vec<Vec<(f32, f32)>> = vec![
// Self-intersecting star, horizontal edges, repeated points, collinear runs.
(0..11)
.map(|i| {
let t = i as f32 * 4.0 * std::f32::consts::PI / 11.0;
(40.0 + 35.0 * t.cos(), 80.0 + 70.0 * t.sin())
})
.collect(),
vec![(10.0, 10.0), (50.0, 10.0), (50.0, 10.0), (50.0, 60.0), (30.0, 60.0), (30.0, 30.0), (10.0, 30.0)],
vec![(0.0, 0.0), (20.0, 0.0), (40.0, 0.0), (40.0, 0.0), (0.0, 0.0)],
// Vertices on the sub-row centres and the pixel edges.
vec![(3.125, 2.375), (70.5, 2.375), (70.875, 140.625), (3.0, 140.0), (35.0, 64.0), (35.0, 64.125)],
// Entirely outside, and straddling every side.
vec![(-500.0, -500.0), (-400.0, -500.0), (-450.0, -300.0)],
vec![(-80.0, -60.0), (300.0, 20.0), (60.0, 400.0)],
// Non-finite coordinates and a negative zero.
vec![(10.0, 10.0), (f32::NAN, 40.0), (60.0, 70.0), (20.0, f32::INFINITY), (-0.0, -0.0), (0.0, 0.0)],
];
for n in [3, 7, 40, 400] {
for spread in [1.0, 2.0] {
cases.push((0..n).map(|_| (rnd() * 100.0 * spread - 50.0 * (spread - 1.0), rnd() * 220.0 * spread - 60.0 * spread)).collect());
// Lasso-like: a random walk snapped to quarter pixels.
let (mut x, mut y) = (45.0f32, 75.0f32);
cases.push(
(0..n)
.map(|_| {
x = ((x + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
y = ((y + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
(x, y)
})
.collect(),
);
}
}
for pts in &cases {
for &area in &areas {
for aa in [true, false] {
let (new, old) = (polygon(pts, area, aa), old_polygon(pts, area, aa));
assert_eq!(new.len(), old.len());
let diff = new.iter().zip(&old).position(|(a, b)| a.to_bits() != b.to_bits());
assert!(diff.is_none(), "{pts:?} {area:?} aa={aa}: first difference at {diff:?}");
}
}
}
}
#[test]
fn combine_modes() {
let a = Rect::new(0, 0, 4, 1);
@@ -1262,6 +1478,37 @@ mod tests {
.collect()
}
#[test]
fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() {
let area = Rect::new(0, 0, 4, 4);
let short = vec![[255u8, 0, 0, 255]; 4];
let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect();
assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::<f32>::new());
assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None);
let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX);
assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty());
assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None);
let valid = Rect::new(0, 0, 2, 2);
assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]);
assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some());
}
#[test]
fn region_coverage_outside_a_short_mask_is_zero() {
let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] };
assert_eq!(region.at(-2, -3), 1.0);
assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6);
assert_eq!(region.at(0, -3), 0.0);
assert_eq!(region.at(-2, -2), 0.0);
assert_eq!(region.at(-3, -3), 0.0);
let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] };
assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0);
assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0);
}
#[test]
fn wand_region_matches_reference_magic_wand() {
let (w, h) = (37, 29);
+4 -1
View File
@@ -569,11 +569,14 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let s = f64::from(sigma);
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
// Rows: only the bounding box's rows are non-zero.
// The run table is scratch: one buffer per worker thread, reused across rows instead of
// one allocation per row (thousands of 8-byte-per-sample Vecs on a 24 MP mask).
thread_local! { static RUNS: std::cell::RefCell<Vec<usize>> = const { std::cell::RefCell::new(Vec::new()) } }
let mut tmp = vec![0.0f32; b.h() * gw];
par_rows(&mut tmp, gw, 1, |y, row| {
let o = (b.y0 + y) * w + win.x0;
if let Some(s) = m.get(o..o + gw) {
kh.line(s, row, &mut Vec::new());
RUNS.with(|runs| kh.line(s, row, &mut runs.borrow_mut()));
}
});
// Columns, a strip of columns at a time.
+12 -6
View File
@@ -6,6 +6,17 @@ use crate::Ctx;
use crate::blur::{conv_sep, gaussian_kernel};
use crate::image::Image;
pub(crate) mod exact;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod fft;
#[cfg(test)]
mod tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod fft_bench;
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
@@ -24,12 +35,7 @@ fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
exact::run(src, out, ctx, amount, radius, threshold, &photocraft_raster::Interrupt::NONE).unwrap_or_default()
}
pub(crate) fn smart(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, reduce_noise: f32) -> Vec<f32> {
+134
View File
@@ -0,0 +1,134 @@
//! Exact separable convolution for Unsharp Mask: stream one source row and reuse
//! the blurred output for the final arithmetic. Each sample retains its tap order.
use crate::image::premultiply;
use crate::{Ctx, Image};
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
const MAX_SAMPLES: usize = 128 * 1024 * 1024;
// The filter dialog caps radius at 1000. Preserve Details resampling can ask
// for more; permit that while the unchanged kernel's i32 square is safe.
const MAX_RADIUS: f32 = 15_446.0;
pub(crate) fn valid_params(amount: f32, radius: f32, threshold: f32) -> bool {
amount.is_finite() && threshold.is_finite() && radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius)
}
pub(crate) fn halo(radius: f32) -> i32 {
if radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius) { (radius * 3.0).ceil() as i32 + 1 } else { 0 }
}
pub(crate) fn valid_area(r: Rect) -> bool {
r.x0 <= r.x1
&& r.y0 <= r.y1
&& r.width() <= 300_000
&& r.height() <= 300_000
&& [r.x0, r.x1, r.y0, r.y1].iter().all(|v| (-1_000_000..=1_000_000).contains(v))
}
fn zeros(len: usize) -> Option<Vec<f32>> {
if len > MAX_SAMPLES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, 0.0);
Some(data)
}
/// Checked contiguous row copy, with the same zero extension as Image::get.
fn read_row(src: &Image, x0: i32, x1: i32, y: i32, dst: &mut [f32]) -> Option<()> {
dst.fill(0.0);
if y < src.rect.y0 || y >= src.rect.y1 {
return Some(());
}
let (lo, hi) = (x0.max(src.rect.x0), x1.min(src.rect.x1));
if lo >= hi {
return Some(());
}
let n = src.ch;
let count = usize::try_from(i64::from(hi) - i64::from(lo)).ok()?.checked_mul(n)?;
let offset = usize::try_from(i64::from(lo) - i64::from(x0)).ok()?.checked_mul(n)?;
let row = usize::try_from(i64::from(y) - i64::from(src.rect.y0)).ok()?;
let col = usize::try_from(i64::from(lo) - i64::from(src.rect.x0)).ok()?;
let start = row.checked_mul(src.rect.width() as usize)?.checked_add(col)?.checked_mul(n)?;
dst.get_mut(offset..offset.checked_add(count)?)?.copy_from_slice(src.data.get(start..start.checked_add(count)?)?);
Some(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32, ctl: &Interrupt) -> Option<Vec<f32>> {
if ctl.cancelled() || !valid_params(amount, radius, threshold) || !valid_area(out) || !valid_area(src.rect) {
return None;
}
let n = src.ch;
if n == 0 || n > crate::fxutil::MAXC {
return None;
}
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(n)? {
return None;
}
if out.is_empty() {
return Some(Vec::new());
}
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let (ow, oh) = (out.width() as usize, out.height() as usize);
let row_len = ow.checked_mul(n)?;
let th = oh.checked_add(2 * r)?;
let ww = ow.checked_add(2 * r)?;
let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?);
let mut row = zeros(ww.checked_mul(n)?)?;
let mut tmp = zeros(row_len.checked_mul(th)?)?;
let mut result = zeros(row_len.checked_mul(oh)?)?;
for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?;
premultiply(&mut row, n, ctx.alpha);
// Tap-major traversal exposes contiguous independent samples to LLVM. No
// reduction, tap merging, symmetry or fused multiply-add changes the sums.
for (i, weight) in kernel.iter().enumerate() {
let start = i.checked_mul(n)?;
let samples = row.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
}
let (amount, threshold) = (amount / 100.0, threshold / 255.0);
let cc = n - usize::from(ctx.alpha);
for (y, dst) in result.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
for (i, weight) in kernel.iter().enumerate() {
let start = y.checked_add(i)?.checked_mul(row_len)?;
let samples = tmp.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
let originals = row.get_mut(..row_len)?;
read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?;
for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) {
if ctx.alpha && *original.last()? <= 0.0 {
blurred.copy_from_slice(original);
continue;
}
let a = if ctx.alpha { *blurred.last()? } else { 1.0 };
for (b, o) in blurred.iter_mut().zip(original).take(cc) {
// Keep the original unpremultiply cut-off and division, followed by
// the threshold epsilon and inclusive comparison, at every depth.
let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b };
let d = o - b_value;
*b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o };
}
if ctx.alpha {
*blurred.last_mut()? = *original.last()?;
}
}
}
Some(result)
}
+325
View File
@@ -0,0 +1,325 @@
//! Wide exact-kernel Gaussian convolution using FFTs. Summation order changes:
//! positive thresholds and unstable samples retain the sequential f32 fallback.
use crate::{FilterParams, Image};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use rayon::prelude::*;
use rustfft::{Fft, FftPlanner, num_complex::Complex};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
const MAX_BYTES: usize = 1 << 30;
const MAX_SIDE: usize = 32_768;
const STRIPE: usize = 32;
type Number = Complex<f64>;
fn buffer<T: Clone>(len: usize, value: T) -> Option<Vec<T>> {
if len.checked_mul(std::mem::size_of::<T>())? > MAX_BYTES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, value);
Some(data)
}
struct Plan {
size: usize,
forward: Arc<dyn Fft<f64>>,
inverse: Arc<dyn Fft<f64>>,
spectrum: Vec<Number>,
scratch: usize,
}
impl Plan {
fn new(needed: usize, kernel: &[f32], planner: &mut FftPlanner<f64>) -> Option<Self> {
let size = needed.checked_next_power_of_two()?;
if size > MAX_SIDE || kernel.len() > size {
return None;
}
let forward = planner.plan_fft_forward(size);
let inverse = planner.plan_fft_inverse(size);
let scratch = forward.get_inplace_scratch_len().max(inverse.get_inplace_scratch_len());
let mut spectrum = buffer(size, Number::default())?;
for (dst, &weight) in spectrum.iter_mut().zip(kernel) {
dst.re = f64::from(weight) / size as f64;
}
forward.process_with_scratch(&mut spectrum, &mut buffer(scratch, Number::default())?);
Some(Self { size, forward, inverse, spectrum, scratch })
}
fn work(&self) -> Option<(Vec<Number>, Vec<Number>)> {
Some((buffer(self.size, Number::default())?, buffer(self.scratch, Number::default())?))
}
fn convolve(&self, data: &mut [Number], scratch: &mut [Number]) -> Option<()> {
if data.len() != self.size || scratch.len() < self.scratch {
return None;
}
self.forward.process_with_scratch(data, scratch);
for (d, k) in data.iter_mut().zip(&self.spectrum) {
*d *= k;
}
self.inverse.process_with_scratch(data, scratch);
Some(())
}
}
// Bound the complete temporary working set, including all concurrently live
// column gathers, originals, FFT scratch and result stripes. Document tiles are
// excluded, just as in the existing tile scheduler's scratch budget.
fn group_size(resident: usize, worker: usize, count: usize, workers: usize) -> Option<usize> {
let group = MAX_BYTES.checked_sub(resident)?.checked_div(worker.max(1))?.min(workers).min(count);
(group > 0).then_some(group)
}
/// `None` declines the optimization, `Some(None)` propagates cancellation.
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply(
surface: &Surface,
params: &FilterParams,
area: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
ctl: &Interrupt,
) -> Option<Option<Surface>> {
let FilterParams::UnsharpMask { amount, radius, threshold } = *params else { return None };
// At a positive threshold, even a tiny sum-order difference can switch the
// entire sharpen contribution. Zero/negative thresholds always keep it.
if !(64.0..=1000.0).contains(&radius)
|| threshold > 0.0
|| amount == 0.0
|| !super::exact::valid_params(amount, radius, threshold)
|| !super::exact::valid_area(area)
|| area.is_empty()
|| u64::from(area.width()) * u64::from(area.height()) < 65_536
|| !(1..=crate::fxutil::MAXC).contains(&surface.channels())
|| selection.is_some_and(|s| s.channels() == 0)
|| extent.is_some_and(|e| !super::exact::valid_area(e) || area.intersect(&e) != area)
{
return None;
}
if ctl.cancelled() {
return Some(None);
}
match run(surface, area, amount, radius, selection, extent, ctl) {
Some(out) => Some(Some(out)),
None if ctl.cancelled() => Some(None),
None => None,
}
}
#[allow(clippy::too_many_arguments)]
fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Option<&Surface>, extent: Option<Rect>, ctl: &Interrupt) -> Option<Surface> {
let (w, h, n) = (area.width() as usize, area.height() as usize, surface.channels());
let alpha = surface.format().alpha;
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let reach = i32::try_from(r).ok()?;
let (x0, x1) = (area.x0.checked_sub(reach)?, area.x1.checked_add(reach)?);
let (y0, y1) = (area.y0.checked_sub(reach)?, area.y1.checked_add(reach)?);
let (read_x0, read_x1, read_y0, read_y1) = match extent {
Some(e) => (x0.max(e.x0), x1.min(e.x1), y0.max(e.y0), y1.min(e.y1)),
None => (x0, x1, y0, y1),
};
let rows = usize::try_from(i64::from(read_y1) - i64::from(read_y0)).ok()?;
let read_width = usize::try_from(i64::from(read_x1) - i64::from(read_x0)).ok()?;
let row_len = w.checked_mul(n)?;
let samples = row_len.checked_mul(rows)?;
let resident = samples.checked_mul(4)?;
let mut planner = FftPlanner::<f64>::new();
let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?;
let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?;
// A conservative allowance includes FFT plans/twiddles in addition to spectra.
let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?;
let resident = resident.checked_add(plans)?;
let workers = rayon::current_num_threads().max(1);
let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?;
let vertical_worker = STRIPE
.checked_mul(rows)?
.checked_mul(n)?
.checked_mul(4)?
.checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)?
.checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?;
let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?;
let mut tmp = buffer(samples, 0.0f32)?;
let finished = AtomicUsize::new(0);
// Read each needed real row once. Outside a repeated extent, horizontal
// results are repeated lazily by the vertical gather, not transformed again.
for band in tmp.chunks_mut(row_len.checked_mul(group)?) {
let first = finished.load(Ordering::Relaxed);
let ok: Option<Vec<()>> = band
.par_chunks_exact_mut(row_len)
.enumerate()
.map(|(offset, dst)| {
if ctl.cancelled() {
return None;
}
let y = read_y0.checked_add(i32::try_from(first.checked_add(offset)?).ok()?)?;
let mut row = buffer(read_width.checked_mul(n)?, 0.0f32)?;
// Reservation above covers resize, so the reusable raster read cannot allocate.
surface.read_region_into(Rect::new(read_x0, y, read_x1, y.checked_add(1)?), &mut row);
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in row.chunks_exact_mut(n) {
if px.iter().any(|v| !v.is_finite() || v.abs() > 1e6) || (alpha && !px.last().is_some_and(|a| (0.0..=1.0).contains(a))) {
return None;
}
let a = if alpha { *px.last()? } else { 1.0 };
for (c, v) in px.iter_mut().enumerate() {
if alpha && c + 1 < n {
*v *= a;
}
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(v.abs()));
}
}
let (mut data, mut scratch) = horizontal.work()?;
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(w + 2 * r).enumerate() {
let sx = x0.checked_add(i32::try_from(i).ok()?)?.clamp(read_x0, read_x1.checked_sub(1)?);
let start = usize::try_from(sx - read_x0).ok()?.checked_mul(n)?;
let px = row.get(start..start.checked_add(n)?)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
horizontal.convolve(&mut data, &mut scratch)?;
for (px, d) in dst.chunks_exact_mut(n).zip(data.get(2 * r..2 * r + w)?) {
*px.get_mut(channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = px.get_mut(channel + 1) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
finished.fetch_add(1, Ordering::Relaxed);
Some(())
})
.collect();
ok?;
ctl.progress(0.5 * finished.load(Ordering::Relaxed) as f32 / rows as f32);
if ctl.cancelled() {
return None;
}
}
let mut out = surface.clone();
let count = w.div_ceil(STRIPE);
for first in (0..count).step_by(group) {
let end = first.checked_add(group)?.min(count);
let results: Option<Vec<_>> = (first..end)
.into_par_iter()
.map(|index| {
if ctl.cancelled() {
return None;
}
let x = index.checked_mul(STRIPE)?;
let width = STRIPE.min(w.checked_sub(x)?);
let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?;
for (y, row) in tmp.chunks_exact(row_len).enumerate() {
if y % 64 == 0 && ctl.cancelled() {
return None;
}
for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() {
let start = (c * rows + y) * n;
columns.get_mut(start..start + n)?.copy_from_slice(px);
}
}
let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1);
let len = width.checked_mul(h)?.checked_mul(n)?;
let mut result = buffer(len, 0.0f32)?;
let mut original = buffer(len, 0.0f32)?;
surface.read_region_into(rect, &mut original);
let (mut data, mut scratch) = vertical.work()?;
for (c, samples) in columns.chunks_exact(rows * n).enumerate() {
if ctl.cancelled() {
return None;
}
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in samples.chunks_exact(n) {
for (s, v) in scales.iter_mut().zip(px) {
*s = s.max(f64::from(v.abs()));
}
}
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() {
let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?);
let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?;
let px = samples.get(start..start + n)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
vertical.convolve(&mut data, &mut scratch)?;
for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() {
let start = (y * width + c) * n;
*result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
}
for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) {
if alpha && *o.last()? <= 0.0 {
px.copy_from_slice(o);
continue;
}
let a = if alpha { *px.last()? } else { 1.0 };
// Near the original unpremultiply cutoff, cancellation/FFT noise
// or f32 sum order can expose colour. Decline the whole fast path.
let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON;
if alpha && a <= 1e-7 + uncertainty {
return None;
}
for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) {
let b = if alpha { *v / a } else { *v };
*v = o + amount / 100.0 * (o - b);
if !v.is_finite() {
return None;
}
}
if alpha {
*px.last_mut()? = *o.last()?;
}
}
if let Some(sel) = selection {
crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha);
}
Some((rect, result))
})
.collect();
for (rect, data) in results? {
if ctl.cancelled() {
return None;
}
out.try_write_region(rect, &data).ok()?;
}
ctl.progress(0.5 + 0.48 * end as f32 / count as f32);
}
if ctl.cancelled() {
return None;
}
out.prune();
if ctl.cancelled() {
return None;
}
ctl.progress(1.0);
Some(out)
}
#[cfg(test)]
mod tests;
+146
View File
@@ -0,0 +1,146 @@
use super::*;
use crate::sharpen::tests::{reference, source};
use crate::{Ctx, image::Image};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
fn compare(old: &Surface, new: &Surface, area: Rect) {
let tolerance = match old.format().sample {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 2.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5,
};
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let limit = if old.format().sample == SampleType::F32 { tolerance * (1.0 + a.abs()) } else { tolerance };
assert!((a - b).abs() <= limit, "{a} != {b}, tolerance {limit}");
}
}
#[test]
fn fft_preserves_gaussian_quality_at_every_depth_and_model() {
let area = Rect::new(-3, 2, 10, 13);
for mode in [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
] {
for depth in SampleType::ALL {
for alpha in [false, true] {
let s = source(area, PixelFormat::new(mode, depth, alpha));
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [3.25, 64.0] {
let src = Image::read_clamped(&s, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, radius, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
let actual = run(&s, area, 500.0, radius, None, Some(area), &Interrupt::NONE).unwrap();
compare(&expected, &actual, area);
if alpha {
for (a, b) in s.read_region(area).chunks_exact(s.channels()).zip(actual.read_region(area).chunks_exact(s.channels())) {
assert_eq!(a.last(), b.last());
if a.last() == Some(&0.0) {
assert_eq!(a, b);
}
}
}
}
}
}
}
}
#[test]
fn fft_keeps_partial_regions_selections_and_edge_policies() {
let extent = Rect::new(-7, -9, 35, 39);
let area = Rect::new(-3, 2, 19, 23);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
for depth in SampleType::ALL {
let s = source(extent, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
for edge in [None, Some(extent)] {
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, extent, Some(&sel), edge, 256);
let actual = run(&s, area, 150.0, 64.0, Some(&sel), edge, &Interrupt::NONE).unwrap();
compare(&expected, &actual, extent);
}
}
}
#[test]
fn fft_falls_back_for_thresholds_and_unstable_samples() {
let area = Rect::new(0, 0, 256, 256);
let mut s = source(area, PixelFormat::RGBA32F);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 8.0 };
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
s.fill_rect(Rect::new(0, 0, 1, 1), &[f32::NAN, 0.5, 0.5, 1.0]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, 1e-8]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, -0.1]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
}
#[test]
fn fft_cancellation_discards_partial_work_and_bounds_scratch() {
let area = Rect::new(0, 0, 256, 256);
let s = source(area, PixelFormat::RGBA8);
let original = s.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 300.0, threshold: 0.0 };
let flag = std::sync::atomic::AtomicBool::new(false);
let cancel = || flag.load(Ordering::Relaxed);
let progress = |_| flag.store(true, Ordering::Relaxed);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &progress)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
flag.store(false, Ordering::Relaxed);
let after_writes = |value| {
if value > 0.55 {
flag.store(true, Ordering::Relaxed);
}
};
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &after_writes)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
assert_eq!(group_size(MAX_BYTES, 1, 1, 1), None);
assert_eq!(group_size(0, usize::MAX, 1, 1), None);
assert!(Plan::new(usize::MAX, &[1.0], &mut FftPlanner::new()).is_none());
assert!(buffer::<f32>(usize::MAX, 0.0).is_none());
}
#[test]
fn positive_threshold_dispatch_retains_exact_pixel_decisions() {
let area = Rect::new(-3, 2, 253, 258);
for depth in SampleType::ALL {
let s = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 500.0, radius: 64.0, threshold: 8.0 };
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, area, None, Some(area), 256);
let actual = crate::apply_in(&s, &p, area, area, None, area);
assert_eq!(expected.to_interleaved(area), actual.to_interleaved(area));
}
}
#[test]
fn fft_handles_maximum_radius_and_hdr_alpha_channel_pairs() {
let area = Rect::new(0, 0, 1, 1);
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
s.fill_rect(area, &[0.5]);
let ctx = Ctx { bounds: area, mode: ColorMode::Grayscale, alpha: false };
let src = Image::read_clamped(&s, area.inflate(3001), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 1000.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 1000.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
let area = Rect::new(-2, 3, 11, 14);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(area, &[1e6, -1e5, 1e-4, 0.02]);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let src = Image::read_clamped(&s, area.inflate(193), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 64.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 64.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
}
+112
View File
@@ -0,0 +1,112 @@
//! Opt-in benchmark of the adaptive production path against the frozen original.
use super::tests::{apply_reference, source};
use crate::FilterParams;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::time::Instant;
#[test]
#[ignore = "adaptive large-radius full-operation quality and performance; run alone"]
fn unsharp_fft_performance() {
let number = |name: &str, default| std::env::var(name).ok().and_then(|v| v.parse::<i32>().ok()).unwrap_or(default);
let (w, h) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1920), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1080));
let reps = number("PHOTOCRAFT_UNSHARP_REPS", 3);
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=5).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let surface = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "100:150:0,300:150:0,500:150:8".into());
for case in cases.split(',') {
let v: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (v[0], v[1], v[2]);
let params = FilterParams::UnsharpMask { amount, radius, threshold };
let baseline = || apply_reference(&surface, &params, area, area, None, Some(area), crate::auto_tile(&params, area));
let candidate = || crate::apply_in(&surface, &params, area, area, None, area);
for _ in 0..2 {
std::hint::black_box(baseline());
std::hint::black_box(candidate());
}
let mut before = Vec::new();
let mut after = Vec::new();
let mut max_error = 0.0f32;
let mut changed = 0usize;
let mut sum_error = 0.0f64;
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let t = Instant::now();
let output = f();
(output, t.elapsed().as_secs_f64() * 1000.0)
};
let ((old, a), (new, b)) = if rep % 2 == 0 {
(timed(&baseline), timed(&candidate))
} else {
let b = timed(&candidate);
(timed(&baseline), b)
};
before.push(a);
after.push(b);
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let e = (a - b).abs();
let limit = match depth {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 3.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5 * (1.0 + a.abs()),
};
assert!(e <= limit, "quality regression: {a} != {b} at radius {radius}, threshold {threshold}");
max_error = max_error.max(e);
if rep == 0 {
sum_error += f64::from(e);
changed += usize::from(a.to_bits() != b.to_bits());
}
}
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"experiment":"adaptive FFT same Gaussian, changed summation","width":w,"height":h,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"before_ms":before,"after_ms":after,"before_median_ms":median(&before),"after_median_ms":median(&after),"speedup":median(&before)/median(&after),"max_error":max_error,"mean_error":sum_error/(w as f64*h as f64*4.0),"changed_samples":changed})
);
}
}
#[test]
#[ignore = "4K wide-radius cancellation latency; run alone in release"]
fn unsharp_fft_cancel_latency() {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
let area = Rect::new(0, 0, 3840, 2160);
let surface = source(area, PixelFormat::RGBA8);
let input = surface.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 1000.0, threshold: 0.0 };
let mut latencies = Vec::new();
for _ in 0..5 {
let flag = AtomicBool::new(false);
let cancel_at = AtomicU64::new(0);
let started = Instant::now();
std::thread::scope(|scope| {
let timer = scope.spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
cancel_at.store(started.elapsed().as_micros() as u64, Ordering::Relaxed);
flag.store(true, Ordering::Release);
});
let cancel = || flag.load(Ordering::Acquire);
let result = crate::apply_in_with(&surface, &p, area, area, None, area, &photocraft_raster::Interrupt::cancel_only(&cancel));
let elapsed = started.elapsed().as_micros() as u64;
timer.join().unwrap();
assert!(result.is_none());
latencies.push(elapsed.saturating_sub(cancel_at.load(Ordering::Relaxed)) as f64 / 1000.0);
});
assert_eq!(surface.to_interleaved(area), input);
}
println!(
"{}",
serde_json::json!({"width":3840,"height":2160,"radius":1000,"cancel_after_ms":10,"cancel_return_latencies_ms":latencies,"scope":"native full-resolution algo dispatch, excludes GUI scheduling/proxy creation"})
);
}
+113
View File
@@ -0,0 +1,113 @@
//! Frozen exact Unsharp Mask from a28d7866, independent of production helpers.
use crate::Ctx;
use crate::image::{Image, premultiply, unpremultiply};
use photocraft_geom::Rect;
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
if sigma < 0.05 {
return vec![1.0];
}
let r = (sigma * 3.0).ceil() as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect()
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
let n = src.ch;
let (rx, ry) = ((kx.len() / 2) as i32, (ky.len() / 2) as i32);
let (ow, oh) = (out.width() as usize, out.height() as usize);
let th = oh + 2 * ry as usize;
// Premultiplied copy of the needed source window.
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
let ww = win.width() as usize;
let mut p = vec![0.0f32; ww * win.height() as usize * n];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
for c in 0..n {
p[o + c] = src.get(x, y, c);
}
}
}
premultiply(&mut p, n, alpha);
// Horizontal pass: (ow × th).
let mut tmp = vec![0.0f32; ow * th * n];
for ty in 0..th {
let row = &p[ty * ww * n..(ty + 1) * ww * n];
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
match n {
1 => conv_row::<1>(row, dst, kx),
2 => conv_row::<2>(row, dst, kx),
3 => conv_row::<3>(row, dst, kx),
4 => conv_row::<4>(row, dst, kx),
5 => conv_row::<5>(row, dst, kx),
_ => {
for ox in 0..ow {
let d = &mut dst[ox * n..(ox + 1) * n];
for (i, kv) in kx.iter().enumerate() {
let s = &row[(ox + i) * n..(ox + i + 1) * n];
for c in 0..n {
d[c] += s[c] * kv;
}
}
}
}
}
}
// Vertical pass.
let mut res = vec![0.0f32; ow * oh * n];
for oy in 0..oh {
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
for (i, kv) in ky.iter().enumerate() {
let s = &tmp[(oy + i) * ow * n..(oy + i + 1) * ow * n];
for (dv, sv) in d.iter_mut().zip(s) {
*dv += sv * kv;
}
}
}
unpremultiply(&mut res, n, alpha);
res
}
// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize
// channels. The tap order is unchanged; unusual multichannel images keep the generic path.
fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
let pixels = src.as_chunks::<N>().0;
for (x, out) in dst.as_chunks_mut::<N>().0.iter_mut().enumerate() {
let mut acc = [0.0f32; N];
for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) {
for c in 0..N {
acc[c] += pixel[c] * weight;
}
}
*out = acc;
}
}
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
let mut res = src.crop(out);
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
for (px, bl) in res.chunks_exact_mut(n).zip(blurred.chunks_exact(n)) {
if ctx.alpha && px[n - 1] <= 0.0 {
continue;
}
for c in 0..cc {
px[c] = f(px[c], bl[c]);
}
}
res
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
}
+434
View File
@@ -0,0 +1,434 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
use std::time::Instant;
#[path = "reference.rs"]
pub(super) mod reference;
pub(super) fn source(rect: Rect, fmt: PixelFormat) -> Surface {
let mut s = Surface::new(fmt);
for y in rect.y0..rect.y1 {
let data: Vec<f32> = (rect.x0..rect.x1)
.flat_map(|x| {
(0..fmt.channels()).map(move |c| {
if fmt.alpha && c + 1 == fmt.channels() {
if (x + y).rem_euclid(17) == 0 { 0.0 } else { ((x ^ y).rem_euclid(255) + 1) as f32 / 255.0 }
} else {
let value = ((x * (7 + c as i32) + y * 13).rem_euclid(257)) as f32 / 256.0;
if fmt.sample == SampleType::F32 { 3.0 * value - 0.5 } else { value }
}
})
})
.collect();
s.write_region(Rect::new(rect.x0, y, rect.x1, y + 1), &data);
}
s
}
// Preserve the surrounding tiled workload as well as the old convolution: halo reads,
// automatic tiles, grouping, selection mixing, writes and pruning are timed in both paths.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_reference(
s: &Surface,
p: &crate::FilterParams,
area: Rect,
bounds: Rect,
sel: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
) -> Surface {
let crate::FilterParams::UnsharpMask { amount, radius, threshold } = *p else { panic!("unsharp params") };
let crate::Halo::Radius(halo) = p.halo_for(bounds) else { panic!("radius halo") };
let fmt = s.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
let tiles: Vec<_> = (area.y0..area.y1)
.step_by(tile as usize)
.flat_map(|y| (area.x0..area.x1).step_by(tile as usize).map(move |x| Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1))))
.collect();
let group = (crate::RESULT_BUDGET / ((tile as usize).pow(2) * fmt.channels() * 4)).max(workers()).max(1);
let finished = std::sync::atomic::AtomicUsize::new(0);
let ctl = Interrupt::NONE;
let run = |t: &Rect| {
let src = match extent {
Some(e) => Image::read_clamped(s, t.inflate(halo), e),
None => Image::read(s, t.inflate(halo)),
};
let mut data = reference::unsharp(&src, *t, &ctx, amount, radius, threshold);
if let Some(sel) = sel {
crate::mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
let done = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
ctl.progress(0.98 * done as f32 / tiles.len().max(1) as f32);
(*t, data)
};
let mut out = s.clone();
for chunk in tiles.chunks(group) {
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<_> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<_> = chunk.iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
out
}
fn workers() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
#[test]
fn frozen_reference_matches_current_kernel() {
let out = Rect::new(-3, 2, 16, 15);
let src = Image { rect: out.inflate(13), ch: 4, data: (0..(out.width() + 26) * (out.height() + 26) * 4).map(|i| (i % 127) as f32 / 126.0).collect() };
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
for radius in [0.1, 0.6, 1.5, 4.0] {
assert_eq!(unsharp(&src, out, &ctx, 150.0, radius, 8.0), reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
fn same_bits(a: &[f32], b: &[f32]) {
assert_eq!(a.len(), b.len());
for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || a.is_nan() && b.is_nan(), "sample {i}: {a:?} ({:x}) != {b:?} ({:x})", a.to_bits(), b.to_bits());
}
}
#[test]
fn exact_kernel_across_models_depths_edges_and_thresholds() {
let modes = [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
];
for mode in modes {
for depth in SampleType::ALL {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, depth, alpha);
let area = Rect::new(-3, 2, 8, 7);
let mut surface = source(area, fmt);
if depth == SampleType::F32 {
// HDR, negative, signed zero, subnormal and nonfinite samples; colour is
// never clamped by this filter. Transparent hidden colour must survive.
let px: Vec<f32> = (0..fmt.channels()).map(|c| if alpha && c + 1 == fmt.channels() { 0.0 } else { -4.0 }).collect();
surface.fill_rect(Rect::new(0, 3, 1, 4), &px);
for (x, value) in [3.5, -0.0, f32::MIN_POSITIVE, f32::INFINITY, f32::NAN].into_iter().enumerate() {
let mut px = vec![value; fmt.channels()];
if alpha {
*px.last_mut().unwrap() = if x == 2 { 1e-8 } else { 1.0 };
}
surface.fill_rect(Rect::new(x as i32, 5, x as i32 + 1, 6), &px);
}
}
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [0.0, 0.049, 0.1, 0.6, 1.5, 4.0, 17.25] {
let src = Image::read_clamped(&surface, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
for (amount, threshold) in [(0.0, 0.0), (50.0, 0.0), (500.0, 8.0), (300.0, 255.0)] {
same_bits(&unsharp(&src, area, &ctx, amount, radius, threshold), &reference::unsharp(&src, area, &ctx, amount, radius, threshold));
}
// An incomplete halo has zero extension, even with alpha.
let src = Image::read(&surface, area);
let out = Rect::new(-5, 1, 10, 8);
same_bits(&unsharp(&src, out, &ctx, 150.0, radius, 8.0), &reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
}
}
}
#[test]
fn exact_threshold_boundary_and_degenerate_images() {
for (w, h, n) in [(1, 1, 1), (1, 7, 2), (9, 1, 3), (7, 5, 4), (2, 3, 5), (5, 2, 7)] {
let out = Rect::new(-2, 3, w - 2, h + 3);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: n % 2 == 0 };
let src = Image { rect: out, ch: n, data: (0..w * h * n as i32).map(|i| if i % 7 == 0 { -0.0 } else { (i % 17) as f32 / 16.0 }).collect() };
for radius in [0.0, 0.1, 0.75, 8.5, 64.0] {
let k = reference::gaussian_kernel(radius);
let blurred = reference::conv_sep(&src, out, &k, &k, ctx.alpha);
for (&o, &b) in src.data.iter().zip(&blurred).take(8) {
let boundary = ((o - b).abs() + 1e-6) * 255.0;
for threshold in [boundary, f32::from_bits(boundary.to_bits().saturating_sub(1)), f32::from_bits(boundary.to_bits() + 1)] {
same_bits(&unsharp(&src, out, &ctx, 500.0, radius, threshold), &reference::unsharp(&src, out, &ctx, 500.0, radius, threshold));
}
}
}
}
let empty = Image { rect: Rect::EMPTY, ch: 4, data: Vec::new() };
let ctx = Ctx { bounds: Rect::EMPTY, mode: ColorMode::Rgb, alpha: true };
assert!(unsharp(&empty, Rect::EMPTY, &ctx, 100.0, 1.0, 0.0).is_empty());
}
#[test]
fn exact_tiled_selected_outputs_and_cancellation() {
let bounds = Rect::new(-13, -7, 281, 271);
let area = Rect::new(-3, 5, 273, 265);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
sel.fill_rect(Rect::new(250, 250, 270, 265), &[1.0]);
for depth in SampleType::ALL {
let surface = source(bounds, PixelFormat::new(ColorMode::Rgb, depth, true));
for radius in [0.6, 3.0, 10.0] {
let p = crate::FilterParams::UnsharpMask { amount: 150.0, radius, threshold: 8.0 };
for extent in [None, Some(bounds)] {
let expected = apply_reference(&surface, &p, area, bounds, Some(&sel), extent, 256);
for tile in [63, 256, 512] {
let actual = crate::apply_tiled(&surface, &p, area, bounds, Some(&sel), tile, extent);
assert_eq!(expected.to_interleaved(bounds), actual.to_interleaved(bounds));
}
}
let cancelled = std::sync::atomic::AtomicBool::new(false);
let cancel = || cancelled.load(std::sync::atomic::Ordering::Relaxed);
let progress = |_| cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
let ctl = Interrupt::new(&cancel, &progress);
let input = surface.to_interleaved(bounds);
assert!(crate::apply_in_with(&surface, &p, area, bounds, Some(&sel), bounds, &ctl).is_none());
assert_eq!(surface.to_interleaved(bounds), input);
}
}
}
#[test]
fn preserve_details_resampling_keeps_the_same_sharpening() {
use crate::resample::{Resample, resize_surface};
let src = Rect::new(-3, 2, 19, 17);
let (sx, sy) = (2.0, 1.5);
let dst = crate::resample::scaled_rect(src, sx, sy);
for depth in SampleType::ALL {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
let s = source(src, PixelFormat::new(mode, depth, true));
let resized = resize_surface(&s, sx, sy, Resample::Lanczos);
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
let area = crate::output_area(&p, resized.content_bounds(), dst, None);
let expected = apply_reference(&resized, &p, area, dst, None, None, crate::auto_tile(&p, dst));
let actual = resize_surface(&s, sx, sy, Resample::PreserveDetails);
assert_eq!(actual.to_interleaved(area), expected.to_interleaved(area));
}
}
}
#[test]
fn flat_fields_and_sharp_edges_keep_exact_halos() {
let out = Rect::new(0, 0, 17, 9);
for alpha in [false, true] {
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha };
for radius in [0.1, 0.6, 3.25, 64.0] {
let rect = out.inflate((radius * 3.0_f32).ceil() as i32 + 1);
for edge in [false, true] {
let n = 3 + usize::from(alpha);
let data = (rect.y0..rect.y1)
.flat_map(|_| {
(rect.x0..rect.x1).flat_map(|x| {
(0..n).map(move |c| {
if alpha && c + 1 == n {
0.7
} else if edge && x >= 8 {
0.75
} else {
0.25
}
})
})
})
.collect();
let src = Image { rect, ch: n, data };
same_bits(&unsharp(&src, out, &ctx, 300.0, radius, 0.0), &reference::unsharp(&src, out, &ctx, 300.0, radius, 0.0));
}
}
}
}
#[test]
fn malformed_inputs_and_allocations_stop_gracefully() {
let rect = Rect::new(0, 0, 2, 2);
let src = Image { rect, ch: 1, data: vec![0.5; 4] };
let ctx = Ctx { bounds: rect, mode: ColorMode::Grayscale, alpha: false };
for (amount, radius, threshold) in [(f32::NAN, 1.0, 0.0), (100.0, f32::INFINITY, 0.0), (100.0, -1.0, 0.0), (100.0, f32::MAX, 0.0), (100.0, 1.0, f32::NAN)] {
assert!(exact::run(&src, rect, &ctx, amount, radius, threshold, &Interrupt::NONE).is_none());
let s = source(rect, PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none());
assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s);
}
for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] {
assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] {
assert!(exact::run(&src, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
let s = source(Rect::new(0, 0, 2, 2), PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 1.0, threshold: 0.0 };
for tile in [i32::MIN, 0, i32::MAX] {
let r = s.content_bounds();
assert_eq!(crate::apply_tiled(&s, &p, r, r, None, tile, Some(r)), crate::apply_in(&s, &p, r, r, None, r));
}
// Allocation/geometry guard prevents a huge halo read before allocating it.
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 15_446.0, threshold: 0.0 };
assert!(crate::apply_in_with(&s, &p, s.content_bounds(), s.content_bounds(), None, s.content_bounds(), &Interrupt::NONE).is_none());
}
#[test]
fn maximum_supported_radius_matches_the_frozen_kernel() {
let out = Rect::new(0, 0, 1, 1);
let src = Image { rect: out, ch: 1, data: vec![0.75] };
let ctx = Ctx { bounds: out, mode: ColorMode::Grayscale, alpha: false };
same_bits(&unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0), &reference::unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0));
}
#[test]
fn cancellation_stops_inside_a_tile_before_publishing() {
let rect = Rect::new(0, 0, 16, 16);
let s = source(rect, PixelFormat::RGBA8);
let src = Image::read(&s, rect);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let checks = std::sync::atomic::AtomicUsize::new(0);
let cancel = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 10;
assert!(exact::run(&src, rect, &ctx, 150.0, 30.0, 8.0, &Interrupt::cancel_only(&cancel)).is_none());
assert_eq!(src, Image::read(&s, rect));
}
#[test]
#[ignore = "release full-operation baseline/candidate comparison; run alone"]
fn unsharp_performance() {
let number = |name: &str, default: i32| std::env::var(name).ok().and_then(|s| s.parse::<i32>().ok()).unwrap_or(default);
let (w, h, reps) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1500), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1000), number("PHOTOCRAFT_UNSHARP_REPS", 5));
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=15).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let channels = number("PHOTOCRAFT_UNSHARP_CHANNELS", 4);
let (mode, alpha) = match channels {
1 => (ColorMode::Grayscale, false),
2 => (ColorMode::Grayscale, true),
3 => (ColorMode::Rgb, false),
4 => (ColorMode::Rgb, true),
5 => (ColorMode::Cmyk, true),
_ => panic!("channels 1..5"),
};
let surface = source(area, PixelFormat::new(mode, depth, alpha));
let measure = std::env::var("PHOTOCRAFT_UNSHARP_MODE").unwrap_or_else(|_| "paired".into());
assert!(matches!(measure.as_str(), "paired" | "reference" | "candidate"));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "0.6:150:0,3:150:8,10:300:16,30:150:0".into());
for case in cases.split(',') {
let values: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (values[0], values[1], values[2]);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
let tile = crate::auto_tile(&p, area);
let before = || apply_reference(&surface, &p, area, area, None, Some(area), tile);
// Isolate the streamed exact path from the new adaptive FFT dispatcher.
let after = || crate::apply_tiled_with_impl(&surface, &p, area, area, None, tile, Some(area), &Interrupt::NONE, false).unwrap();
if measure != "paired" {
let f = if measure == "reference" { &before as &dyn Fn() -> Surface } else { &after as &dyn Fn() -> Surface };
let mut times = Vec::new();
for rep in 0..reps + 2 {
let start = Instant::now();
let output = f();
let ms = start.elapsed().as_secs_f64() * 1000.0;
std::hint::black_box(output);
if rep >= 2 {
times.push(ms);
}
}
println!(
"{}",
serde_json::json!({"mode":measure,"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"runs_ms":times})
);
continue;
}
for _ in 0..2 {
std::hint::black_box(before());
std::hint::black_box(after());
}
let (mut a, mut b) = (Vec::new(), Vec::new());
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let start = Instant::now();
let s = f();
(s, start.elapsed().as_secs_f64() * 1000.0)
};
let ((old, old_ms), (new, new_ms)) = if rep % 2 == 0 {
(timed(&before), timed(&after))
} else {
let b = timed(&after);
(timed(&before), b)
};
assert_eq!(old.to_interleaved(area), new.to_interleaved(area));
a.push(old_ms);
b.push(new_ms);
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"before_ms":a,"after_ms":b,"before_median_ms":median(&a),"after_median_ms":median(&b),"speedup":median(&a)/median(&b),"max_error":0})
);
}
}
#[test]
#[ignore = "unchanged-filter stage profile; run alone in release"]
fn unsharp_baseline_profile() {
let out = Rect::new(0, 0, 256, 256);
for radius in [0.6, 3.0, 10.0, 30.0] {
let reach = (radius * 3.0_f32).ceil() as i32 + 1;
let s = source(out.inflate(reach), PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
let src = Image::read(&s, out.inflate(reach));
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let k = reference::gaussian_kernel(radius);
let mut times = [0.0; 4];
for _ in 0..20 {
let start = Instant::now();
std::hint::black_box(reference::gaussian_kernel(radius));
times[0] += start.elapsed().as_secs_f64();
let start = Instant::now();
let bl = reference::conv_sep(&src, out, &k, &k, true);
times[1] += start.elapsed().as_secs_f64();
let start = Instant::now();
let mut res = src.crop(out);
for (px, b) in res.as_chunks_mut::<4>().0.iter_mut().zip(bl.as_chunks::<4>().0) {
if px[3] <= 0.0 {
continue;
}
for c in 0..3 {
let d = px[c] - b[c];
if d.abs() + 1e-6 >= 8.0 / 255.0 {
px[c] += 1.5 * d;
}
}
}
std::hint::black_box(res);
times[2] += start.elapsed().as_secs_f64();
let start = Instant::now();
std::hint::black_box(reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
times[3] += start.elapsed().as_secs_f64();
}
println!(
"{}",
serde_json::json!({"baseline_tile_radius":radius,"kernel_ms":times[0]*50.0,"convolution_ms":times[1]*50.0,"crop_blend_ms":times[2]*50.0,"total_kernel_ms":times[3]*50.0})
);
}
}
+17 -2
View File
@@ -5,6 +5,7 @@ use photocraft_geom::Rect;
use photocraft_raster::{from_rgba, to_rgba};
use crate::Ctx;
use crate::fxutil::subtractive;
use crate::image::Image;
fn fmt(ctx: &Ctx) -> PixelFormat {
@@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) {
px.copy_from_slice(&v[..px.len()]);
}
/// Solarize: inverts the highlights. CMYK samples are ink, so there the
/// channels below 50% ink (the light ones) invert.
pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
if subtractive(ctx) {
per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v })
} else {
per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v })
}
}
/// Colour channels through `f` (alpha untouched).
pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec<f32> {
let n = src.ch;
@@ -167,10 +178,13 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32,
res
}
/// Find Edges: Sobel magnitude per colour channel, dark edges on white.
/// Find Edges: Sobel magnitude per colour channel, dark edges on white (CMYK samples are ink, so
/// there the edge strength is the ink itself). At full strength, as Photoshop's: a 60-level step
/// darkens by 240 (Photoshop 27.11; a quarter of that was 4× too pale).
pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let sub = subtractive(ctx);
let mut res = src.crop(out);
let w = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -179,7 +193,8 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c);
let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1));
let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1));
*v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
let e = (gx * gx + gy * gy).sqrt().clamp(0.0, 1.0);
*v = if sub { e } else { 1.0 - e };
}
}
res
+112
View File
@@ -367,6 +367,30 @@ fn stylize_basics() {
assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6);
}
/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink)
/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched.
#[test]
fn find_edges_and_solarize_treat_cmyk_as_ink() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]);
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
let want = [0.9, 0.8, 0.8, 0.9, 0.6];
assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}");
}
// RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert.
let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}");
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}");
}
#[test]
fn distortions_are_identity_at_zero() {
let s = pattern(SampleType::U8, R);
@@ -422,6 +446,10 @@ fn halos_and_output_area() {
assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R);
assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12));
assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty());
// Fibers renders without reading the layer: an empty layer still fills the bounds (#5241).
let fibers = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 0, foreground: [0.0; 4], background: [1.0; 4] };
assert_eq!(output_area(&fibers, Rect::EMPTY, R, None), R);
assert_eq!(output_area(&fibers, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(0, 0, 12, 12));
}
#[test]
@@ -463,3 +491,87 @@ fn layer_filters_repeat_the_canvas_edge() {
assert!(faded.pixel(0, 0)[3] < 0.9, "{depth:?}: unclamped path should read transparency");
}
}
/// Photoshop 27.11's Polar Coordinates turn counter-clockwise from 12 o'clock: just left of the
/// top centre, Rectangular to Polar samples the source's left edge (clockwise would take its right).
#[test]
fn polar_coordinates_turn_counter_clockwise() {
let r = Rect::new(0, 0, 64, 64);
let mut s = flat(SampleType::U8, r, [0.0, 0.0, 1.0, 1.0]);
s.fill_rect(Rect::new(0, 0, 32, 64), &[1.0, 0.0, 0.0, 1.0]);
let p = FilterParams::PolarCoordinates { mode: PolarMode::RectangularToPolar };
let out = apply(&s, &p, r, r, None);
let left_of_top = out.rgba(28, 12);
assert!(left_of_top[0] > 0.9 && left_of_top[2] < 0.1, "{left_of_top:?}");
// The corners outside the circle repeat the source's edge instead of going transparent.
assert!(out.rgba(0, 0)[3] > 0.99, "{:?}", out.rgba(0, 0));
}
/// `mix_selection` before #4533, one selection lookup per pixel: the oracle for the per-tile read.
fn mix_selection_reference(data: &mut [f32], t: Rect, sel: &Surface, orig: &crate::image::Image, alpha: bool) {
let w = t.width() as usize;
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
}
}
}
/// Reading the selection once per tile gives exactly the per-pixel result (#4533): a soft
/// selection with fully selected, unselected and partial pixels, with and without alpha, on a
/// tile that crosses the selection's tile boundaries.
#[test]
fn mix_selection_reads_the_selection_once_per_tile() {
let noise = |seed: u32, n: usize| -> Vec<f32> {
(0..n as u32)
.map(|i| {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
h ^= h >> 13;
h = h.wrapping_mul(0xc2b2_ae35);
(h >> 16) as f32 / 65535.0
})
.collect()
};
let t = Rect::new(200, 180, 330, 300);
let n = (t.width() * t.height()) as usize;
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(150, 150, 260, 250), &[1.0]);
let soft = Rect::new(260, 150, 330, 250);
sel.write_region(soft, &noise(5, (soft.width() * soft.height()) as usize));
for (alpha, fmt) in [(true, PixelFormat::RGBA8), (false, PixelFormat { alpha: false, ..PixelFormat::RGBA8 })] {
let ch = fmt.channels();
let orig = crate::image::Image { rect: t, ch, data: noise(1, n * ch) };
let filtered = noise(2, n * ch);
let (mut a, mut b) = (filtered.clone(), filtered);
mix_selection(&mut a, t, &sel, &orig, alpha);
mix_selection_reference(&mut b, t, &sel, &orig, alpha);
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
}
}
+8 -2
View File
@@ -61,7 +61,14 @@ pub use shadows_highlights::shadows_highlights;
/// Returns the selection mask (what the dialog previews).
pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec<f32> {
let mask = crate::selection::color_range(px, color, fuzziness);
for (q, &k) in px.iter_mut().zip(&mask) {
replace_color_masked(px, &mask, hue, saturation, lightness);
mask
}
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
for (q, &k) in px.iter_mut().zip(mask) {
if k <= 0.0 {
continue;
}
@@ -74,7 +81,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0);
}
}
mask
}
/// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each.
+228 -33
View File
@@ -3,13 +3,13 @@
//! A transform is described by where the four corners of a source rectangle land (a quad). Scale,
//! rotate, skew and flips are affine special cases; Distort and Perspective use the full
//! homography. Warping is an inverse mapping with premultiplied-alpha sampling (nearest, bilinear
//! or Catmull-Rom bicubic), processed per destination tile (in parallel on native) so memory stays
//! or Photoshop's Keys bicubic), processed per destination tile (in parallel on native) so memory stays
//! bounded by the tile working set. Large reductions are pre-filtered with a proper resize first,
//! so shrinking a layer doesn't alias.
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_raster::{Interrupt, Surface};
use serde::{Deserialize, Serialize};
use crate::resample::{Resample, resize_surface};
@@ -43,6 +43,27 @@ impl Homography {
((m[0] * x + m[1] * y + m[2]) / w, (m[3] * x + m[4] * y + m[5]) / w)
}
/// The homogeneous `w` of `(x, y)`: its sign is the side of the horizon the point maps to
/// (0 on the horizon itself).
pub fn w(&self, x: f64, y: f64) -> f64 {
let m = &self.0;
m[6] * x + m[7] * y + m[8]
}
/// The side of the horizon (+1 or −1) that most corners of `r` (`[x0, y0, x1, y1]`) map to,
/// the centre's on a tie. A Distort dragged past the horizon maps the rest of the source to the
/// far side, where it would show as a mirrored copy wrapped round infinity; drawing keeps to
/// this side, as Photoshop's does (#4863).
pub fn front_side(&self, r: [f64; 4]) -> f64 {
let ahead = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].iter().filter(|(x, y)| self.w(*x, *y) > 0.0).count();
match ahead {
3 | 4 => 1.0,
0 | 1 => -1.0,
_ if self.w((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0) >= 0.0 => 1.0,
_ => -1.0,
}
}
pub fn mul(&self, o: &Homography) -> Homography {
let (a, b) = (&self.0, &o.0);
let mut r = [0.0; 9];
@@ -133,24 +154,40 @@ impl Interp {
}
}
fn catmull_rom(t: f64) -> [f64; 4] {
let t2 = t * t;
let t3 = t2 * t;
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
/// Photoshop's Bicubic: the Keys cubic at a = −0.75 (Catmull-Rom is −0.5), the weights of the
/// four source pixels around a sample `t` (0..1) past the second. Measured on Photoshop 27.11's
/// Free Transform: a 200 % step edge matches within 0.5 levels; Catmull-Rom was 6 off.
pub(crate) fn bicubic(t: f64) -> [f64; 4] {
const A: f64 = -0.75;
let near = |x: f64| (A + 2.0) * x * x * x - (A + 3.0) * x * x + 1.0;
let far = |x: f64| A * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0);
[far(1.0 + t), near(t), near(1.0 - t), far(2.0 - t)]
}
/// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space).
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
/// the warped quad are transparent.
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, None)
warp(src, src_rect, h, interp, None, &Interrupt::NONE)
}
/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled.
pub fn warp_surface_with(
src: &Surface,
src_rect: Rect,
h: &Homography,
interp: Interp,
ctl: &Interrupt,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
let out = warp(src, src_rect, h, interp, None, ctl)?;
Ok((!ctl.cancelled()).then_some(out))
}
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, Some(clip))
warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE)
}
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
@@ -192,7 +229,39 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
}
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
return None;
}
let lim = (1 << 20) as f64;
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
Some(Rect::new(bx0, by0, bx1, by1))
}
/// A cancelled `ctl` skips the remaining tiles; the caller discards the result.
fn warp(
src: &Surface,
src_rect: Rect,
h: &Homography,
interp: Interp,
clip: Option<Rect>,
ctl: &Interrupt,
) -> Result<Surface, photocraft_raster::AllocationError> {
let mut fmt = src.format();
let converted;
let mut src = if fmt.alpha {
@@ -212,12 +281,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
// that matters is the one at the source point landing in the clip's centre.
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
let f = 2f64.powf(scale.log2().ceil()).min(1.0); // remaining scale lands in [0.5, 1)
// Photoshop reduces with its Bicubic widened by 1 / scale (as Image Size does), so the source is
// brought down by the whole local scale first and the warp samples it at about 1:1; a plain
// 4×4 kernel at 50 % aliased (Photoshop 27.11 Free Transform, 50 % of an impulse image).
// Bilinear samples the source directly, as Photoshop's does (exact at 50 % without a prefilter).
if interp == Interp::Bicubic && scale < 0.999 && scale > 0.0 {
let f = scale.max(1.0 / 64.0);
reduced = resize_surface(src, f, f, Resample::Bicubic);
src = &reduced;
h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0]));
@@ -234,21 +307,17 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite());
let lim = 1 << 20;
let bounds = || {
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
Rect::new(bx0, by0, bx1, by1)
};
let dst = match clip {
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
// sign of the forward w at the source point it samples, and the other side would show as a
// mirrored copy wrapped round infinity (#4863).
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
let bounds = corner_bounds(&corners);
let dst = match (clip, bounds) {
// The warped corners only bound the result while the whole source is in front of the horizon.
Some(c) if finite && side.is_some() => bounds().intersect(&c),
Some(c) => c,
None if finite => bounds(),
None => return Ok(out),
(Some(c), Some(b)) if side.is_some() => b.intersect(&c),
(Some(c), _) => c,
(None, Some(b)) => b,
(None, None) => return Ok(out),
};
if dst.is_empty() {
return Ok(out);
@@ -261,7 +330,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
let src_ref: &Surface = src;
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
if ctl.cancelled() {
return Ok(None);
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes.
ctl.progress(0.98 * k as f32 / total as f32);
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]);
@@ -288,10 +366,10 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
return Ok(None);
}
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
@@ -314,6 +392,15 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64
}
};
// Samples past the source rect repeat its edge, so a stretched edge row or column keeps its
// colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage
// instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels.
let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c);
let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0);
let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0));
let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 };
let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs());
let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs());
let w = t.width() as usize;
let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
@@ -324,6 +411,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
let mut acc = [0.0f64; 8];
for y in t.y0..t.y1 {
for x in t.x0..t.x1 {
if front.is_some_and(|s| s * inv.w(x as f64 + 0.5, y as f64 + 0.5) <= 0.0) {
continue;
}
let (u, v) = inv.apply(x as f64 + 0.5, y as f64 + 0.5);
let (u, v) = (u - 0.5, v - 0.5);
if u < src_rect.x0 as f64 - 1.0 || v < src_rect.y0 as f64 - 1.0 || u > src_rect.x1 as f64 || v > src_rect.y1 as f64 {
@@ -343,24 +433,33 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += at(ix + dx, iy + dy, c) * wx * wy;
*s += clamped(ix + dx, iy + dy, c) * wx * wy;
}
}
}
}
Interp::Bicubic => {
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (catmull_rom(u - ix as f64), catmull_rom(v - iy as f64));
let (wx, wy) = (bicubic(u - ix as f64), bicubic(v - iy as f64));
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
*s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
}
}
}
}
}
if interp != Interp::Nearest {
// How much of this pixel the warped source rect covers, per axis.
let (uc, vc) = (u + 0.5, v + 0.5);
let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0);
let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0);
for s in acc.iter_mut().take(n) {
*s *= cu * cv;
}
}
let al = acc[a].clamp(0.0, 1.0);
if al <= 0.0 {
continue;
@@ -397,6 +496,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
}
}
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if ctl.cancelled() {
return Ok(out);
}
for (r, v) in done {
out.try_write_region(r, &v)?;
}
@@ -408,6 +510,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
mod tests {
use super::*;
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
let r = s.content_bounds();
let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap();
let reported = std::sync::Mutex::new(0.0f32);
let progress = |p: f32| {
let mut m = reported.lock().unwrap();
*m = m.max(p);
};
let never = || false;
let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled");
assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r));
assert!(*reported.lock().unwrap() > 0.9);
let always = || true;
assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none());
}
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
/// self-crossing, counter-clockwise and non-finite quads have none.
#[test]
@@ -436,6 +557,42 @@ mod tests {
Surface::new(PixelFormat::RGBA8)
}
/// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in
/// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased.
#[test]
fn stretched_single_column_stays_opaque() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]);
for interp in [Interp::Bicubic, Interp::Bilinear] {
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap();
let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap();
assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}");
for x in [0, 1, 25, 50, 75, 99] {
for y in 0..4 {
assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3));
}
}
}
}
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
#[test]
fn bicubic_upscale_matches_photoshop() {
let mut src = rgba();
let g = |v: f32| v / 255.0;
src.fill_rect(Rect::new(0, 0, 32, 64), &[g(64.0), g(64.0), g(64.0), 1.0]);
src.fill_rect(Rect::new(32, 0, 64, 64), &[g(192.0), g(192.0), g(192.0), 1.0]);
let q = [[-32.0, -32.0], [96.0, -32.0], [96.0, 96.0], [-32.0, 96.0]];
let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], q).unwrap();
let out = warp_surface(&src, Rect::new(0, 0, 64, 64), &h, Interp::Bicubic).unwrap();
let photoshop = [64.0, 64.0, 64.0, 60.0, 51.0, 93.0, 163.0, 206.0, 197.0, 192.0];
for (i, want) in photoshop.iter().enumerate() {
let got = out.pixel(26 + i as i32, 50)[0] * 255.0;
assert!((got - want).abs() <= 1.5, "column {}: {got} vs {want}", 26 + i);
}
}
#[test]
fn homography_maps_rect_corners_and_inverts() {
let r = [10.0, 20.0, 110.0, 70.0];
@@ -470,6 +627,44 @@ mod tests {
}
}
/// #4863: a Distort with the bottom corners pulled in and one dragged past the horizon (a
/// concave quad) leaves part of the source behind the horizon. That part maps to the far side,
/// where it showed as a mirrored copy wrapped round infinity; only the front side is drawn.
#[test]
fn a_distort_past_the_horizon_draws_only_its_front_side() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 100, 100), &[0.0, 0.5, 1.0, 1.0]);
let r = s.content_bounds();
let h = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [60.0, 60.0], [0.0, 300.0]]).unwrap();
let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| h.w(x, y));
assert!(corners.iter().any(|w| *w > 0.0) && corners.iter().any(|w| *w < 0.0), "the source straddles the horizon: {corners:?}");
let front = h.front_side([0.0, 0.0, 100.0, 100.0]);
let inv = h.inverse().unwrap();
let clip = Rect::new(-400, -400, 700, 700);
for interp in [Interp::Nearest, Interp::Bilinear] {
let w = warp_surface_clipped(&s, r, &h, interp, clip).unwrap();
let px = w.read_region(clip);
let (mut drawn, mut behind) = (0, 0);
for y in clip.y0..clip.y1 {
for x in clip.x0..clip.x1 {
let i = ((y - clip.y0) * clip.width() as i32 + (x - clip.x0)) as usize * 4;
if px[i + 3] > 0.0 {
drawn += 1;
// The source point this pixel samples is on the far side of the horizon.
if front * inv.w(x as f64 + 0.5, y as f64 + 0.5) <= 0.0 {
behind += 1;
}
}
}
}
assert!(drawn > 1000, "{interp:?}: the front side is drawn ({drawn} px)");
assert_eq!(behind, 0, "{interp:?}: {behind} pixels come from behind the horizon");
}
// A quad wholly in front of the horizon is drawn exactly as before.
let ok = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [155.0, 155.0], [0.0, 300.0]]).unwrap();
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
}
#[test]
fn identity_and_translation_are_exact() {
let mut s = rgba();
+3 -9
View File
@@ -4,7 +4,7 @@
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
//! cell's two triangles are rasterized in destination space with the source position
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
@@ -15,13 +15,7 @@ use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::transform::Interp;
fn catmull_rom(t: f64) -> [f64; 4] {
let t2 = t * t;
let t3 = t2 * t;
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
use crate::transform::{Interp, bicubic};
/// Grid cell size (source px) used to approximate the map.
const CELL: f64 = 4.0;
@@ -290,7 +284,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let snap = |t: f64| if (t - t.round()).abs() < 1e-7 { t.round() } else { t };
let (u, v) = (snap(u), snap(v));
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (catmull_rom(u - f64::from(ix)), catmull_rom(v - f64::from(iy)));
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
+90
View File
@@ -0,0 +1,90 @@
//! Frozen scalar convolution from main 5dac5c6, used as an exact-output oracle.
use photocraft_algo::retouch::local_blur;
#[test]
fn matches_scalar_blur_bit_for_bit_across_layouts_edges_and_hdr() {
for (w, h) in [(1, 1), (3, 7), (37, 29)] {
for ch in 1..=5 {
for alpha in std::iter::once(None).chain((0..ch).map(Some)) {
let data: Vec<f32> = (0..w * h * ch)
.map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })
.collect();
for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {
for sigma in [0.0, 0.25, 1.0, 3.0, 4.0, 12.0] {
let expected = scalar_local_blur(&data, w, h, ch, alpha, win, sigma);
let actual = local_blur(&data, w, h, ch, alpha, win, sigma);
assert_eq!(actual.len(), expected.len());
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(a.to_bits(), b.to_bits(), "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} sigma={sigma} sample={i}");
}
}
}
}
}
}
}
#[test]
fn empty_channel_layout_remains_empty() {
assert!(local_blur(&[], 5, 5, 0, None, (0, 0, 5, 5), 4.0).is_empty());
}
#[allow(clippy::too_many_arguments)]
fn scalar_local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
if c != a {
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
}
out
}
+59
View File
@@ -0,0 +1,59 @@
use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
#[test]
fn clipped_warp_matches_the_existing_kernel_at_every_depth() {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]);
source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]);
let clip = Rect::new(10, 10, 52, 52);
for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] {
let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap();
let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap();
assert_eq!(full.read_region(clip), bounded.read_region(clip));
assert!(clip.contains_rect(&bounded.content_bounds()));
assert!(bounded.tiles().count() <= clip.tiles().count());
}
}
}
}
#[test]
fn huge_scaled_warp_allocates_only_the_destination_clip() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]);
let clip = Rect::new(0, 0, 64, 64);
let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
assert_eq!(output.pixel(32, 32), source.pixel(10, 10));
assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none());
}
#[test]
fn inverse_footprint_handles_extreme_finite_affines() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
// A strongly skewed inverse maps a small visible tile beyond i32 limits.
let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let clip = Rect::new(0, 0, 32, 32);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
}
#[test]
fn source_center_does_not_add_extreme_coordinates_in_i32() {
let mut source = Surface::new(PixelFormat::RGBA8);
let origin = 1_500_000_000;
let bounds = Rect::new(origin, origin, origin + 4, origin + 4);
source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]);
let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap();
assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2));
}
+10 -7
View File
@@ -230,9 +230,11 @@ impl Headless {
(None, Some(p)) => {
let own = file_cmds::extension(p);
let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone());
if !file_cmds::saves_in_place(p) || written != own {
if !file_cmds::holds_at(&doc, p) || written != own {
let lost = file_cmds::losses_at(&doc, p);
let why = if lost.is_empty() { String::new() } else { format!(" (it can't keep: {})", lost.join("; ")) };
return Err(AutomationError::BadRequest(format!(
"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)
@@ -255,13 +257,14 @@ impl Headless {
}
};
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc));
// A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy:
// record the current revision as saved so the session stops reporting `dirty`, and make
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
// alone (#1547).
// A layered document save (PSD, PSB, OpenRaster or .pcraft) that holds the whole document is a
// full write, not a flattened copy: record the current revision as saved so the session
// stops reporting `dirty`, and make the file the document's path. A flat export (PNG,
// JPEG, …), or an OpenRaster file that drops part of it, is a copy and leaves both alone
// (#1547, #3806).
let ext =
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
if ext.as_deref().is_some_and(file_cmds::layered_extension) {
if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) {
// Saving by index must not retarget the next automation command.
let previously_active = self.session.active_index();
self.session.set_active(i);
+78 -21
View File
@@ -60,8 +60,13 @@ fn bad(msg: impl Into<String>) -> AutomationError {
AutomationError::BadRequest(msg.into())
}
fn index_of(p: &Value) -> Option<usize> {
p.get("index").and_then(Value::as_u64).map(|i| i as usize)
/// The optional `index` param: missing or null means the active document; anything else must be a
/// non-negative integer (never silently fall back to the active document, which `doc.close` would close).
fn index_of(p: &Value) -> Result<Option<usize>, AutomationError> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")),
}
}
fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
@@ -86,17 +91,9 @@ impl Headless {
let all = self.command_list();
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
None => all,
Some(n) => Value::Array(
all.as_array()
.into_iter()
.flatten()
.filter(|c| {
let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""));
hay.to_lowercase().contains(&n)
})
.cloned()
.collect(),
),
Some(n) => {
Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect())
}
})
}
"session.list" => Ok(self.session_list()),
@@ -109,20 +106,21 @@ impl Headless {
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.place(index_of(&p), path, &Value::Object(opts))
self.place(index_of(&p)?, path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
opts.encode.webp_lossless = false;
}
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
}
"doc.exportLayers" | "doc.exportDataSets" => {
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
@@ -130,16 +128,16 @@ impl Headless {
opts.remove("dir");
opts.remove("index");
let opts = Value::Object(opts);
if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) }
if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) }
}
"doc.importDataSets" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.import_data_sets(index_of(&p), path, &Value::Object(opts))
self.import_data_sets(index_of(&p)?, path, &Value::Object(opts))
}
"doc.inspect" => self.inspect(index_of(&p)),
"doc.inspect" => self.inspect(index_of(&p)?),
"doc.render" => {
let max = match p.get("maxSide") {
None => 1024,
@@ -147,7 +145,7 @@ impl Headless {
value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))?
}
};
let png = self.render_png(index_of(&p), max)?;
let png = self.render_png(index_of(&p)?, max)?;
match str_of(&p, "path") {
Some(path) => {
self.write_render(&PathBuf::from(path), &png)?;
@@ -160,10 +158,10 @@ impl Headless {
}
}
"doc.select" => {
let i = index_of(&p).ok_or_else(|| bad("doc.select needs `index`"))?;
let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?;
self.select(i)
}
"doc.close" => self.close(index_of(&p)),
"doc.close" => self.close(index_of(&p)?),
"batch" => self.batch(&p),
"methods" => Ok(json!(METHODS)),
other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
@@ -357,6 +355,42 @@ mod tests {
}
}
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
/// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`.
#[test]
fn command_list_replies_are_documented_array_of_objects() {
let h = session();
let mut g = h.lock().unwrap();
let all = g.handle("engine.commands", json!({})).unwrap();
let filtered = g.handle("engine.commands", json!({"filter": "BLUR"})).unwrap();
let engine = g.handle("engine.execute", json!({"command": "command.list"})).unwrap();
for (name, reply) in [("engine.commands", &all), ("engine.commands {filter}", &filtered), ("command.list", &engine)] {
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
assert!(!items.is_empty(), "{name}");
for c in items {
// `keywords` is optional: only commands with other search names carry it (#5060).
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect();
keys.sort_unstable();
assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}");
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
assert!(c["needsDocument"].is_boolean(), "{c}");
assert_eq!(c["disabledReason"].is_null(), c["enabled"] == true, "disabledReason is null exactly when enabled: {c}");
assert!(c["menu"].as_array().unwrap().iter().all(Value::is_string), "{c}");
assert!(c["shortcut"].is_string() || c["shortcut"].is_null(), "{c}");
}
}
let n_all = all.as_array().unwrap().len();
assert_eq!(engine.as_array().unwrap().len(), n_all);
let filtered = filtered.as_array().unwrap();
assert!(filtered.len() < n_all);
assert!(filtered.iter().all(|c| format!("{} {}", c["id"], c["label"]).to_lowercase().contains("blur")));
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((&new["menu"], &new["shortcut"], &new["enabled"]), (&json!(["File"]), &json!("Cmd+N"), &json!(true)), "{new}");
assert_eq!((&new["disabledReason"], &new["needsDocument"]), (&Value::Null, &json!(false)), "{new}");
}
#[test]
fn lines_round_trip_and_errors() {
let h = session();
@@ -829,4 +863,27 @@ mod tests {
assert_eq!(v["error"], "authentication required");
assert!(v.get("result").is_none());
}
#[test]
fn invalid_document_index_is_rejected_not_the_active_document() {
let h = session();
for _ in 0..2 {
assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true);
}
let count = |h: &Mutex<Headless>| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len();
for index in [json!("0"), json!(-1), json!(0.5), json!(true)] {
for method in ["doc.close", "doc.inspect", "doc.select"] {
let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string());
assert_eq!(reply["ok"], false, "{method} {index}: {reply}");
assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}");
}
assert_eq!(count(&h), 2, "index {index} closed a document");
}
// Null means "the active document", like a missing `index`.
assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true);
// A valid index still closes that document.
let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(count(&h), 1);
}
}
+20 -8
View File
@@ -167,6 +167,9 @@ pub struct SaveParams {
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
#[serde(default, rename = "tgaBits")]
pub tga_bits: Option<u8>,
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option<u8>,
#[serde(default)]
pub index: Option<usize>,
}
@@ -202,7 +205,7 @@ pub struct SelectParams {
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ListParams {
/// Only commands whose id or label contains this text (case-insensitive).
/// Only commands whose id, label or keywords contain this text (case-insensitive).
#[serde(default)]
pub filter: Option<String>,
/// Only commands that can run right now.
@@ -306,7 +309,7 @@ pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
@@ -317,10 +320,12 @@ pub struct UiSetParams {
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). Other fields are an error.
pub fields: Value,
}
@@ -792,7 +797,8 @@ impl PhotocraftMcp {
}
}
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \
#[tool(description = "List engine commands. Returns a JSON array (no wrapper object) of {id, label, menu: [path], \
shortcut: string|null, params: doc string, enabled: bool, disabledReason: string|null, needsDocument: bool}. \
`disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \
document is open: create or open one first (doc_new, doc_open).")]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
@@ -816,9 +822,8 @@ impl PhotocraftMcp {
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
.into_iter()
.filter(|c| {
let hay =
format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase();
needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
&& (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
})
.collect();
Ok(ok_json(&Value::Array(items)))
@@ -987,6 +992,11 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
};
let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) {
None => None,
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
@@ -996,6 +1006,7 @@ impl PhotocraftMcp {
p.format.is_some().then_some("format"),
p.quality.is_some().then_some("quality"),
p.tiff_layers.then_some("tiffLayers"),
dpx_bits.is_some().then_some("dpxBits"),
p.index.is_some().then_some("index"),
]
.into_iter()
@@ -1016,6 +1027,7 @@ impl PhotocraftMcp {
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
+13 -2
View File
@@ -252,6 +252,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
| "file.fileInfo"
| "file.automate.fitImage"
| "file.automate.conditionalModeChange"
| "file.automate.cropAndStraightenPhotos"
| "file.scripts.flattenAllLayerEffects"
| "file.scripts.flattenAllMasks"
| "file.scripts.deleteAllEmptyLayers"
@@ -362,12 +363,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"swatches.export" => params.get("path").is_some(),
"actions.save" | "swatches.export" => params.get("path").is_some(),
"plugin.reload" => true,
_ => false,
}
@@ -695,6 +696,8 @@ mod tests {
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
}
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
assert!(authorize_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
assert!(authorize_desktop_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers", "layer.smartObjects.saveContents"] {
assert!(authorize_desktop_engine_command(id, &serde_json::json!({"layer": 19})).is_ok(), "{id}");
}
@@ -755,6 +758,7 @@ mod tests {
fn automation_rejects_ambient_path_commands_and_preferences() {
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})),
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
@@ -762,15 +766,20 @@ mod tests {
("plugin.install", serde_json::json!({"path": " "})),
("swatches.import", serde_json::json!({"path": "/outside/set.aco"})),
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
("brush.presets.importImage", serde_json::json!({"data": "QUJD"})),
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
("plugin.install", serde_json::json!({"data": "QUJD"})),
("swatches.import", serde_json::json!({"data": "QUJD"})),
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
@@ -851,6 +860,8 @@ mod tests {
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
| "type.attachToPath"
| "type.detachFromPath"
| "select.toWorkPath"
| "layer.vectorMask.add"
| "layer.vectorMask.edit"
+3 -2
View File
@@ -166,7 +166,7 @@ async fn agent_completes_ten_scripted_tasks() {
let outside = pixel(&c, 1, 1).await;
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}");
assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}");
run(&c, "select.deselect", json!({})).await;
assert_eq!(inspect(&c).await["hasSelection"], false, "task 5: the selection became the mask");
// 6. Save a selection to a channel and load it back.
run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await;
@@ -331,6 +331,7 @@ async fn agent_play_refuses_a_recorded_file_open() {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let (c, server) = connect_headless(headless).await;
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
@@ -412,7 +413,7 @@ async fn agent_completes_ten_more_scripted_tasks() {
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
let boundary = pixel(&c, 20, 40).await;
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
run(&c, "select.deselect", json!({})).await;
assert_eq!(doc["hasSelection"], false, "task 17: the selection became the mask");
// 18. Expand a selection and read its bounds back.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
+68
View File
@@ -0,0 +1,68 @@
//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP
//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster.
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
}
out
}
fn kra() -> Vec<u8> {
let maindoc = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>
<DOC xmlns="http://www.calligra.org/DTD/krita" syntaxVersion="2.0"><IMAGE name="art" width="32" height="24" colorspacename="RGBA" x-res="72" y-res="72"><layers>
<layer nodetype="grouplayer" name="Inks" filename="layer1" compositeop="normal" opacity="255" visible="1"><layers>
<layer nodetype="paintlayer" name="Line" colorspacename="RGBA" filename="layer2" compositeop="multiply" opacity="204" visible="1"/>
</layers></layer>
<layer nodetype="shapelayer" name="Speech bubble" filename="layer3"/>
<layer nodetype="paintlayer" name="Paper" colorspacename="RGBA" filename="layer4" compositeop="normal" opacity="255" visible="1"/>
</layers></IMAGE></DOC>"#;
let mut z = photocraft_format::zip::ZipWriter::new();
z.add("mimetype", b"application/x-krita").unwrap();
z.add("maindoc.xml", maindoc.as_bytes()).unwrap();
z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap();
z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap();
z.finish().unwrap()
}
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
h.handle("engine.execute", json!({"command": command, "params": params})).unwrap()
}
#[test]
fn krita_documents_open_with_layers_and_save_as_openraster() {
let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("art.kra");
std::fs::write(&path, kra()).unwrap();
let mut h = Headless::trusted_local();
let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
let warnings = opened["warnings"].to_string();
assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}");
let doc = &h.session.active().unwrap().doc;
let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Paper", "Inks"]);
let line = doc.layers[1].children().unwrap()[0].clone();
assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply));
let flat = photocraft_compose::flatten(doc);
assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]);
// Read-only source: the file itself is never overwritten as Krita.
assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err());
exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5}));
let ora = dir.join("art.ora");
h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap();
let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document;
assert_eq!(back.layers.len(), 2);
assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5);
let _ = std::fs::remove_dir_all(&dir);
}
+10 -2
View File
@@ -167,6 +167,9 @@ async fn command_list_filters() {
let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await);
assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all);
assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string()));
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await);
assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}");
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
@@ -1236,11 +1239,16 @@ async fn convention_resources_are_live_json() {
assert_eq!(read("photocraft://document").await["width"], 8);
// Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold
// anything, and the other tests in this binary fill and purge those caches concurrently, so
// its `enabled` flag can flip between the two reads. Every other field must match exactly.
// its `enabled` flag and paired `disabledReason` can flip between the two reads. Validate
// each availability pair; every stable field and every other command must match exactly.
let stable = |mut v: Value| {
for c in v.as_array_mut().into_iter().flatten() {
if c["id"] == "edit.purge.all" {
c.as_object_mut().map(|o| o.remove("enabled"));
let reason = if c["enabled"].as_bool().unwrap() { Value::Null } else { json!("nothing to purge") };
assert_eq!(c["disabledReason"], reason, "purge availability stays internally consistent");
let command = c.as_object_mut().unwrap();
command.remove("enabled");
command.remove("disabledReason");
}
}
v
+37
View File
@@ -66,3 +66,40 @@ fn a_corrupt_openraster_file_reports_an_error() {
assert!(err.to_string().contains("OpenRaster"), "{err}");
std::fs::remove_dir_all(dir).unwrap();
}
/// #3806: an OpenRaster file holds only what OpenRaster can. Once the document has an adjustment
/// layer, a pathless save refuses to overwrite the .ora (saying what it would drop), and a save to
/// an .ora path writes a copy: the document keeps its file and stays unsaved.
#[test]
fn an_openraster_file_never_overwrites_what_it_cannot_hold() {
let dir = tmp("lossy");
let path = dir.join("art.ora");
let path_str = path.to_str().unwrap();
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
exec(&mut h, "layer.new.layer", json!({}));
exec(&mut h, "paint.stroke", json!({"points": [[4, 4], [30, 20]], "size": 6, "hardness": 1.0, "color": "#ff0000"}));
h.handle("doc.save", json!({"path": path_str})).unwrap();
let original = std::fs::read(&path).unwrap();
// A Curves adjustment layer: OpenRaster has no adjustment layers.
exec(&mut h, "layer.newAdjustmentLayer.curves", json!({}));
assert!(h.session.active().unwrap().is_dirty());
let err = h.handle("doc.save", json!({})).unwrap_err().to_string();
assert!(err.contains("adjustment layers"), "{err}");
assert_eq!(std::fs::read(&path).unwrap(), original, "the original is untouched");
assert!(h.session.active().unwrap().is_dirty(), "and the edits are still unsaved");
// Saving to an .ora path explicitly writes a copy (with the warnings), not the document's file.
let copy = dir.join("copy.ora");
let saved = h.handle("doc.save", json!({"path": copy.to_str().unwrap()})).unwrap();
assert!(saved["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("adjustment layers")))), "{saved}");
let st = h.session.active().unwrap();
assert!(st.is_dirty(), "a copy leaves the edits unsaved");
assert_eq!(st.path.as_deref(), Some(path_str), "the document keeps its file");
// As PSD it holds everything, so that becomes the document's file.
let psd = dir.join("art.psd");
h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap();
let st = h.session.active().unwrap();
assert!(!st.is_dirty());
assert_eq!(st.path.as_deref(), psd.to_str());
std::fs::remove_dir_all(dir).unwrap();
}
+3 -3
View File
@@ -1,6 +1,6 @@
# photocraft-cms
Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable),
Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable),
no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2)
specifications, CIE 15, and Adobe's published black point compensation paper.
@@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
| `rec2020` | Rec. 2020 primaries, Rec. 709 OETF |
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) |
| `lab-d50` | Lab identity (v4 encoding) |
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
### The CMYK profile
Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely
downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry
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
Binary file not shown.
+33 -13
View File
@@ -26,12 +26,18 @@ pub enum Builtin {
ProPhotoCompat,
/// sRGB primaries with a linear transfer function.
LinearSrgb,
/// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders.
AcesCg,
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
Rec2020,
/// Gray, gamma 2.2.
GrayGamma22,
/// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB.
SGray,
/// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure
/// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red
/// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile.
DotGain20Compat,
/// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding).
LabD50,
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
@@ -39,15 +45,17 @@ pub enum Builtin {
}
impl Builtin {
pub const ALL: [Builtin; 10] = [
pub const ALL: [Builtin; 12] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
Builtin::ProPhotoCompat,
Builtin::LinearSrgb,
Builtin::AcesCg,
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
Builtin::DotGain20Compat,
Builtin::LabD50,
Builtin::CoatedCmyk,
];
@@ -60,9 +68,11 @@ impl Builtin {
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
Builtin::ProPhotoCompat => "prophoto-compat",
Builtin::LinearSrgb => "linear-srgb",
Builtin::AcesCg => "aces-cg",
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
Builtin::DotGain20Compat => "dot-gain-20-compat",
Builtin::LabD50 => "lab-d50",
Builtin::CoatedCmyk => "coated-cmyk",
}
@@ -73,13 +83,15 @@ impl Builtin {
match self {
Builtin::Srgb => "sRGB IEC61966-2.1",
Builtin::DisplayP3 => "Display P3",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)",
Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)",
Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)",
Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)",
Builtin::LabD50 => "Lab D50 identity (PhotoCraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
}
}
@@ -96,6 +108,8 @@ impl Builtin {
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
"aces-cg" | "acescg" => Some(Builtin::AcesCg),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
_ => None,
@@ -105,18 +119,19 @@ impl Builtin {
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 10] = [const { OnceLock::new() }; 10];
static CELLS: [OnceLock<Profile>; 12] = [const { OnceLock::new() }; 12];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
}
}
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default
/// working gray), the coated CMYK profile, Lab D50.
pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> {
Some(match cs {
ColorSpace::Rgb => Builtin::Srgb.profile(),
ColorSpace::Gray => Builtin::SGray.profile(),
ColorSpace::Gray => Builtin::DotGain20Compat.profile(),
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
ColorSpace::Lab => Builtin::LabD50.profile(),
_ => return None,
@@ -131,7 +146,7 @@ pub fn srgb() -> &'static Profile {
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
@@ -247,12 +262,13 @@ fn lab() -> Profile {
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
fn build(b: Builtin) -> Profile {
let (srgb_p, p3, adobe, romm, bt2020) = (
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
);
match b {
Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()),
@@ -260,6 +276,9 @@ fn build(b: Builtin) -> Profile {
Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)),
Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)),
Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)),
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
@@ -268,6 +287,7 @@ fn build(b: Builtin) -> Profile {
}
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
Builtin::LabD50 => lab(),
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild
// them rather than crash if they ever fail to parse.
+148 -36
View File
@@ -429,10 +429,18 @@ impl<'a> Reader<'a> {
Ok(self.u32(off)? as i32 as f64 / 65536.0)
}
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?])
let r = Reader { b: self.slice(off, 12)? };
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
}
}
/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not
/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32):
/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976).
fn extent(base: usize, n: usize, size: usize) -> Result<usize, CmsError> {
n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated)
}
fn sig(s: &[u8; 4]) -> u32 {
u32::from_be_bytes(*s)
}
@@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
match r.slice(0, 4)? {
b"curv" => {
let n = r.u32(8)? as usize;
// Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976).
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let len = 12 + n * 2;
match n {
0 => Ok((Curve::Identity, len)),
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
_ => {
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let s = r.slice(12, n * 2)?;
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
Ok((Curve::Table(t), len))
@@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
return Err(CmsError::Invalid("bad lutAB dimensions".into()));
}
let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize);
let matrix = |off: usize| -> Result<Stage, CmsError> {
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = r.s15f16(off + k * 4)?;
}
let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
};
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = r.u8(off + k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = r.u8(off + 16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let data: Vec<f32> = match prec {
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
};
let matrix = |off: usize| lut_ab_matrix(&r, off);
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
let mut stages = Vec::new();
if ty == b"mAB " {
// A curves → CLUT → M curves → matrix → B curves.
@@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
}
}
/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_matrix(r: &Reader, off: usize) -> Result<Stage, CmsError> {
// One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`.
let e = Reader { b: r.slice(off, 48)? };
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = e.s15f16(k * 4)?;
}
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
}
/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result<Stage, CmsError> {
// Grid points (16 bytes), precision and padding; the samples follow.
let head = Reader { b: r.slice(off, 20)? };
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = head.u8(k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = head.u8(16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let samples = extent(off, 20, 1)?;
let data: Vec<f32> = match prec {
1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
}
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
fn parse_text(d: &[u8]) -> String {
let r = Reader { b: d };
@@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String {
let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() };
let mut best: Option<(usize, usize)> = None;
for k in 0..(n as usize).min(256) {
let o = 16 + k * size as usize;
let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break };
// The record size comes from the file: `k * size` can overflow a 32-bit `usize`.
let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break };
let rec = Reader { b: rec };
let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break };
if best.is_none() || lang == u16::from_be_bytes(*b"en") {
best = Some((off as usize, len as usize));
if lang == u16::from_be_bytes(*b"en") {
@@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String {
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn be(v: u32) -> [u8; 4] {
v.to_be_bytes()
}
/// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on
/// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow.
#[test]
fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() {
let bytes = [0u8; 64];
let r = Reader { b: &bytes };
for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] {
assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}");
assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}");
assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}");
}
assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated));
assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated));
assert_eq!(extent(16, 3, 12), Ok(52));
}
#[test]
fn a_curve_count_is_checked_before_its_length_is_computed() {
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into())));
// Controls: the identity and a two-point table still parse with their lengths.
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(0));
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12));
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(2));
d.extend([0, 0, 0xFF, 0xFF]);
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16));
}
#[test]
fn lut_ab_elements_at_hostile_offsets_are_rejected() {
// A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT
// offsets in turn point at the end of a 32-bit file.
let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| {
let mut d = b"mAB \0\0\0\0".to_vec();
d.extend([3, 3, 0, 0]);
for v in [b, matrix, m, clut, a] {
d.extend(be(v));
}
for _ in 0..3 {
d.extend(b"curv\0\0\0\0");
d.extend(be(0));
}
d
};
assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses");
let top = u32::MAX - 8;
assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset");
assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset");
assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset");
}
#[test]
fn multilingual_records_with_a_huge_record_size_give_the_first_record() {
let record = |lang: &[u8; 2], len: u32, off: u32| {
let mut r = lang.to_vec();
r.extend(b"US");
r.extend(be(len));
r.extend(be(off));
r
};
// Two records of the standard 12 bytes: the English one wins.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(2));
d.extend(be(12));
d.extend(record(b"de", 4, 40));
d.extend(record(b"en", 4, 44));
d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']);
assert_eq!(parse_text(&d), "EN");
// A record size of u32::MAX: the second record lies past any file, so the first is used.
d[12..16].copy_from_slice(&be(u32::MAX));
assert_eq!(parse_text(&d), "DE");
// No records at all.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
d.extend(be(u32::MAX));
assert_eq!(parse_text(&d), "");
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ use crate::math;
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
/// Parameters of the synthetic profile.
#[derive(Clone, Debug)]
+23 -2
View File
@@ -40,11 +40,13 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@@ -61,11 +63,17 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
`photocraft-io` can open them in Indexed Color mode.
Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the
embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit
@@ -74,6 +82,19 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
`CodecError::Unsupported`; Bayer mosaics open undebayered.
* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element,
descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits.
10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data
packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16
`round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No
colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and
colorimetric specification, so a log frame is reported as log. More elements than the first
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.
+284
View File
@@ -0,0 +1,284 @@
//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
//! texture arrays and volume textures are refused rather than flattened to one face or slice.
use crate::SampleType;
use crate::error::CodecError;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
/// DDS_HEADER_DXT10, present when the FourCC is `DX10`.
const DX10_HEADER: usize = 20;
const DDSD_DEPTH: u32 = 0x80_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const DDSCAPS2_CUBEMAP: u32 = 0x200;
const DDSCAPS2_VOLUME: u32 = 0x20_0000;
const DIMENSION_TEXTURE2D: u32 = 3;
const MISC_TEXTURECUBE: u32 = 0x4;
fn bad(reason: impl std::fmt::Display) -> CodecError {
CodecError::malformed(Format::Dds, reason)
}
fn unsupported(reason: impl Into<String>) -> CodecError {
CodecError::unsupported(Format::Dds, reason)
}
fn le32(b: &[u8], at: usize) -> Result<u32, CodecError> {
let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?;
Ok(u32::from_le_bytes(bytes))
}
/// A FourCC as text when it is printable, else as the number legacy writers store there.
fn fourcc_name(fourcc: u32) -> String {
let bytes = fourcc.to_le_bytes();
if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() }
}
/// One channel of an uncompressed pixel: where it sits and its largest value.
#[derive(Clone, Copy)]
struct Field {
shift: u32,
max: u32,
}
impl Field {
/// `None` for an empty mask; an error for one with gaps or wider than the pixel.
fn new(mask: u32, bits: u32) -> Result<Option<Field>, CodecError> {
if mask == 0 {
return Ok(None);
}
let shift = mask.trailing_zeros();
let max = mask >> shift;
if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) {
return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel")));
}
Ok(Some(Field { shift, max }))
}
fn get(self, pixel: u32) -> u8 {
let v = u64::from((pixel >> self.shift) & self.max);
let max = u64::from(self.max);
((v * 255 + max / 2) / max) as u8
}
}
enum Encoding {
/// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha.
Masks {
bytes: usize,
gray: bool,
fields: [Option<Field>; 4],
},
Bc1,
Bc2,
Bc3,
}
impl Encoding {
fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result<Encoding, CodecError> {
if !matches!(bits, 8 | 16 | 24 | 32) {
return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32")));
}
let mut fields = [None; 4];
for (field, mask) in fields.iter_mut().zip(masks) {
*field = Field::new(mask, bits)?;
}
let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) };
if !colour {
return Err(bad("the pixel format has no colour channel masks"));
}
Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields })
}
fn layout(&self) -> ChannelLayout {
match self {
Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA,
Encoding::Masks { gray: true, .. } => ChannelLayout::Gray,
Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb,
_ => ChannelLayout::Rgba,
}
}
/// Bytes of the top mip level.
fn level_size(&self, width: u32, height: u32) -> Option<usize> {
let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?);
let block = match self {
Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h),
Encoding::Bc1 => 8,
Encoding::Bc2 | Encoding::Bc3 => 16,
};
w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block)
}
}
/// The legacy pixel format: a FourCC, or channel masks.
fn legacy_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?);
let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 };
if flags & DDPF_FOURCC != 0 {
return match &fourcc.to_le_bytes() {
b"DXT1" => Ok(Encoding::Bc1),
b"DXT3" => Ok(Encoding::Bc2),
b"DXT5" => Ok(Encoding::Bc3),
b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")),
_ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))),
};
}
if flags & DDPF_RGB != 0 {
return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false);
}
if flags & DDPF_LUMINANCE != 0 {
return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true);
}
Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression")))
}
/// The DX10 header's DXGI format, refusing anything but a single 2D texture.
fn dx10_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?);
if misc & MISC_TEXTURECUBE != 0 {
return Err(unsupported("cubemaps are not supported"));
}
if dimension != DIMENSION_TEXTURE2D {
return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})")));
}
if array > 1 {
return Err(unsupported(format!("texture arrays are not supported ({array} textures)")));
}
const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000];
const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000];
const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0];
match dxgi {
27..=29 => Encoding::masks(32, RGBA, false),
87 | 90 | 91 => Encoding::masks(32, BGRA, false),
88 | 92 | 93 => Encoding::masks(32, BGRX, false),
70..=72 => Ok(Encoding::Bc1),
73..=75 => Ok(Encoding::Bc2),
76..=78 => Ok(Encoding::Bc3),
_ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))),
}
}
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !b.starts_with(b"DDS ") {
return Err(bad("missing the `DDS ` signature"));
}
if le32(b, 4)? != 124 {
return Err(bad("header size is not 124"));
}
let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?);
if caps2 & DDSCAPS2_CUBEMAP != 0 {
return Err(unsupported("cubemaps are not supported"));
}
if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) {
return Err(unsupported("volume textures are not supported"));
}
let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10";
let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) };
let layout = encoding.layout();
limits.check(width, height, layout, SampleType::U8)?;
let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?;
let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?;
let (w, h) = (width as usize, height as usize);
let channels = layout.channels();
let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?;
let mut pixels = vec![0u8; total];
match encoding {
Encoding::Masks { bytes, gray, fields } => {
for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) {
let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte));
let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel));
let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] };
dst.copy_from_slice(&values[..channels]);
}
}
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 };
let blocks_x = w.div_ceil(4);
for (i, block) in data.chunks_exact(block_size).enumerate() {
let texels = match encoding {
Encoding::Bc1 => bc1(block, true),
Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)),
_ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)),
};
let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4);
for (t, texel) in texels.iter().enumerate() {
let (x, y) = (bx + t % 4, by + t / 4);
if x < w && y < h {
let at = (y * w + x) * 4;
if let Some(dst) = pixels.get_mut(at..at + 4) {
dst.copy_from_slice(texel);
}
}
}
}
}
}
Image::from_u8(width, height, layout, pixels)
}
fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
/// `c0 <= c1` order selects three colours and transparent black.
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
let palette = if c0 > c1 || !one_bit_alpha {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
};
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
/// BC2: four explicit bits of alpha per texel.
fn bc2_alpha(block: &[u8]) -> [u8; 16] {
let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0)));
std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17)
}
/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus
/// 0 and 255 when `a0 <= a1`.
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
for (texel, a) in texels.iter_mut().zip(alpha) {
texel[3] = a;
}
texels
}
+44 -2
View File
@@ -227,6 +227,27 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;
limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;
// The display window is the full stage the renderer composed onto (a render pass's data
// window is often its crop). Open on the stage when it plausibly holds the data region:
// sane size for the limits, and the data region inside it. Otherwise fall back to the
// data window alone, as before.
let display = header.shared_attributes.display_window;
let display_size = (display.size.0.max(1) as u32, display.size.1.max(1) as u32);
let (stage, origin) = match display_size {
(sw, sh)
if sw <= 1 << 15
&& sh <= 1 << 15
&& limits.check_bytes(sw, sh, 4).is_ok()
&& dw.position.0 >= display.position.0
&& dw.position.1 >= display.position.1
&& dw.position.0.saturating_add(w as i32) <= display.position.0.saturating_add(sw as i32)
&& dw.position.1.saturating_add(h as i32) <= display.position.1.saturating_add(sh as i32) =>
{
(Some((sw, sh)), (dw.position.0 - display.position.0, dw.position.1 - display.position.1))
}
_ => (None, (0, 0)),
};
let exr_types: Vec<ExrSample> = header.channels.list.iter().map(|c| c.sample_type).collect();
let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum();
let multipart = meta.requirements.has_multiple_layers;
@@ -440,7 +461,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
}
}
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts: deep_counts })
}
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
@@ -565,7 +586,28 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
}
}
}
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
// Open on the full display-window stage when the file declares one: the renderer's data
// window is its crop, and the composition lives on the whole stage. The composed region
// pads to the stage at its offset, transparent elsewhere (limits validated the size).
let mut img = match deep.stage {
Some((sw, sh)) => {
let mut padded = vec![0f32; sw as usize * sh as usize * nc];
let row_len = deep.width as usize * nc;
let dx = deep.origin.0.max(0) as usize;
for row in 0..deep.height as usize {
let src = row * row_len;
let dst = (row + deep.origin.1.max(0) as usize) * sw as usize + dx;
if let (Some(d), Some(r)) = (padded.get_mut(dst * nc..dst * nc + row_len), out.get(src..src + row_len)) {
d.copy_from_slice(r);
}
}
Image::from_f32(sw, sh, layout, &padded)?
}
None => Image::from_f32(deep.width, deep.height, layout, &out)?,
};
if let Some((sw, sh)) = deep.stage {
img.warnings.push(DecodeWarning::DisplayWindowStage { stage: (sw, sh), origin: deep.origin });
}
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
+656
View File
@@ -0,0 +1,656 @@
//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the
//! 1664-byte generic header (file and image information, image source), the 384-byte industry
//! header (film and television), then the image data of each element.
//!
//! Reading:
//!
//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian).
//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without
//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words
//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the
//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits
//! (1, method A). Lines start on a 32-bit word boundary.
//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16
//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth.
//! * No colour transform: the transfer characteristic and colorimetric specification are reported
//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred.
//! * The image orientation is applied on import (the caller turns the image upright).
//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a
//! DPX export writes them back.
//!
//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with
//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image
//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one.
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
use crate::options::{DpxBits, EncodeOptions, Limits};
const F: Format = Format::Dpx;
/// Generic plus industry header: where our image data starts.
const HEADER_LEN: usize = 2048;
const GENERIC_LEN: u32 = 1664;
const INDUSTRY_LEN: u32 = 384;
/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float).
const UNDEF32: u32 = u32::MAX;
const UNDEF8: u8 = u8::MAX;
/// Offset of the first image element and the size of one.
const ELEMENT0: usize = 780;
const ELEMENT_LEN: usize = 72;
const MAX_ELEMENTS: u16 = 8;
// Header field offsets (generic file header, image information, film and TV headers).
const CREATION_TIME: (usize, usize) = (136, 24);
const CREATOR: (usize, usize) = (160, 100);
const PROJECT: (usize, usize) = (260, 200);
const COPYRIGHT: (usize, usize) = (460, 200);
const ORIENTATION: usize = 768;
const FRAME_POSITION: usize = 1712;
const FRAME_RATE: usize = 1724;
const TIME_CODE: usize = 1920;
/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip.
pub(crate) const KEY_CREATOR: &str = "DPX:Creator";
pub(crate) const KEY_PROJECT: &str = "DPX:Project";
pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright";
pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime";
pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer";
pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric";
pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition";
pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate";
pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode";
pub(crate) const KEYS: [&str; 9] =
[KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE];
fn err(msg: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, msg)
}
fn unsupported(msg: impl Into<String>) -> CodecError {
CodecError::unsupported(F, msg)
}
/// The file's byte order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Order {
Big,
Little,
}
impl Order {
fn bytes<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
b.get(at..at.checked_add(N)?)?.try_into().ok()
}
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
let s = Self::bytes::<2>(b, at)?;
Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) })
}
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
let s = Self::bytes::<4>(b, at)?;
Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) })
}
/// A 32-bit field that may be undefined (all ones).
fn defined_u32(self, b: &[u8], at: usize) -> Option<u32> {
self.u32(b, at).filter(|&v| v != UNDEF32)
}
fn f32(self, b: &[u8], at: usize) -> Option<f32> {
self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite())
}
}
/// The byte order from the magic number, `None` when it is not DPX.
fn order(b: &[u8]) -> Option<Order> {
match b.get(..4)? {
b"SDPX" => Some(Order::Big),
b"XPDS" => Some(Order::Little),
_ => None,
}
}
/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when
/// empty or undefined (all ones).
fn string(b: &[u8], (at, len): (usize, usize)) -> Option<String> {
let raw = b.get(at..at.checked_add(len)?)?;
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect();
Some(s.trim().to_string()).filter(|s| !s.is_empty())
}
/// The descriptive fields of image element 0 that decoding needs.
#[derive(Debug, Clone, Copy)]
struct Element {
signed: bool,
descriptor: u8,
transfer: u8,
colorimetric: u8,
bits: u8,
packing: u16,
encoding: u16,
data_offset: usize,
eol_padding: usize,
}
struct Header {
order: Order,
width: u32,
height: u32,
elements: u16,
element: Element,
}
fn parse(b: &[u8]) -> Result<Header, CodecError> {
let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?;
let missing = |what: &str| err(format!("the header ends before {what}"));
let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?;
o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?;
let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?;
if !(1..=MAX_ELEMENTS).contains(&elements) {
return Err(err(format!("invalid number of image elements {elements}")));
}
let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?;
let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?;
let e = ELEMENT0;
let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1"));
let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1"));
let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1"));
let data_offset = match field(28)? {
0 | UNDEF32 => image_offset,
v => v,
};
let eol_padding = match field(32)? {
UNDEF32 => 0,
v => v,
};
let element = Element {
signed: field(0)? == 1,
descriptor: byte(20)?,
transfer: byte(21)?,
colorimetric: byte(22)?,
bits: byte(23)?,
packing: half(24)?,
encoding: half(26)?,
data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?,
eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?,
};
Ok(Header { order: o, width, height, elements, element })
}
/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7
/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…).
/// Anything else, undefined included, is taken as the default (1).
pub(crate) fn orientation(b: &[u8]) -> u16 {
let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 };
match o {
1 => 2,
2 => 4,
3 => 3,
4 => 5,
5 => 6,
6 => 8,
7 => 7,
_ => 1,
}
}
/// Bytes in one stored line of `samples` samples, padded to a 32-bit word.
fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option<usize> {
let words = match (bits, packing) {
// Three samples to a word, or one to a 16-bit half word.
(10, 1 | 2) => samples.div_ceil(3),
(12, 1 | 2) => samples.div_ceil(2),
_ => samples.checked_mul(usize::from(bits))?.div_ceil(32),
};
words.checked_mul(4)
}
/// Code values of one stored line.
fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> {
let short = || err("truncated image data");
match (bits, packing) {
(8, _) => {
let src = line.get(..out.len()).ok_or_else(short)?;
for (v, &c) in out.iter_mut().zip(src) {
*v = u16::from(c);
}
}
(16, _) => {
for (i, v) in out.iter_mut().enumerate() {
*v = o.u16(line, i * 2).ok_or_else(short)?;
}
}
(10, 1 | 2) => {
let base = if packing == 1 { 22 } else { 20 };
for (i, v) in out.iter_mut().enumerate() {
let word = o.u32(line, i / 3 * 4).ok_or_else(short)?;
*v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16;
}
}
(12, 1 | 2) => {
let shift = if packing == 1 { 4 } else { 0 };
for (i, v) in out.iter_mut().enumerate() {
*v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF;
}
}
// Packing 0: one bit stream through the line's 32-bit words, high bits first.
_ => {
let mask = (1u64 << bits) - 1;
let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize);
for v in out.iter_mut() {
while have < u32::from(bits) {
acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?);
word += 1;
have += 32;
}
have -= u32::from(bits);
*v = ((acc >> have) & mask) as u16;
}
}
}
Ok(())
}
/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`.
fn widen(c: u16, max: u64) -> u16 {
((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16
}
/// The inverse of [`widen`]: `round(v × max / 65535)`.
fn narrow(v: u16, max: u64) -> u16 {
((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let h = parse(bytes)?;
let e = h.element;
let layout = match e.descriptor {
6 => ChannelLayout::Gray,
50 => ChannelLayout::Rgb,
51 => ChannelLayout::Rgba,
d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))),
};
if e.signed {
return Err(unsupported("data sign 1 (signed samples) is not supported"));
}
if e.encoding != 0 {
return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding)));
}
let packing_ok = match e.bits {
8 | 16 => e.packing == 0,
10 => e.packing <= 2,
12 => e.packing <= 1,
b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))),
};
if !packing_ok {
return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits)));
}
let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 };
limits.check(h.width, h.height, layout, sample)?;
let nc = layout.channels();
let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
let height = h.height as usize;
let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
let too_large = || CodecError::LimitExceeded("image too large".into());
let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?;
let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?;
// Lines run `stride` apart; the last one needs no padding after it.
let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len);
// Some writers pack 8- and 16-bit lines without padding them to a word; take that layout
// when the file is too short for padded lines but holds the unpadded ones.
let tight = per_line * usize::from(e.bits / 8);
if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) {
(line_len, stride) = (tight, tight);
}
if span(stride, line_len).ok_or_else(too_large)? > data.len() {
return Err(err("truncated image data"));
}
let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?];
for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() {
let start = y * stride;
let line = data.get(start..).ok_or_else(|| err("truncated image data"))?;
unpack_line(line, h.order, e.bits, e.packing, row)?;
}
let mut img = match e.bits {
8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?,
16 => Image::from_u16(h.width, h.height, layout, &codes)?,
b => {
let max = (1u64 << b) - 1;
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, max)).collect();
Image::from_u16(h.width, h.height, layout, &wide)?
}
};
if h.elements > 1 {
img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) });
}
if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 {
img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric });
}
img.meta.text = header_text(bytes, h.order, e);
Ok(img)
}
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
let mut text = Vec::new();
let mut put = |k: &str, v: Option<String>| {
if let Some(v) = v {
text.push((k.to_string(), v));
}
};
put(KEY_CREATOR, string(b, CREATOR));
put(KEY_PROJECT, string(b, PROJECT));
put(KEY_COPYRIGHT, string(b, COPYRIGHT));
put(KEY_CREATION_TIME, string(b, CREATION_TIME));
put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string()));
put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string()));
// The industry header is there when the generic header says so and the file holds it.
let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN;
if industry {
put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string()));
put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string()));
put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code));
}
text
}
/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD.
fn format_time_code(v: u32) -> String {
let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF);
if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") }
}
/// The inverse of [`format_time_code`]; `None` for anything else.
fn parse_time_code(s: &str) -> Option<u32> {
if let Some(hex) = s.strip_prefix("0x") {
return u32::from_str_radix(hex, 16).ok();
}
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) {
return None;
}
parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0')))
}
/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise.
pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
match opt {
Some(b) => b.bits(),
None if sample == SampleType::U8 => 8,
None => 16,
}
}
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
match opt {
Some(DpxBits::Bits8) => SampleType::U8,
Some(_) => SampleType::U16,
None if src == SampleType::U8 => SampleType::U8,
None => SampleType::U16,
}
}
struct Writer {
h: Vec<u8>,
}
impl Writer {
fn u8(&mut self, at: usize, v: u8) {
if let Some(b) = self.h.get_mut(at) {
*b = v;
}
}
fn u16(&mut self, at: usize, v: u16) {
if let Some(s) = self.h.get_mut(at..at + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
}
fn u32(&mut self, at: usize, v: u32) {
if let Some(s) = self.h.get_mut(at..at + 4) {
s.copy_from_slice(&v.to_be_bytes());
}
}
/// Marks a run of numeric fields undefined (all ones).
fn undefined(&mut self, from: usize, to: usize) {
if let Some(s) = self.h.get_mut(from..to) {
s.fill(0xFF);
}
}
/// An ASCII string field, cut to leave room for a terminating NUL.
fn string(&mut self, (at, len): (usize, usize), v: &str) {
let ascii: Vec<u8> = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect();
if let Some(s) = self.h.get_mut(at..at + ascii.len()) {
s.copy_from_slice(&ascii);
}
}
}
fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> {
img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty())
}
/// Appends one line's code values, padded to a 32-bit word.
fn pack_line(out: &mut Vec<u8>, codes: &[u16], bits: u8) {
let start = out.len();
match bits {
8 => out.extend(codes.iter().map(|&c| c as u8)),
10 => {
for chunk in codes.chunks(3) {
let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32)));
out.extend_from_slice(&word.to_be_bytes());
}
}
12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())),
_ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())),
}
let written = out.len() - start;
out.resize(start + written.div_ceil(4) * 4, 0);
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let (descriptor, layout) = match img.layout() {
ChannelLayout::Gray => (6u8, ChannelLayout::Gray),
ChannelLayout::Rgb => (50, ChannelLayout::Rgb),
ChannelLayout::Rgba => (51, ChannelLayout::Rgba),
l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))),
};
let bits = bits_for(opts.dpx_bits, img.sample_type());
let codes: Vec<u16> = match (img.sample_type(), bits) {
(SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(),
(SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(),
(SampleType::U16, 10 | 12) => {
let max = (1u64 << bits) - 1;
img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect()
}
(s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))),
};
let per_line = w as usize * layout.channels();
if codes.len() != per_line * h as usize {
return Err(CodecError::encode(F, "the image has the wrong number of samples"));
}
let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 })
.and_then(|l| l.checked_mul(h as usize))
.ok_or_else(|| CodecError::encode(F, "image too large"))?;
let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?;
let mut wr = Writer { h: vec![0; HEADER_LEN] };
// Generic file header.
wr.h[..4].copy_from_slice(b"SDPX");
wr.u32(4, HEADER_LEN as u32);
wr.string((8, 8), "V2.0");
wr.u32(16, total);
wr.u32(20, 1);
wr.u32(24, GENERIC_LEN);
wr.u32(28, INDUSTRY_LEN);
wr.u32(32, 0);
let keep = |key| if opts.embed_metadata { text(&img, key) } else { None };
wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or(""));
wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft"));
wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or(""));
wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or(""));
wr.u32(660, UNDEF32);
// Image information: default orientation, one element.
wr.u16(ORIENTATION, 0);
wr.u16(ORIENTATION + 2, 1);
wr.u32(772, w);
wr.u32(776, h);
let e = ELEMENT0;
wr.u32(e, 0);
wr.u32(e + 4, 0);
wr.u32(e + 8, UNDEF32);
wr.u32(e + 12, ((1u32 << bits) - 1).min(65535));
wr.u32(e + 16, UNDEF32);
wr.u8(e + 20, descriptor);
// No claim about the encoding unless the document came with one.
let code = |key| keep(key).and_then(|v| v.parse::<u8>().ok()).unwrap_or(UNDEF8);
wr.u8(e + 21, code(KEY_TRANSFER));
wr.u8(e + 22, code(KEY_COLORIMETRIC));
wr.u8(e + 23, bits);
wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 });
wr.u16(e + 26, 0);
wr.u32(e + 28, HEADER_LEN as u32);
wr.u32(e + 32, 0);
wr.u32(e + 36, 0);
// The seven unused elements, the image source header's numbers and the industry header's
// numbers are undefined; their strings stay empty.
wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS));
wr.undefined(1408, 1432);
wr.undefined(1620, 1644);
wr.undefined(FRAME_POSITION, 1732);
wr.undefined(TIME_CODE, 1972);
if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::<u32>().ok()) {
wr.u32(FRAME_POSITION, v);
}
if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::<f32>().ok()).filter(|v| v.is_finite()) {
wr.u32(FRAME_RATE, v.to_bits());
}
if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) {
wr.u32(TIME_CODE, v);
}
let mut out = wr.h;
out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?;
for row in codes.chunks_exact(per_line.max(1)) {
pack_line(&mut out, row, bits);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal header for `w`×`h` with element 0 set; image data at 2048.
fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec<u8> {
let mut b = vec![0u8; HEADER_LEN];
let put32 = |b: &mut Vec<u8>, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
let put16 = |b: &mut Vec<u8>, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() });
b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" });
put32(&mut b, 4, HEADER_LEN as u32);
put32(&mut b, 28, INDUSTRY_LEN);
put16(&mut b, ORIENTATION, 0);
put16(&mut b, ORIENTATION + 2, 1);
put32(&mut b, 772, w);
put32(&mut b, 776, h);
b[ELEMENT0 + 20] = descriptor;
b[ELEMENT0 + 21] = UNDEF8;
b[ELEMENT0 + 22] = UNDEF8;
b[ELEMENT0 + 23] = bits;
put16(&mut b, ELEMENT0 + 24, packing);
put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32);
b[FRAME_POSITION..1732].fill(0xFF);
b[TIME_CODE..1972].fill(0xFF);
b
}
fn words(o: Order, ws: &[u32]) -> Vec<u8> {
ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect()
}
#[test]
fn ten_bit_method_a_and_b_unpack_three_to_a_word() {
for o in [Order::Big, Order::Little] {
for (packing, base) in [(1u16, 22u32), (2, 20)] {
let mut b = header(o, 1, 1, 50, 10, packing);
b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))]));
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}");
}
}
}
#[test]
fn ten_bit_packing_0_is_one_bit_stream_per_line() {
// Four luma samples: 40 bits over two words, the line padded to the second word.
let samples = [1u64, 2, 1022, 1023];
let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24;
for o in [Order::Big, Order::Little] {
let mut b = header(o, 4, 2, 6, 10, 0);
let line = words(o, &[(stream >> 32) as u32, stream as u32]);
b.extend(&line);
b.extend(&line);
let img = decode(&b, &Limits::default()).unwrap();
let want: Vec<u16> = samples.iter().map(|&s| widen(s as u16, 1023)).collect();
assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}");
}
}
#[test]
fn twelve_bit_method_a_keeps_the_top_bits() {
let mut b = header(Order::Big, 2, 1, 6, 12, 1);
b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]);
}
#[test]
fn widen_and_narrow_round_trip_every_code() {
for bits in [10u8, 12] {
let max = (1u64 << bits) - 1;
for c in 0..=max as u16 {
assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}");
}
}
assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16);
}
#[test]
fn orientation_codes_map_to_exif() {
let mut b = header(Order::Big, 1, 1, 6, 8, 0);
for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] {
b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes());
assert_eq!(orientation(&b), exif, "DPX {dpx}");
}
}
#[test]
fn time_codes_round_trip() {
assert_eq!(format_time_code(0x0102_0304), "01:02:03:04");
assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304));
assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000));
assert_eq!(parse_time_code("1:2:3:4"), None);
}
#[test]
fn eight_bit_lines_without_word_padding_still_open() {
// 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8.
let mut b = header(Order::Big, 1, 2, 50, 8, 0);
b.extend_from_slice(&[1, 2, 3, 4, 5, 6]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
}
}
+15
View File
@@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their
/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show.
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> {
let meta = read_meta(bytes, limits)?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "no header-only size probe for deep data"));
}
let pick = pick_part(&meta);
let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?;
if info.color_rank() == 0 {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
}
Ok((info.width, info.height))
}
/// Decode one part of a multi-part file by its index into [`info`]'s list. A file with
/// deep parts is not mixed: the deep decoder composites those instead.
pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result<Image, CodecError> {
+23 -13
View File
@@ -1,7 +1,8 @@
//! Cryptomatte (specification 1.2): per-pixel object/material IDs with coverage in EXR
//! channels, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles. Layers are
//! announced by `cryptomatte/<key>/name|hash|conversion|manifest` header attributes; the
//! samples live in `<name>NN.red` (ID) / `<name>NN.green` (coverage) channel pairs.
//! samples live in `<name>NN` RGBA channel sets, each holding two (ID, coverage) ranks:
//! `.red`/`.green` (rank 2·NN) and `.blue`/`.alpha` (rank 2·NN+1).
//!
//! IDs are the float *values*: MurmurHash3 x86-32 (seed 0) of the object name, its
//! exponent fixed away from 0/255 so no ID is denormal, infinite or NaN, read as an f32
@@ -20,21 +21,26 @@ use crate::options::Limits;
const F: Format = Format::OpenExr;
/// A Cryptomatte stream channel (`crypto_asset00.red`) splits into its stream name
/// (`crypto_asset00`), the layer prefix without digits (`crypto_asset`), the stream index,
/// and whether it is the coverage (green) channel instead of the ID (red).
/// (`crypto_asset00`), the stream index, whether it is a coverage channel (green/alpha)
/// instead of an ID (red/blue), and its rank within the set (0 for red/green, 1 for
/// blue/alpha).
pub(crate) struct StreamChannel {
pub stream: String,
pub index: u32,
pub coverage: bool,
pub rank: usize,
}
/// `<name>NN` where NN is one or more digits (the reference readers match `\d+`), with a
/// `.red`/`.r` (ID) or `.green`/`.g` (coverage) suffix.
/// `.red`/`.r` or `.blue`/`.b` (ID) or `.green`/`.g` or `.alpha`/`.a` (coverage) suffix,
/// in any case (Blender writes `.R`/`.G`/`.B`/`.A`).
pub(crate) fn split_stream(channel: &str) -> Option<StreamChannel> {
let (stream, suffix) = channel.rsplit_once('.')?;
let coverage = match suffix {
"red" | "r" => false,
"green" | "g" => true,
let (coverage, rank) = match suffix.to_ascii_lowercase().as_str() {
"red" | "r" => (false, 0),
"green" | "g" => (true, 0),
"blue" | "b" => (false, 1),
"alpha" | "a" => (true, 1),
_ => return None,
};
let digits = stream.chars().rev().take_while(|c| c.is_ascii_digit()).count();
@@ -43,7 +49,7 @@ pub(crate) fn split_stream(channel: &str) -> Option<StreamChannel> {
}
let split = stream.len() - digits;
let index = stream[split..].parse().ok()?;
Some(StreamChannel { stream: stream.to_string(), index, coverage })
Some(StreamChannel { stream: stream.to_string(), index, coverage, rank })
}
/// MurmurHash3, x86-32 bit variant, seed 0 (public-domain algorithm, Austin Appleby).
@@ -210,14 +216,17 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
let npx = w.checked_mul(h).ok_or_else(|| CodecError::malformed(F, "the layer is too large"))?;
let too_large = || CodecError::malformed(F, "the layer is too large");
let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?);
// Each pixel also gets a pair list, so count its header with the channel samples.
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
// Each pixel also gets a pair list, so count its header with the channel samples (four
// f32 channels per set: two ranks of ID and coverage).
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
limits.check_bytes(w32, h32, per_pixel)?;
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
// Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`.
let ranks = layer.channels.len().saturating_mul(2);
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); ranks];
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); ranks];
for c in &ex_layer.channel_data.list {
let Some(stream) = split_stream(&c.name.to_string()) else { continue };
let Some(slot) = layer.channels.iter().position(|s| *s == stream.stream) else { continue };
@@ -226,7 +235,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
return Err(CodecError::malformed(F, "a Cryptomatte channel is smaller than its layer"));
}
let bucket = if stream.coverage { &mut coverages } else { &mut ids };
bucket[slot] = values;
let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) else { continue };
*dst = values;
}
let mut pixels = vec![Vec::new(); npx];
+85 -7
View File
@@ -14,10 +14,12 @@
//! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN
//! and blank pixels read as 0.
//! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and
//! `HISTORY` by their text, `HIERARCH` keywords by their long name.
//! `HISTORY` by their text, `HIERARCH` keywords by their long name. Long strings continued
//! on `CONTINUE` cards are joined.
//!
//! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards.
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards
//! (strings longer than one card on `CONTINUE` cards).
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
use crate::Format;
@@ -149,7 +151,20 @@ fn parse_header(b: &[u8], start: usize) -> Result<Header, CodecError> {
if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) {
break;
}
cards.push(parse_card(raw));
let card = parse_card(raw);
// Long strings (FITS 4.0 section 4.2.1.2): a string ending in '&' goes on in the next
// `CONTINUE` record. An orphaned `CONTINUE` stays a card of its own (commentary).
if let Some(Card { value: Some(Value::Str(s)), .. }) = cards.last_mut()
&& s.ends_with('&')
&& card.key == "CONTINUE"
&& card.text.starts_with(" ")
&& let Value::Str(more) = parse_value(&card.text)
{
s.pop();
s.push_str(&more);
continue;
}
cards.push(card);
}
let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?;
Ok(Header { cards, data_start })
@@ -390,9 +405,22 @@ impl Writer {
fn value(&mut self, key: &str, v: &str) {
if literal(v) {
self.card(&format!("{key:<8}= {v:>20}"));
} else {
self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10)));
return;
}
// A string too long for one card goes on in `CONTINUE` cards, every piece but the last
// ending in '&' (FITS 4.0 section 4.2.1.2). A doubled quote is never split, and trailing
// blanks (not significant) are dropped so none are kept before an '&'.
let escaped = ascii(v).trim_end().replace('\'', "''");
let mut rest = escaped.as_str();
let mut head = format!("{key:<8}= ");
while rest.len() > CARD - 12 {
let cut = CARD - 13 - rest.get(..CARD - 13).unwrap_or("").bytes().rev().take_while(|&b| b == b'\'').count() % 2;
let (piece, tail) = rest.split_at_checked(cut).unwrap_or((rest, ""));
self.card(&format!("{head}'{piece}&'"));
head = "CONTINUE ".to_string();
rest = tail;
}
self.card(&format!("{head}'{rest:<8}'"));
}
fn commentary(&mut self, key: &str, text: &str) {
@@ -410,7 +438,8 @@ impl Writer {
if matches!(upper.as_str(), "COMMENT" | "HISTORY") {
self.commentary(&upper, v);
} else if standard_key(key) {
if !STRUCTURAL.contains(&key) && !is_naxis(key) {
// `CONTINUE` only ever carries a string's continuation, written by `value`.
if !STRUCTURAL.contains(&key) && !is_naxis(key) && key != "CONTINUE" {
self.value(key, v);
}
} else {
@@ -501,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
// 0..1 as documented: a value above 2 would make readers (ours too)
// take the data as ADU and scale it by 65535. NaN is written as 0.
let v = d[idx(p, y, x)];
let v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) };
out.extend_from_slice(&v.to_be_bytes());
}
}
}
@@ -526,6 +559,40 @@ mod tests {
out
}
#[test]
fn continue_strings_decode() {
let mut b = header(&[
"SIMPLE = T",
"BITPIX = 8",
"NAXIS = 2",
"NAXIS1 = 1",
"NAXIS2 = 1",
"OBJECT = 'aaa&'",
"CONTINUE 'bbb&' / a comment",
"CONTINUE '&'",
"CONTINUE ''",
"OBSERVER= 'R&'",
"COMMENT hi",
"CONTINUE 'orphan'",
]);
b.push(0);
let img = decode(&b, &Limits::default()).unwrap();
let text = [("OBJECT", "aaabbb"), ("OBSERVER", "R&"), ("COMMENT", "hi"), ("CONTINUE", "'orphan'")];
assert_eq!(img.meta.text, text.map(|(k, v)| (k.to_string(), v.to_string())));
}
#[test]
fn continue_strings_round_trip() {
for value in ["a".repeat(100), format!("{}' {}", "a".repeat(65), "b".repeat(100)), "'".repeat(100), "a".repeat(68)] {
let mut img = Image::from_u8(1, 1, ChannelLayout::Gray, vec![0]).unwrap();
img.meta.text = vec![("OBJECT".into(), value.clone()), ("CONTINUE".into(), "orphan".into())];
let b = crate::encode(&img, F, &EncodeOptions::default()).unwrap();
assert!(!b.chunks(CARD).any(|c| c.starts_with(b"CONTINUE=")));
let decoded = decode(&b, &Limits::default()).unwrap();
assert_eq!(decoded.meta.text, vec![("OBJECT".into(), value)]);
}
}
#[test]
fn camera_u16_with_bzero_reads_exactly_and_bottom_up() {
let mut b = header(&[
@@ -569,6 +636,17 @@ mod tests {
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
}
#[test]
fn hdr_float_saves_clamped_and_reopens_as_saved() {
// One value above 2 used to make the reader scale everything by 65535 (all black).
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
let opts = EncodeOptions::default();
let bytes = encode(&img, crate::fidelity::plan(&img, F, &opts), &opts).unwrap();
let back = decode(&bytes, &Limits::default()).unwrap().to_f32_samples().unwrap();
assert!((back[0] - 0.5).abs() < 1e-6, "{back:?}");
assert!((back[1] - 1.0).abs() < 1e-6, "{back:?}");
}
#[test]
fn signed_negative_data_falls_back_to_float() {
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
+4
View File
@@ -1,4 +1,6 @@
pub(crate) mod dds;
pub(crate) mod deep_exr;
pub(crate) mod dpx;
pub(crate) mod exr;
pub(crate) mod exr_cryptomatte;
pub(crate) mod fits;
@@ -9,7 +11,9 @@ pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
pub(crate) mod tiff;
pub(crate) mod tiff_exif;
pub(crate) mod tiff_ifd;
pub(crate) mod via_image;
pub(crate) mod vp8;
pub(crate) mod webp;
pub(crate) mod xpm;
+68 -1
View File
@@ -17,6 +17,15 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
/// Pixels per inch for `pHYs` pixels per metre. A whole number of ppi is stored rounded to whole
/// pixels per metre (300 ppi = 11811), so reading it back gives 299.9994: the whole number that
/// rounds to the stored value is the resolution that was meant, as Photoshop reads it.
pub(crate) fn dpi_of_ppm(ppm: u32) -> f32 {
let dpi = f64::from(ppm) * f64::from(METERS_PER_INCH);
let whole = dpi.round();
if (whole / f64::from(METERS_PER_INCH)).round() == f64::from(ppm) { whole as f32 } else { dpi as f32 }
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let mut input = Cursor::new(bytes);
let mut decoder = png::Decoder::new_with_limits(&mut input, png::Limits { bytes: limits.alloc_usize() });
@@ -74,7 +83,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
&& d.xppu > 0
&& d.yppu > 0
{
meta.dpi = Some((d.xppu as f32 * METERS_PER_INCH, d.yppu as f32 * METERS_PER_INCH));
meta.dpi = Some((dpi_of_ppm(d.xppu), dpi_of_ppm(d.yppu)));
}
drop(reader);
let end = usize::try_from(input.position()).map_err(|_| err("invalid PNG reader position"))?;
@@ -87,6 +96,37 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The colour table of a palette PNG (`PLTE`) and the one entry its `tRNS` makes transparent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PngPalette {
pub colors: Vec<[u8; 3]>,
pub transparent: Option<u8>,
}
/// The colour table of a palette PNG (colour type 3), read from the headers without decoding
/// pixels. `None` for other colour types, and for a `tRNS` with partial alpha or several
/// transparent entries: an Indexed Color document holds one transparent entry.
pub fn palette(bytes: &[u8]) -> Result<Option<PngPalette>, CodecError> {
let mut decoder = png::Decoder::new_with_limits(Cursor::new(bytes), png::Limits::default());
decoder.set_ignore_text_chunk(true);
let reader = decoder.read_info().map_err(map_png_err)?;
let info = reader.info();
if info.color_type != png::ColorType::Indexed {
return Ok(None);
}
let Some(plte) = info.palette.as_deref() else { return Ok(None) };
let colors = plte.as_chunks::<3>().0.to_vec();
let mut transparent = None;
for (i, &a) in info.trns.as_deref().unwrap_or_default().iter().enumerate() {
match a {
255 => {}
0 if transparent.is_none() && i < colors.len() => transparent = u8::try_from(i).ok(),
_ => return Ok(None),
}
}
Ok(Some(PngPalette { colors, transparent }))
}
fn map_png_err(e: png::DecodingError) -> CodecError {
match e {
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
@@ -586,6 +626,33 @@ mod indexed_tests {
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
}
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec<u8> {
let mut out = Vec::new();
let mut e = png::Encoder::new(&mut out, 2, 1);
e.set_color(color);
e.set_depth(png::BitDepth::Eight);
e.set_palette(vec![1, 2, 3, 4, 5, 6]);
if let Some(t) = trns {
e.set_trns(t.to_vec());
}
let data = if color == png::ColorType::Rgb { vec![0; 6] } else { vec![0, 1] };
e.write_header().unwrap().write_image_data(&data).unwrap();
out
}
#[test]
fn palette_reads_the_table_and_its_one_transparent_entry() {
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap();
let colors = vec![[1, 2, 3], [4, 5, 6]];
assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) }));
assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None }));
// A truecolour PNG's suggested palette is not a colour table.
assert_eq!(palette(&png(png::ColorType::Rgb, None)).unwrap(), None);
// Partial alpha or two transparent entries don't fit one transparent index.
assert_eq!(palette(&png(png::ColorType::Indexed, Some(&[255, 128]))).unwrap(), None);
assert_eq!(palette(&png(png::ColorType::Indexed, Some(&[0, 0]))).unwrap(), None);
}
}
#[cfg(test)]
+34 -1
View File
@@ -35,6 +35,7 @@ use tiff::encoder::colortype::{self, ColorType as TiffColorType};
use tiff::encoder::{Rational, TiffEncoder, TiffKind, TiffKindBig, TiffKindStandard, TiffValue};
use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, Type};
use super::tiff_exif::{self, ExifDirs};
use super::tiff_ifd::{self as ifd, File, Ifd};
use crate::Format;
use crate::error::CodecError;
@@ -297,6 +298,13 @@ fn metadata(f: &File<'_>, dir: &Ifd, icc: &mut Option<Vec<u8>>) -> Metadata {
if dir.at == f.first_ifd { photoshop_tags(f.b) } else { (f.tag_bytes(dir, TAG_PHOTOSHOP), f.tag_bytes(dir, TAG_IMAGE_SOURCE_DATA)) };
meta.photoshop_resources = resources.map(<[u8]>::to_vec);
meta.photoshop_layers = layers.map(<[u8]>::to_vec);
// EXIF: the Exif and GPS directories IFD0 points to, with IFD0's descriptive tags, as an
// EXIF block (the other pages are reduced copies or layers of the same image).
if dir.at == f.first_ifd
&& let Some(e) = tiff_exif::read(f.b).filter(ExifDirs::has_directories)
{
meta.exif = Some(tiff_exif::block(&e));
}
meta
}
@@ -1016,6 +1024,8 @@ custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5],
struct TagSet<'a> {
icc: Option<&'a [u8]>,
/// The EXIF block's directories and IFD0 tags, written as the TIFF's own.
exif: Option<ExifDirs>,
xmp: Option<&'a [u8]>,
dpi: Option<(f32, f32)>,
text: Vec<(Tag, &'a str)>,
@@ -1032,6 +1042,16 @@ where
[C::Inner]: TiffValue,
K: TiffKind,
{
// The Exif and GPS directories go before the image, outside its chain; IFD0 points to them.
let mut sub = |e: &ExifDirs, fields: &[tiff_exif::Field]| (!fields.is_empty()).then(|| tiff_exif::write_directory(enc, fields, e.le)).transpose();
let exif_at = match &tags.exif {
Some(e) => sub(e, &e.exif)?,
None => None,
};
let gps_at = match &tags.exif {
Some(e) => sub(e, &e.gps)?,
None => None,
};
let mut im = enc.new_image::<C>(w, h)?;
if let Some((x, y)) = tags.dpi {
im.resolution_unit(ResolutionUnit::Inch);
@@ -1052,6 +1072,17 @@ where
for (tag, s) in &tags.text {
d.write_tag(*tag, *s)?;
}
if let Some(e) = &tags.exif {
// The EXIF block's descriptive IFD0 tags, unless the text metadata already wrote them.
let ifd0: Vec<tiff_exif::Field> = e.ifd0.iter().filter(|f| !tags.text.iter().any(|(t, _)| t.to_u16() == f.tag)).cloned().collect();
tiff_exif::write_fields(d, &ifd0, e.le)?;
}
if let Some(at) = exif_at {
d.write_tag(Tag::Unknown(tiff_exif::TAG_EXIF_IFD), at)?;
}
if let Some(at) = gps_at {
d.write_tag(Tag::Unknown(tiff_exif::TAG_GPS_IFD), at)?;
}
if let Some(r) = tags.photoshop_resources {
d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?;
}
@@ -1099,7 +1130,8 @@ const CLASSIC_LIMIT: u64 = u32::MAX as u64;
/// room for the directory and strip tables.
fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 {
let payloads = [tags.icc, tags.xmp, tags.photoshop_resources, tags.photoshop_layers].iter().flatten().map(|p| p.len() as u64).sum::<u64>();
let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::<u64>();
let text = tags.text.iter().map(|(_, s)| s.len() as u64).sum::<u64>()
+ tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::<u64>();
// Strip tables: one offset and one count per strip of about 1 MB.
let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16;
(img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20)
@@ -1132,6 +1164,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
let tags = TagSet {
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
exif: if opts.embed_metadata { img.meta.exif.as_deref().and_then(|e| tiff_exif::read(crate::orientation::tiff_body(e))) } else { None },
xmp: xmp.as_deref().map(str::as_bytes),
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
text,
+195
View File
@@ -0,0 +1,195 @@
//! EXIF in a TIFF file: the Exif (34665) and GPS (34853) directories that IFD0 points to, plus
//! IFD0's descriptive tags (make, model, software, artist, …), the way cameras and Photoshop
//! write them.
//!
//! An EXIF block (`Metadata::exif`) is itself a small TIFF stream, so both directions go through
//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own
//! directories, writing one copies the block's directories into the file. Values are copied as
//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive.
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
use tiff::tags::{Tag, Type};
use tiff::{Directory, TiffResult};
use super::tiff_ifd::{File, Ifd};
/// IFD0's pointer to the Exif directory.
pub(crate) const TAG_EXIF_IFD: u16 = 34665;
/// IFD0's pointer to the GPS directory.
pub(crate) const TAG_GPS_IFD: u16 = 34853;
/// The Exif directory's pointer to the Interoperability directory (not carried: it holds only
/// the interoperability index, and a copied pointer would point nowhere).
const TAG_INTEROP_IFD: u16 = 40965;
/// IFD0 tags an EXIF block carries next to its directories: ImageDescription, Make, Model,
/// Software, DateTime, Artist, Copyright. Structure, resolution and orientation belong to the
/// image and are written from it.
const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432];
/// At most this many entries are copied from one directory.
const MAX_FIELDS: usize = 1024;
/// One entry with its value bytes in the byte order of the stream it came from.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Field {
pub tag: u16,
pub ty: u16,
pub count: u32,
pub value: Vec<u8>,
}
/// The EXIF parts of a TIFF stream, in its byte order (`le`).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ExifDirs {
pub le: bool,
pub ifd0: Vec<Field>,
pub exif: Vec<Field>,
pub gps: Vec<Field>,
}
impl ExifDirs {
/// Whether there is an Exif or GPS directory (IFD0's descriptive tags alone are not EXIF).
pub fn has_directories(&self) -> bool {
!self.exif.is_empty() || !self.gps.is_empty()
}
}
fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec<Field> {
ifd.entries
.iter()
.filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD))
.filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() }))
.take(MAX_FIELDS)
.collect()
}
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
let at = f.tag_uint(ifd, tag)?;
(at != ifd.at).then(|| f.ifd(at)).flatten()
}
/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its
/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories.
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
let f = File::parse(b)?;
if f.big {
return None;
}
let ifd0 = f.ifd(f.first_ifd)?;
let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default();
Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) })
}
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
fn element(ty: u16) -> usize {
match ty {
3 | 8 => 2,
4 | 5 | 9 | 10 | 11 | 13 => 4,
12 | 16 | 17 | 18 => 8,
_ => 1,
}
}
/// `value` of type `ty` in byte order `to_le`, from `from_le`.
pub(crate) fn reorder(ty: u16, value: &[u8], from_le: bool, to_le: bool) -> Vec<u8> {
let n = element(ty);
if from_le == to_le || n == 1 {
return value.to_vec();
}
value.chunks(n).flat_map(|c| c.iter().rev().copied()).collect()
}
/// An EXIF block: a classic TIFF stream in `dirs.le` order whose IFD0 holds `dirs.ifd0` and
/// points to the Exif and GPS directories.
pub(crate) fn block(dirs: &ExifDirs) -> Vec<u8> {
let le = dirs.le;
let u16b = |v: u16| if le { v.to_le_bytes() } else { v.to_be_bytes() };
let u32b = |v: u32| if le { v.to_le_bytes() } else { v.to_be_bytes() };
let mut ifd0: Vec<Field> = dirs.ifd0.clone();
// Placeholders for the pointers; their offsets are known once the layout is.
let pointer = |tag: u16| Field { tag, ty: 4, count: 1, value: vec![0; 4] };
if !dirs.exif.is_empty() {
ifd0.push(pointer(TAG_EXIF_IFD));
}
if !dirs.gps.is_empty() {
ifd0.push(pointer(TAG_GPS_IFD));
}
// Layout: header, then each directory followed by its out-of-line values.
let size = |fs: &[Field]| 2 + 12 * fs.len() + 4 + fs.iter().filter(|f| f.value.len() > 4).map(|f| f.value.len().next_multiple_of(2)).sum::<usize>();
let at0 = 8;
let at_exif = at0 + size(&ifd0);
let at_gps = at_exif + if dirs.exif.is_empty() { 0 } else { size(&dirs.exif) };
for f in &mut ifd0 {
match f.tag {
TAG_EXIF_IFD => f.value = u32b(at_exif as u32).to_vec(),
TAG_GPS_IFD => f.value = u32b(at_gps as u32).to_vec(),
_ => {}
}
}
let mut out = Vec::new();
out.extend_from_slice(if le { b"II" } else { b"MM" });
out.extend_from_slice(&u16b(42));
out.extend_from_slice(&u32b(at0 as u32));
let write_dir = |out: &mut Vec<u8>, fs: &[Field]| {
let mut fs = fs.to_vec();
fs.sort_by_key(|f| f.tag);
let start = out.len();
let mut extra = start + 2 + 12 * fs.len() + 4;
let mut values = Vec::new();
out.extend_from_slice(&u16b(fs.len() as u16));
for f in &fs {
out.extend_from_slice(&u16b(f.tag));
out.extend_from_slice(&u16b(f.ty));
out.extend_from_slice(&u32b(f.count));
if f.value.len() <= 4 {
let mut inline = f.value.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(&u32b(extra as u32));
values.extend_from_slice(&f.value);
if f.value.len() % 2 == 1 {
values.push(0);
}
extra += f.value.len().next_multiple_of(2);
}
}
out.extend_from_slice(&u32b(0));
out.extend_from_slice(&values);
};
write_dir(&mut out, &ifd0);
if !dirs.exif.is_empty() {
write_dir(&mut out, &dirs.exif);
}
if !dirs.gps.is_empty() {
write_dir(&mut out, &dirs.gps);
}
out
}
/// Writes `fields` (in byte order `from_le`) into a directory of `enc` that is not part of the
/// page chain, and returns its offset for the pointer tag.
pub(crate) fn write_directory<W: std::io::Write + std::io::Seek, K: TiffKind>(
enc: &mut TiffEncoder<W, K>,
fields: &[Field],
from_le: bool,
) -> TiffResult<K::OffsetType> {
let mut sub = enc.extra_directory()?;
write_fields(&mut sub, fields, from_le)?;
Ok(sub.finish_with_offsets()?.offset)
}
/// Adds `fields` (in byte order `from_le`) to directory `d`, in the file's (native) byte order.
pub(crate) fn write_fields<W: std::io::Write + std::io::Seek, K: TiffKind>(
d: &mut DirectoryEncoder<'_, W, K>,
fields: &[Field],
from_le: bool,
) -> TiffResult<()> {
let mut dir = Directory::empty();
for f in fields {
let Some(ty) = Type::from_u16(f.ty) else { continue };
let entry = d.write_entry_bytes(ty, &reorder(f.ty, &f.value, from_le, cfg!(target_endian = "little")))?;
dir.extend([(Tag::from_u16_exhaustive(f.tag), entry)]);
}
d.extend_from(&dir);
Ok(())
}
+20 -2
View File
@@ -37,7 +37,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecErr
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
/// The `image` decoder for `f` with our limits, its header read and its size checked.
fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDecoder + 'a, CodecError> {
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
let mut il = image::Limits::default();
@@ -45,9 +46,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
il.max_image_height = Some(limits.max_height);
il.max_alloc = Some(limits.max_alloc);
reader.limits(il);
let mut dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let (w, h) = dec.dimensions();
limits.check_bytes(w, h, dec.color_type().bytes_per_pixel() as u64)?;
Ok(dec)
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let mut dec = open(f, bytes, limits)?;
let icc = dec.icc_profile().ok().flatten();
let exif = dec.exif_metadata().ok().flatten();
let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?;
@@ -60,6 +66,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
Ok(img)
}
/// The stored size and EXIF block [`decode`] would find, from the decoder's header alone: the
/// same reader, limits and size check, without decoding pixels. AVIF is refused: its decoder
/// decodes the image as it opens.
pub(crate) fn probe(f: Format, bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
if f == Format::Avif {
return Err(CodecError::unsupported(f, "no header-only size probe"));
}
let mut dec = open(f, bytes, limits)?;
let (w, h) = dec.dimensions();
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
/// The total is unknown when the blocks end before the trailer or break off.
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
+16 -1
View File
@@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
type Decoder<'a> = image_webp::WebPDecoder<Cursor<&'a [u8]>>;
/// The decoder with its chunks read, the stored size and the output layout, the size checked.
fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> {
let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?;
dec.set_memory_limit(limits.alloc_usize());
let (w, h) = dec.dimensions();
@@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
Ok((dec, w, h, layout))
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (mut dec, w, h, layout) = open(bytes, limits)?;
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = alloc_zeroed::<u8>(size)?;
dec.read_image(&mut buf).map_err(err)?;
@@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the
/// same header reads and size checks, without decoding the bitstream.
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
let (mut dec, w, h, _) = open(bytes, limits)?;
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
if !opts.webp_lossless {
+221
View File
@@ -0,0 +1,221 @@
//! X PixMap reader for XPM2 plain-text icons and XPM3 C-source icon arrays.
//! Supports transparency (None), RGB hex at 4/8/16 bits per channel and
//! common X11 colour names. Does not execute C code.
use std::collections::HashMap;
mod x11_colors;
use crate::SampleType;
use crate::error::CodecError;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
const MAX_SOURCE: usize = 256 << 20;
const MAX_COLOURS: usize = 65_536;
const MAX_CPP: usize = 8;
fn bad(msg: impl std::fmt::Display) -> CodecError {
CodecError::malformed(Format::Xpm, msg)
}
/// Extract C string literals without treating quoted text or // in a string as a comment.
/// XPM uses one literal for the header, each palette entry and each row.
fn c_strings(bytes: &[u8]) -> Result<Vec<Vec<u8>>, CodecError> {
let mut lines = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i..].starts_with(b"/*") {
let tail = &bytes[i + 2..];
let Some(end) = tail.windows(2).position(|p| p == b"*/") else { return Err(bad("unterminated C comment")) };
i += end + 4;
} else if bytes[i..].starts_with(b"//") {
i += bytes[i..].iter().position(|&c| c == b'\n').unwrap_or(bytes.len() - i);
} else if bytes[i] == b'"' {
i += 1;
let mut s = Vec::new();
let mut closed = false;
while i < bytes.len() {
let c = bytes[i];
i += 1;
match c {
b'"' => {
closed = true;
break;
}
b'\\' => {
let next = *bytes.get(i).ok_or_else(|| bad("truncated C escape"))?;
i += 1;
s.push(match next {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'0' => 0,
_ => next, // quoted backslash or quote
});
}
_ => s.push(c),
}
}
if !closed {
return Err(bad("unterminated XPM string"));
}
lines.push(s);
} else {
i += 1;
}
}
Ok(lines)
}
/// Extract XPM2's plain-text lines without trimming pixel rows: leading and
/// trailing spaces can be palette keys, and physical CRLF is not image data.
fn xpm2_lines(bytes: &[u8]) -> Result<Vec<Vec<u8>>, CodecError> {
let mut input = bytes.split(|&b| b == b'\n');
let header = input.next().ok_or_else(|| bad("missing XPM2 marker"))?;
if header.strip_suffix(b"\r").unwrap_or(header) != b"! XPM2" {
return Err(bad("not an XPM2 image"));
}
let mut lines = Vec::new();
for raw in input {
let line = raw.strip_suffix(b"\r").unwrap_or(raw);
// The header may be preceded by comments and blank lines. After the
// header, a leading '!' is allowed as a palette key or pixel value.
if lines.is_empty() && (line.is_empty() || line.starts_with(b"!")) {
continue;
}
lines.push(line.to_vec());
}
Ok(lines)
}
fn channel(hex: &str) -> Option<u8> {
let v = u32::from_str_radix(hex, 16).ok()?;
let max = (1u32 << (hex.len() * 4)) - 1;
Some(((v * 255 + max / 2) / max) as u8)
}
fn named(name: &str) -> Option<[u8; 3]> {
let s = name.to_ascii_lowercase().replace([' ', '-', '_'], "");
// XPM imports X11 names, whose RGB values differ from CSS for names
// such as gray, maroon and purple. Keep these exact rather than guessing.
if let Ok(index) = x11_colors::X11_COLORS.binary_search_by_key(&s.as_str(), |(name, _)| *name) {
return Some(x11_colors::X11_COLORS[index].1);
}
// Retain useful web color aliases not present in the X11 database.
Some(match s.as_str() {
"silver" => [192, 192, 192],
"olive" => [128, 128, 0],
"teal" => [0, 128, 128],
"fuchsia" => [255, 0, 255],
"navyblue" => [0, 0, 128],
_ => return None,
})
}
fn color(name: &str) -> Option<[u8; 4]> {
if name.eq_ignore_ascii_case("none") {
return Some([0, 0, 0, 0]);
}
let rgb = if let Some(hex) = name.strip_prefix('#') {
// UTF-8 byte lengths can match a supported hex width while slicing would panic.
// `from_str_radix` would also take a leading `+`: accept hex digits only.
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let digits = match hex.len() {
3 => 1,
6 => 2,
12 => 4,
_ => return None,
};
let rgb = (0..3).map(|i| channel(&hex[i * digits..(i + 1) * digits])).collect::<Option<Vec<_>>>()?;
[rgb[0], rgb[1], rgb[2]]
} else {
named(name)?
};
Some([rgb[0], rgb[1], rgb[2], 255])
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if bytes.len() > MAX_SOURCE {
return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into()));
}
let lines = if bytes.starts_with(b"/* XPM */") {
c_strings(bytes)?
} else if bytes.starts_with(b"! XPM2\n") || bytes.starts_with(b"! XPM2\r\n") {
xpm2_lines(bytes)?
} else {
return Err(bad("not an XPM2/XPM3 image"));
};
decode_lines(&lines, limits)
}
fn decode_lines(lines: &[Vec<u8>], limits: &Limits) -> Result<Image, CodecError> {
let header = lines.first().ok_or_else(|| bad("missing image header"))?;
let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?;
let mut parts = parts.split_ascii_whitespace();
let number = |key: &str, s: Option<&str>| -> Result<usize, CodecError> {
s.ok_or_else(|| bad(format!("missing {key}")))?.parse::<usize>().map_err(|_| bad(format!("invalid {key}")))
};
let width = number("width", parts.next())?;
let height = number("height", parts.next())?;
let colours = number("palette size", parts.next())?;
let cpp = number("chars per pixel", parts.next())?;
if width == 0 || height == 0 || colours == 0 || colours > MAX_COLOURS || !(1..=MAX_CPP).contains(&cpp) {
return Err(bad("invalid XPM dimensions, palette size or chars per pixel"));
}
let (w, h) = (u32::try_from(width).map_err(|_| bad("width overflow"))?, u32::try_from(height).map_err(|_| bad("height overflow"))?);
limits.check(w, h, ChannelLayout::Rgba, SampleType::U8)?;
let required = 1usize.checked_add(colours).and_then(|n| n.checked_add(height)).ok_or_else(|| bad("row count overflow"))?;
if lines.len() < required {
return Err(bad("truncated palette or image rows"));
}
let mut palette = HashMap::<Vec<u8>, [u8; 4]>::with_capacity(colours);
for line in &lines[1..1 + colours] {
if line.len() < cpp {
return Err(bad("truncated colour key"));
}
let fields = std::str::from_utf8(&line[cpp..]).map_err(|_| bad("invalid colour name"))?;
// XPM priority is colour, gray, four-level gray, then monochrome,
// regardless of the order of keys in the palette entry.
let terms: Vec<&str> = fields.split_ascii_whitespace().collect();
let mut picked: Option<(u8, String)> = None;
let mut i = 0;
while i < terms.len() {
let priority = match terms[i] {
"c" => 4,
"g" => 3,
"g4" => 2,
"m" => 1,
_ => 0, // Symbolic names ('s') are not colour values.
};
let start = i + 1;
i = start;
while i < terms.len() && !matches!(terms[i], "c" | "g" | "g4" | "m" | "s") {
i += 1;
}
if priority > 0 && i > start && picked.as_ref().is_none_or(|(current, _)| priority > *current) {
picked = Some((priority, terms[start..i].join(" ")));
}
}
let value = picked.as_ref().and_then(|(_, name)| color(name)).ok_or_else(|| bad("unsupported or missing XPM colour"))?;
if palette.insert(line[..cpp].to_vec(), value).is_some() {
return Err(bad("duplicate XPM palette key"));
}
}
let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?;
let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?];
for (y, line) in lines[1 + colours..required].iter().enumerate() {
if line.len() != row_len {
return Err(bad("XPM row has the wrong width"));
}
for x in 0..width {
let key = &line[x * cpp..(x + 1) * cpp];
let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?;
pixels[(y * width + x) * 4..(y * width + x + 1) * 4].copy_from_slice(rgba);
}
}
Image::from_u8(w, h, ChannelLayout::Rgba, pixels)
}
Loaded 100 of 679 files, more files were not shown because too many files have changed in this diff. Show more