Commit Graph

1563 Commits

Author SHA1 Message Date
Brandon Thomas 85e8c51b8a Tests: the MCP resources test also ignores Purge All's disabledReason
#3740 added disabledReason/needsDocument to the command list. For
edit.purge.all they flip with `enabled` while other tests in the binary
fill and purge the caches, so ignore them alongside `enabled`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:40:54 -04:00
Brandon Thomas 4d684b83e1 Merge remote-tracking branch 'origin/main' into fix-flaky-5 2026-10-12 07:38:10 -04:00
FinnNk 53d814f643
feat: Photoshop's layer and selection keys (Ctrl+, toggle, Ctrl+\, /, Ctrl+Alt+2, Ctrl+Alt+Backspace, Tab rename, Alt-click thumbnail) (#3710)
* feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock

Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once
they are all hidden, shows them again: its Layer menu item then reads
Show Layers. layer.hideLayers now does the same, so the key toggles: it
shows them only when every targeted layer is hidden.

The / key toggles Lock Transparent Pixels, as the Layers panel's first
lock button does (layer.toggleLockTransparency). With several layers
selected, any unlocked one means lock them all; the Background, which
can't take this lock, is left out. Each press is one undoable step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity

Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking
its thumbnail does, beside Ctrl+2 for the composite
(channel.target.layerMask; greyed when the layer has no mask). Its
Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection,
the Ctrl-click on the composite channel (select.loadLuminosity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(engine): Ctrl+Alt+Backspace fills from history

Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or
the layer, from the History Brush's source state. edit.fillHistory is
edit.fill with contents "history": the oldest state still held, unless
"state" names another. It joins the other fill keys, so it honours the
Channels panel target and appears with them in Keyboard Shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer

While a layer name is being edited, Tab commits it and starts renaming the
layer below, and Shift+Tab the layer above, as in Photoshop; the
Background, which isn't renamed in place, ends it. Alt-clicking a layer's
thumbnail selects the layer and fits its content on screen (View › Fit
Layer(s) on Screen).

Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace
through the shortcut dispatcher. docs/ui-parity.md lists the new keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): an explicit channel target drives the Layers panel's mask target

After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back
to the composite, but the Layers panel kept the mask targeted (its own
flag, which only ever followed the engine towards the mask), so painting
still went to the mask. The same happened after clicking the mask
thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets
the panel's mask and vector-mask targets from the engine's target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use Document::is_background after #4036 removed extra_cmds::is_background

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 07:32:23 -04:00
Fabio Lussi 3dd5fc1cdc
automation: say why a command is disabled in engine.commands (#3740)
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.

Fixes #3664

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:57 -04:00
Valentin Klein 6751a46003
Codecs: integer fast paths for Image::convert (#3717) (#3729)
U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:45 -04:00
Fabio Lussi 89312de597
Answer a repeat auth with "already authenticated" (#3723)
A second `auth` frame on an authenticated TCP connection fell through to
the method dispatcher and came back as an unknown method, in both the
headless serve server and the desktop control server. A shared
`security::repeat_auth_reply` now answers it with `already authenticated`
(keeping the request id) and the connection keeps serving.

Fixes #3668

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:32 -04:00
Nicolas Falesy 0c2ca488ae
fix(text): scale faux bold dilation with glyph scaling (#3705) (#3719)
When drawing faux-bold glyphs, the offset dilation was composed as
`Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o`
first (`self ∘ o`), the translation offset was added after the glyph's
scaling transform, leaving faux bold dilation unscaled by horizontal
and vertical font scaling.

Applying the glyph transform to the offset transform (`glyph.mul(&offset)`)
ensures dilation is performed in font-local coordinates and scaled
proportionally with anisotropic glyph scaling, matching Photoshop.
2026-10-12 07:13:06 -04:00
Nigel Ihza Firdausy e54adc250c
feat(codecs): decode legacy PCX images and palettes (#3692) (#3701)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:55 -04:00
Streetblock 38236f90c2
perf(compose): allow empty vector masks in preview proxies (#3716) 2026-10-12 07:12:47 -04:00
Streetblock e156a9ed85
perf(compose): select Normal RGB kernel once per buffer (#3715) 2026-10-12 07:12:34 -04:00
Valentin Klein 476f983876
Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:13 -04:00
Valentin Klein 7893a6e04d
PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:00 -04:00
Valentin Klein 21f1012ce2
Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706)
A tiny file declaring a huge image reserved the full output buffer before any
data was checked. Add Limits::check_plausible (compression-ratio guard) and a
fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default
max_alloc to 1 GiB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:11:41 -04:00
Brandon Thomas b564f2ca9c
fix(layers): identify the Background by structure, never by its English name (#4036)
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
  in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
  "Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
  no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
  lock the Background's position like every other place that creates one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:04:33 -04:00
Brandon Thomas 6547ca0b84
fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031)
- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
  spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
  EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
  they match the channels), except the ones that only describe layers, plus the document-level
  tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
  layered writer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:02:10 -04:00
Nigel Ihza Firdausy 4705fc5980
feat(io): open Krita .kra composites as editable raster documents (#3692) (#3700)
* feat(io): open Krita .kra composites as editable raster documents (#3692)

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:01:20 -04:00
Scott Macdonald 0dbc288667
fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias (#3672) (#3678)
* fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias

warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and
Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification
filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size
in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render
is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly
this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent.

Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices.
Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its
source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8).
Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or
enlarge the source still take the exact single sample, so identity and unscaled warps are
bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead
of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh
warp's scale varies across the surface: one reduction factor over-blurs the parts that stay
large or under-filters the parts that shrink most.

Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop
exports of real mockups (RMSE of the placed design): 1.78% -> 0.81%
(White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged.

Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at
1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid
black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period.

Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7):
  1/3 size: 55 ms -> 87 ms
  1/1 size: 240 ms -> 239 ms (no subsampling: identical path)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Use as_chunks for the opaque-pixel scan (clippy 1.99)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:49:25 -04:00
kayrockscreenprinting e4298f554b
Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) (#3696)
* Free Transform: layer styles render with the transformed layer (#3653), work in progress

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)

The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).

A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.

The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.

Still plain: the selected part of a styled layer, and Warp mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format the type warp texel read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Free Transform: Content-Aware Scale's preview has no styled composite

Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 06:37:15 -04:00
Arthur Wagner 304720b1a0
Gradient Map: edit color stops on the ramp (#3982)
* Gradient Map: edit color stops on the ramp (#2958)

* Gradient Map: keep stop removal scoped to the editor

* Gradient Map: fit selected stop controls in narrow Properties panels

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:30:14 -04:00
Scott Macdonald 590bec7998
feat(io): import Photoshop Displace smart filters with their embedded map (#3680)
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.

Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).

The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.

Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:55 -04:00
Scott Macdonald 5ed758763d
PSD: smart object Trnf frames the custom warp's control points, not the source rect (#3677)
A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp
mesh) can have control points outside its source rect 0..w x 0..h. Photoshop
maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft
mapped the source rect onto it, so a mesh that spills past the source counted
the overhang twice and the re-rendered smart object came out stretched.

Measured against Photoshop's own export of a real shirt mockup (4200 x 4800
source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch,
the tile offset growing from 0 to -16 px top to bottom; after, alignment is
exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of
the evaluated warp outline (rather than the control points) fixed that file
but put another at 5.4% RMSE, so it is the control-point box.

- blocks::placed_frame: the shared rule. The control-point box of a
  single-patch custom warp, else the source rect; a degenerate or non-finite
  box falls back to the source rect.
- blocks::parse_smart (and so smart_map::parse_sold): the affine maps that
  frame onto Trnf.
- smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of
  the same frame, so a spilled mesh round-trips and quad_moved keeps the
  imported Trnf as stored. Distort / Perspective placements keep the source
  rect, matching parse_smart_perspective.

Refs #579.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:38 -04:00
Scott Macdonald 2cb396d154
fix(engine): Displace smart filter ignores its mapPath displacement map (#3676)
A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.

apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.

Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:33 -04:00
Valentin Klein 64a02107ba
PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:18 -04:00
Valentin Klein 16341720c0
PSD: bound the zero padding of undersized pattern channels (#3652) (#3686)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:14 -04:00
Valentin Klein 7c77143b97
PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:09 -04:00
Valentin Klein 04396dd85f
PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:27:57 -04:00
Brandon Thomas 27f56eb762
feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038)
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:

- Invert and Posterize act on L, a and b alike (normalised L/100,
  (a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
  a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).

Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:20:44 -04:00
zenjabba ce16e100b0
Expose WebP and optional Batch export quality (#3956)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 06:20:16 -04:00
McClabe be877c27d0
fix(ui): keep filter jobs polling on idle Linux (#3961) 2026-10-12 06:20:00 -04:00
Nigel Ihza Firdausy 6ca7e60cb0
fix(ui): make checkbox labels nonselectable with pointer cursors (#4002) 2026-10-12 06:19:44 -04:00
Nigel Ihza Firdausy 73520f8681
fix(ui): Open layer color choices from the visibility icon (#4001)
* fix(ui): offer label colors from the layer eye icon

* fix(ui): offer label colors from the layer eye icon

* test(ui): cover layer eye color context menu

* style(ui): keep extracted color menu indentation consistent
2026-10-12 06:19:28 -04:00
MarekSpodBiedry db509dc4d9
fix(ui): show a paint-bucket cursor for the Paint Bucket tool (#3968) (#3989) 2026-10-12 06:19:13 -04:00
Vikram Bhaskaran ad3417dc05
Dismiss Home when the document count changes. (#3984)
Chrome stored the document count when Home opened so File New could leave the overlay. Closing a tab that restored that count re-opened Home and hid remaining tabs (#3981). Clear the stored count as soon as it no longer matches, then keep Home dismissed until the user opens it again.
2026-10-12 06:18:58 -04:00
Brandon Thomas 0d6e658e73
PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034)
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen

Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.

- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
  (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
  command made it one; a vector mask (vmsk/vsms with its vogk) stays.

Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:11:33 -04:00
Brandon Thomas 6f1c4481bf
fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info) (#4041)
- TySh: the four values after the warp descriptor are big-endian f32, not
  i32. Written as Photoshop writes them: zeros for unwarped type, the
  text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
  text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
  read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
  3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
  no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
  no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
  write.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:06:46 -04:00
Tarek Mohamed 5bc66094f8
fix(ui): allow selecting shapes and editing color with path selection (#3915) (#3932)
* fix(ui): allow selecting shapes and editing color with path selection (#3915)

- In vector_ui.rs, implement pick_shape_at to hit-test visible shape layers by path, surface pixels, or live shape geometry.
- In path_selection_finish and canvas ToolEvent::Down, select the clicked shape layer under the pointer when using Path Selection.
- In direct_select.rs, include visible shape layers in candidate hit tests and sync layer.select on press.
- In options_bar, display Fill swatch/checkbox, Stroke swatch/width/style, and corner radius when a shape is selected with Path Selection or Direct Selection, and dispatch shape.edit.
- Add tests in path_selection_tests.rs and vector_ui.rs verifying shape selection on click and options bar editing.

Fixes storytold/photocraft#3915

* Collapse nested ifs for clippy (collapsible_if)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:05:15 -04:00
Scott C Hughes 24bd7533fc
feat(ui): the generic filter dialogs get a Photoshop-style preview window (#3769) (#3877)
Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls.
2026-10-12 06:05:02 -04:00
Fabian Ghenof 83f4452bf2
Layer › Export As exports every selected layer or artboard (#3629) (#3909)
With several layers selected, Export As was greyed: its dialog exported only
the active layer. Someone with 20+ artboards (ads, Steam store capsules) had
to export them one at a time.

Export As now opens the same dialog for the selection ("Export As: N
layers", the preview showing the first) and, on Export, asks for one
destination and writes one file per layer with the dialog's format, quality,
transparency and scale, each at its own size. Files are named as Quick
Export as PNG names a selection's: the chosen path, then numbered siblings
named after each layer, now in the chosen format. The layers are captured
when Export is pressed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:04:35 -04:00
Scott C Hughes 19e51cf0ad
refactor(ui): unify plain text inputs on one text_field widget (#3789) (#3896)
Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm
2026-10-12 06:04:23 -04:00
coolvitto c0fa2269d2
Update Japanese translations (#3892)
Improved readability and several translation corrections.
2026-10-12 05:57:45 -04:00
Brandon Thomas 32552a61a1
fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047)
Liquify dabs were spaced a fifth of the brush radius apart and restarted
at every pointer event, so a fast mouse (few far-apart events) and a slow
one (many close ones) warped the same path differently, and a slow drag
piled up one dab per event. Dabs now fall every twentieth of the radius
by distance travelled along the whole stroke, carried from one segment
to the next, as in Photoshop: any sampling of a path gives the same
field. The stationary tools' per-dab strength is scaled to the new
spacing so their effect per distance travelled is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:56:09 -04:00
Brandon Thomas f86517a029
fix(psd): write descriptor ids warp, list and time as length-4 strings (#4039)
A descriptor class or key id is a u32 length and bytes, where length 0
means a 4-byte code follows. Photoshop stores `warp`, `list` and `time`
as length-4 strings and treats the code form as a different key, so the
warps of new smart objects and type layers and regenerated layer comp
lists were not recognised. In the corpus, `warp` appears 741 times as a
string and never as a code, `list` 5/0, `time` 7/0.

`Id::new` now makes these three names string ids, as keys and class ids.
Reading is byte-based and accepts both forms; a code read from a file is
written back as a code.

Tests: exact bytes for each name as class and key, both forms read,
fresh placed-layer warp (SoLd/PlLd) and layer comp list bytes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:50:36 -04:00
Brandon Thomas 62346f7fb6
feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399) (#4032)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:45:50 -04:00
Brandon Thomas dd708f4ce4
feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip (#4033)
Photoshop writes a Gradient Map's colour stops in the document's own colour
space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such
layers imported as unsupported and did nothing.

- Decode every plain PSD colour space (RGB, HSB, CMYK, Lab, Grayscale) in
  `grdm` stops; CMYK converts through the document's own CMYK profile
  (photocraft-cms), Lab through D50 Lab. Book colours and unknown ids stay
  unsupported (kept verbatim).
- Saving keeps the file's own `grdm` block, so stops keep their colour space;
  toggling Reverse or Dither patches just those flags in place.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:43:27 -04:00
Reynold Hu e79d6571ba
Actions: replay tests for crop, filters, selections and styled text on a second document (AUTO-219-4) (#3566)
* test(engine): a recorded edit run replays on a second document

* docs(scorecard): mark AUTO-219-4 done; the replay tests cover all four families
2026-10-12 05:36:39 -04:00
Scott C Hughes a1a7b17ee0
fix(engine): a PSD type layer names the installed font, not an invented one (#3556) (#3564)
* fix(engine): a PSD type layer names the installed font, not an invented one (#3556)

A type layer created with a font by family name stored no PostScript name, so PSD export
invented one ("<Family>-Regular", spaces removed). Photoshop looks type up by PostScript name,
so it showed the font as missing and substituted Myriad Pro as soon as the text was edited.

Resolve the family's installed face (matching weight/italic) from the font database when the
family, style, weight or italic is set, and store its real PostScript name; an explicit
postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls
back as before.

Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects
the resolved name.

* Type tool test: a family change names the installed face (#3556)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:32:03 -04:00
n-delic 79cfa2b65a
Guides: Shift-drag out of a ruler lands on the nearest ruler tick (#3770) (#3796)
As in Photoshop, holding Shift while dragging a new guide out of a ruler
snaps it to the tick marks the ruler draws (tenths of the labelled step),
in the current ruler unit and zoom. Without Shift the guide snaps to the
document, layers, selection and grid as before.

The tick spacing draw_rulers computed inline moves to ruler_scale, shared
by the renderer and the new snap_to_tick, so the snap always matches the
ticks on screen.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 05:22:58 -04:00
Jakub 6d03075ed7
perf: draw marching ants as one mesh instead of a line shape per dash (#3846)
Every ants segment was a white line shape plus a black line shape per 4 px dash, and egui
tessellates each. A selection with many edges seen whole (a speckled selection at 30% zoom:
94,666 segments) cost 45.6 ms per idle frame, ten frames a second while the selection shows.

Horizontal and vertical segments now go into one egui::Mesh of 1 px colored quads, with the
same pixel-centre rounding, dash length and phase; the white base runs half a pixel past both
ends so corners join as the line caps did. Slanted segments (Content-Aware Move's transformed
outline) stay line shapes, which egui antialiases. A textured, repeating dash isn't possible:
egui-wgpu clamps texture coordinates.

Speckled selection, idle frame: 45.6 -> 12.0 ms at 30% zoom, 6.6 -> 4.5 ms at 125%
(RTX 3090, Vulkan; an idle frame with no selection is about 3 ms). Screenshots at 100% and
800% zoom are pixel-identical to the line shapes.
2026-10-12 05:14:41 -04:00
Sir Starch 5bdb12ec51
fix(canvas): keep transform preview visible while panning (#3610) (#3843) 2026-10-12 05:14:27 -04:00
Scott C Hughes ae0e5eb731
feat(ui): drag to reorder swatch groups and preset folders (#3658) (#3828)
Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too.

Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
2026-10-12 05:14:17 -04:00