Commit Graph
413 Commits
Author SHA1 Message Date
fb924dad16 Shapes: a hairline vector stroke no longer darkens the shape's edge pixels (#397)
* Shapes: a hairline vector stroke no longer darkens the shape's edge pixels

A stroked shape with layer effects (or clipped layers) is drawn as our
fill plus a vector stroke fitted to Photoshop's own pixels: where those
pixels reach past our fill, the stroke takes the alpha the fill doesn't
explain, `(pixels - fill) / (1 - fill)`. It kept our stroke's colour for
that alpha. When Photoshop's fill covers more of an edge pixel than ours
(shapes snapped to whole pixels with Align Edges, our path a fraction off
the grid), the fitted alpha is mostly Photoshop's fill, so a 0.25 px grey
border on a white card was painted as a solid grey line: the edge came out
at the stroke's full colour (122) instead of Photoshop's 220.

The fitted stroke now takes the colour that, drawn Normal over our fill,
gives back Photoshop's pixel (un-compositing the premultiplied colour),
and keeps our colour only where the fitted alpha is negligible. Where
Photoshop's pixels agree with our fill this is our stroke's colour, as
before.

Test: hairline_vector_stroke_keeps_photoshops_edge_pixel_colour (edge
pixel 0.478 before, 0.86 after). The compose, gpu, io and engine tests
and the psd/io/engine corpus tests pass.

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

* Shapes: neutral wording in the hairline-stroke fix; test it at 8, 16 and 32 bits

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

---------

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-07 13:50:09 -07:00
a1f4387e63 fix(codecs): correct TIFF polarity and BigTIFF orientation (#746)
Use the TIFF decoder's normalized WhiteIsZero samples without a second inversion, and parse BigTIFF IFD0 Orientation so decode applies the recorded rotation.

Fixes #691

Fixes #693

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:50:04 -07:00
702b4aa6b0 Fix ui.set brush size command journaling (#744)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:59 -07:00
Nigel Ihza FirdausyandTanish Manku ac57c17b14 Clamp Variables data-set indices from control input (#772)
* fix(ui): bound Variables data-set index from control input (#756)

* test(ui): cover hostile Variables data-set indices (#756)

* style: format regression coverage for #756

---------

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-07 13:49:54 -07:00
151afe47b6 fix(automation): downscale bridge screenshots before PNG budget (#753)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:50 -07:00
3ad26e3b51 Fix document run and ID overflow (#684, #685) (#749)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:46 -07:00
Tanish MankuandClaude Opus 5.5 f46c462129 rustfmt crates/codecs/tests/deep_exr.rs (Format red on main since #642) (#804)
Formatting only: cargo fmt --check (rustfmt 1.99, as CI) fails on main at
crates/codecs/tests/deep_exr.rs:360 since #642 landed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:47:55 -07:00
b70bf524ea Filter › Render › Relight: redirect a photo's lighting (#735)
* Add Filter › Relight to redirect a photograph's lighting

Introduce a tiled Retinex and Lambertian kernel with angle, elevation,
intensity, ambient, warmth and softness. Wire it as filter.render.relight
with a live-preview dialog, selection support, and smart-filter recording.

* Tidy Relight kernel loops

Rewrite the per-channel colour multiply and alpha restore so the filter
builds cleanly under clippy.

* Format the Relight kernel and enablement check

Keep rustfmt's wrapping on the alpha restore and collapse the lock
predicate to a single expression.

* Step Relight shading gradients at the blur scale

A one-pixel difference vanishes on large documents, so the lamp
collapsed to a flat multiply. Sample S at ±σ and keep a 256 px
sphere direction check so that does not regress.

* i18n: German entry for Filter › Render › Relight

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

* i18n: Brazilian Portuguese entry for Filter › Render › Relight

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-07 16:45:57 -04:00
6c7d01a0f3 Selections: drag to move the outline; ⌘-drag floats the selected pixels (engine commands) (#686)
Engine (scripts, the control channel and agents can drive it):
- `select.float {"dx","dy"}`: cuts the selected pixels of the active layer
  into a floating piece the first time, then moves it by whole pixels.
  The document is unchanged while it floats (`DocState::floating`);
  `float_cmds::displayed` gives the document with the piece at its offset.
- `select.drop`: drops the piece into its layer and moves the selection
  with it, one history step ("Move Selected Pixels").
- Any other command drops a floating piece first (a hook at the top of
  dispatch, so it lands where it was shown); Undo puts it back instead.
- The layer is split once, only inside the selection, and the piece is
  cropped to it: placing it costs the piece, not the layer (6000×4000
  layer, 500×400 piece: 18 ms to cut, under 5 ms per move).

Canvas (Marquee / Lasso):
- A drag inside the ants moves the outline (`select.transformSelection`);
  a click inside without moving deselects; ⇧ / ⌥ drags still draw.
- ⌘-drag floats the selected pixels and shows them at the pointer live;
  plain drags on the piece move it again; a press elsewhere drops it.
- A move cursor over the ants; the ants follow the dragged / floating piece.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:45:31 -04:00
107ffe9127 codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured (#642)
* codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured

Deep EXR files errored with "not supported: deep data not supported yet"
(the `exr` crate rejects deep headers, and its chunk framing for them is an
unfinished guess), so the chunk stream is walked directly from the bytes, per
the OpenEXR file-layout specification:

- offset tables after the headers; deep chunks of [y | tile coords, packed
  table size, packed data size, unpacked data size, compressed cumulative
  sample-count table, compressed sample data],
- NONE, RLE and ZIPS (zlib, the +128 difference predictor and the half-split
  interleave - applied to the count table and the sample data alike; a stored
  payload of uncompressed length is raw). ZIP-16 and mip/rip-map deep tiles
  decline with `unsupported`,
- samples are channel-grouped, per pixel; u32 counts cap the allocation and
  go through `Limits` before anything is held in memory.

`decode` composites to the flat image (samples sorted by Z, front-to-back
`over` with premultiplied colour, un-premultiplied to straight alpha) and
reports a new DecodeWarning::DeepFlattened; `decode_deep_exr` returns the
structured DeepImage (per-channel samples plus cumulative counts) for callers
that composite deep data themselves. exr::Error::NotSupported now maps to
CodecError::unsupported instead of malformed, like the TIFF codec.

Validated against the OpenEXR repository's deep test images (11/42/64/objectid
.deep.exr: 256x256, up to 8 samples/pixel, plausible z and alpha), and covered
by a synthetic generator (NONE/RLE/ZIPS x scanline/tile) with hand-computed
compositing oracles, truncation sweeps and hostile offsets/sizes (13 tests).

* Deep EXR: match the OpenEXR layout (RLE tokens and predictor, per-row tile tables and data), keep HDR

Checked against deep files written by the OpenEXR 3.5 library (scan line and tiled,
NONE/RLE/ZIPS): RLE tokens were inverted and skipped the predictor and byte split, the
count table of a tile restarts at every row, a tile's sample data is grouped per row, edge
tiles may store a full-size table, and the flattened colour was clamped to 1.0. The
synthetic writer in the tests now follows the same layout; new HDR / edge-tile test.

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-07 16:45:02 -04:00
3f637410cb Apply the Interface UI Font Size preference (#616)
* Apply the Interface UI Font Size preference

* Regenerate the scorecard after rebase (58 unread settings); drop the before/after screenshots

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-07 16:43:03 -04:00
yangandClaude Opus 5.5 fee9ff9613 Find and Replace Text: no panic searching a non-type active layer (#703) (#762)
With allLayers: false the search keeps only the active layer; when that
layer isn't a type layer the list is empty, and the find loop indexed
layers[0] on it (find, change and changeFind). Read the layer with
get() and stop when there is none, so the command finds nothing and
returns {"found": null}, as it does when nothing matches.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:42:29 -04:00
Tanish MankuandClaude Opus 5.5 6103da5278 rustfmt xtask/src/i18n_coverage.rs (Format red on main since #663 + #700) (#798)
#663 and #700 each added one entry to the complete-catalog list in
source_key_set_is_covered_by_complete_catalogs. Each line passed rustfmt on
its own branch; together they exceed the width limit, so cargo fmt --check
fails on main and on every PR updated to main. Formatting only.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:39:39 -07:00
Jamie Pates 33c58b5ca0 Prevent "Export As" from saving webp files as "*.webp.psd" (#724) 2026-10-07 16:34:38 -04:00
9d46c8ea80 Arbitrary rotation starts at the ruler line's straightening angle (#660)
* fix(image): Arbitrary rotation starts at the ruler's straightening angle

Fixes #602

* Arbitrary rotation: ignore a non-finite ruler angle (from #659)

Co-Authored-By: Alex Gorevski <alex.gorevski@live.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Alex Gorevski <alex.gorevski@live.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:33:52 -04:00
yangandClaude Opus 5.5 1e1f78325c type.edit: a reversed runs range is a bad-params error, not a panic (#714) (#754)
A run with end before start reached style_range with a > b, and the
styled run's length (b - a) underflowed: a panic in debug builds (the
dispatch guard turned it into a generic internal error), a huge stored
run length in release.

type.edit now checks every run before changing anything and rejects
end < start with a BadParams error naming the run, so the layer is left
untouched and no history step is recorded. style_range also treats a
reversed range as empty, so no other caller can underflow it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:33:23 -04:00
35ceaefce2 Fix #695: validate Layer Style effect fields (#729)
* Fix #695: validate Layer Style effect fields

Reject non-object effect parameter fields before preview or confirmation, render a visible validation message, and keep the canvas on the original document when preview input is invalid.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(ui): keep global light preview fields compatible

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Layer Style: show an invalid-parameters error once

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>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:33:11 -04:00
135ae8cf96 Image Size keeps the canvas border opaque instead of fading it (#646)
Resampling read the pixels beyond a layer's content as the default pixel
(transparent for layers, white for masks). For content that reaches the
canvas edge, such as a Background or any full-canvas layer, the filter
taps past the border mixed that in, so after Image Size the outermost
rows and columns were translucent: a white Background enlarged 200 %
came out with alpha 162/255 in the corners and 203/255 along the edges.
Export As at any scale other than 100 %, and Generate Image Assets at
200 %, run Image Size first, so every such export had a translucent
frame. Channels, the quick mask and the selection faded the same way.

Image Size now resamples layers, masks, channels and the selection with
resize_surface_in_canvas: taps past the canvas edge (or past a layer's
own pixels where they extend beyond the canvas) read the nearest edge
sample. Inside the canvas nothing changes, so a layer away from the edge
keeps its soft border. resize_surface itself, used by transforms and
placing, is unchanged.

Tests: canvas_edges_repeat_instead_of_fading (algo: every filter, up and
down, 8, 16 and 32 bits; inner layers unchanged) and
image_size_keeps_canvas_edges_opaque (engine; fails before: corner alpha
0.635), which also checks a full selection stays full to the edge.

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:33:04 -04:00
06ec4044ea Improve Korean terminology and dynamic UI translation coverage (#604)
* Improve Korean Photoshop terminology and dynamic UI translations

* Keep untranslated form labels in sentence case; add the new strings to de and pt-br

label_of fell back to the Title Case filter label whenever the sentence-case label had no
translation, which changed English form labels ("Use antialias" -> "Use Antialias"). It now
uses the Title Case translation only when one exists. German and Brazilian Portuguese (merged
since this branch was opened) get "No patterns" and "Enable {name}".

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

---------

Co-authored-by: kijoong <gijoong.gim@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:32:47 -04:00
ac5338360f JPEG export no longer fails when the document's XMP is over 64 KB (#645)
A JPEG holds XMP in one APP1 segment, at most 65,533 bytes. Documents
from the source application often carry far more: their XMP lists every
document ever placed in them (DocumentAncestors, thousands of ids) and
the text of every type layer (TextLayers). The encoder passed the packet
through whole, the segment was refused, and the export failed with "App
segment exceeds maximum allowed data length of 65533". That stopped
every JPEG path: Export As, Save a Copy, convert, Layers and Artboards to
Files, Batch and Image Processor. 3 of 16 real designer documents in a
sweep hit it.

When the packet doesn't fit, the encoder now leaves out those two
layered-document properties (any namespace prefix), which a flat image
doesn't need; everything else in the packet is kept. If it still
doesn't fit, the XMP is left out, and so is an EXIF block over 64 KB.
Both cases are reported ahead of time by a new fidelity warning,
MetadataTooLarge, so the export shows what was dropped. Formats without
a segment limit (PNG, TIFF, WebP) still keep the packet whole.

Test: jpeg_with_oversized_metadata_still_encodes (fails before: encode
error), at 8, 16 and 32 bits.

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:32:26 -04:00
Alex GorevskiandCopilot 7a45adc913 Fix Hue/Saturation range drag bounds (#727)
Canonicalize imported bounds before rendering or moving range handles. Keep drag constraints ordered and cover both non-canonical and ordinary ranges.

Fixes #696

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:32:22 -04:00
12460d0345 Fix multi-file open dialog (#595) (#657)
* Fix multi-file open dialog (#595)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Multi-file open test: write its fixtures under the temp dir

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>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:31:57 -04:00
9a448fe23b Type: a forced line break (U+0003) breaks the line instead of drawing a box (#644)
PSD type stores a line break inside a paragraph (Shift+Return) as U+0003,
and the layer text keeps it as is. The layout passed it to the line
breaker as an ordinary character: it isn't a break opportunity there, so
the line ran on, and the font has no glyph for it, so a missing-glyph box
was drawn in its place. Nothing showed until the text was re-laid out,
because an opened layer draws its saved pixels; any edit of the text, an
Image Size or an export at another scale then turned a two-line headline
into one long line with a box in the middle.

The layout now hands U+0003 to the line breaker as a newline (same byte
length, so every text offset stays put): the line ends, the next one
starts with the paragraph's leading and no paragraph spacing, and no
glyph is drawn. The text itself is unchanged, so PSD save writes it back.

Test: forced_line_break_starts_a_line_in_the_same_paragraph (fails
before: one line), covering point, centred and box text and rendering at
8, 16 and 32 bits.

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:31:33 -04:00
Alex GorevskiandCopilot 2050ca083e Fix #570: connect brush strokes on Shift-click (#654)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:31:15 -04:00
Armaan Sandhu 0aabb48687 document.pixel: no panic at i32::MAX, reject coordinates beyond i32 (#629)
Rect::from_xywh(i32::MAX, y, 1, 1) saturates to an empty rect, so the composite has no pixels and .px[0] panicked. Coordinates are also cast with as i32, so 2^32 silently read column 0. Read the coordinates with i32::try_from (BadParams when out of range) and return transparent for the empty rect, which is what any other off-canvas pixel reads as.
2026-10-07 16:30:52 -04:00
a62b6473f3 Transform modes: Skew / Distort / Perspective as real modes; legacy corners as a preference (#671)
- Edit › Transform › Skew, Distort and Perspective now set a mode on the box
  instead of just starting Free Transform: in Distort a corner moves alone
  with no keys held (Skew: edges skew; Perspective: corners move in mirrored
  pairs). While a box is up they switch its mode, as does a right-click on
  the canvas (Free Transform, Scale, Rotate, Skew, Distort, Perspective),
  with the current mode checked.
- Distort moves only corners (or the whole box from inside): dragging
  outside doesn't rotate, edge handles don't move, nothing snaps; the cursor
  shows when a drag would do nothing.
- Corner drags stay proportional by default (⇧ frees them). Preferences ›
  General › Use Legacy Free Transform (already a setting, hidden until now)
  swaps that: free by default, ⇧ keeps proportions.
- The handle under a press is hit-tested where the press was, before
  snapping, which could push it off a corner and move the whole box.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:28:17 -04:00
reese 2cdff3afb9 feat(ui): numeric fields evaluate typed arithmetic (#726)
Typing 1280*2 or 20*2+5-2 into a numeric field applies the result on
Enter, Tab or click-away (+ - * /, * and / first). Plain numbers still
apply as they're typed. Arithmetic is held until it's committed, so a
caller that rounds a partial result can't cut it short, and changed()
now means a new value rather than a keystroke.

The floating Properties card scopes its fields per layer, so text still
being typed for one layer can't commit to the next.

Fixes #601
2026-10-07 16:28:08 -04:00
Alex GorevskiandCopilot 681dee8c8f Fix type edit undo grouping (#652)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:21:49 -04:00
reese 6c4010cfc4 Color panel: click a chip to edit that color, double-click to open the Color Picker (#655) (#664)
* fix: Color panel chips pick the colour to edit and open the Color Picker on double-click

A click on the foreground or background chip makes the Color panel's field and hue
strip edit that colour; a double-click opens the Color Picker on it, with its RGB,
HSB, Lab, CMYK and hex fields. Each chip remembers its own hue for greys.

Fixes #655

* refactor(ui): keep the Color panel's edited chip in UiState

The chip the Color panel edits is UiState::color_panel.background, so the
control channel and MCP can read it and set it with
ui.set {colorPanel: {background}}. The double-click test now clicks where
the chips overlap, where the foreground chip takes the click.
2026-10-07 16:20:52 -04:00
9aef02cac3 macOS: menus open on press; only the title bar's free gap drags the window (#669)
Menus:
- Titles open their menu on the press and never toggle on the release (no
  blink), so one gesture can press a title, drag to an item and release on
  it to run it. A plain click still opens the menu and leaves it open;
  pressing an open title closes it.

Title bar:
- The window draws under a transparent title bar, so AppKit dragged it from
  anywhere in that strip, menus included. AppKit's own title-bar dragging is
  off (NSWindow.movable = NO via safe objc2 calls, no unsafe; objc2 crates
  pinned to the versions already in Cargo.lock), and the app's drag region
  is only the free gap between the menus and the right-hand controls.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:18:14 -04:00
782ea69f35 fix(linux): AppImage registers its desktop entry and icon on launch (#617)
* fix(linux): AppImage registers its desktop entry and icon on launch

On Wayland the compositor resolves the dock icon from the window's app_id
(ai.storyteller.photocraft, already set) against a host-installed .desktop
file. An AppImage installs nothing, so every run showed the generic Wayland
logo (#593). The app cannot set the icon itself: winit 0.30.13 (egui 0.36.2's
pin) has an empty set_window_icon on Wayland; xdg-toplevel-icon-v1 landed on
winit's 0.31 line only.

- packaging/linux/AppRun is a script instead of a symlink: best-effort
  integration into $XDG_DATA_HOME before launch (desktop entry with Exec
  rewritten to the AppImage's path and TryExec dropped, hicolor icons
  copied), content-compared so an unchanged install costs a few cmp calls,
  refreshed when the AppImage moves, PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1
  opts out, and no failure can block the launch (best-effort steps silent).
- packaging/linux/apprun-test.sh: 8-case smoke (passthrough, Exec rewrite
  with spaces, metadata preserved, idempotence, move refresh, opt-out,
  unwritable data dir, extracted AppDir). packaging-lint runs it and now
  shellchecks AppRun.
- Docs: releasing.md Linux section; README install paragraph.

Fixes #593

* AppRun: skip desktop integration when the AppImage path needs Exec escaping

A quote, backquote, dollar, backslash, percent or line break in the path would need
desktop-entry escaping (a line break could even add keys), so integration is skipped
and the app still launches.

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-07 16:12:00 -04:00
8d8ea584b8 Brazilian Portuguese UI translation (pt-br) (#700)
* Brazilian Portuguese UI translation (pt-br)

Add a pt-br catalog covering every key in the existing catalogs (2,492
entries), register it as "Português (Brasil)" with Portuguese plural
rules, and resolve pt, pt-BR and pt-PT locales to it. i18n coverage
1404/1404.

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

* Keyboard Shortcuts dialog: show menus and commands in the UI language

The command list showed the raw English menu paths and labels while the
rest of the dialog was translated. Translate them the way the menus do
(tr / tr_id); search matches the translated and the English names, like
the command palette. Also rustfmt the pt-br registry row.

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

* pt-br: gate it in xtask i18n-coverage (from #637, thanks @RicSchonfelder); licence note covers German too

Co-Authored-By: RicSchonfelder <RicSchonfelder@users.noreply.github.com>
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>
Co-authored-by: RicSchonfelder <RicSchonfelder@users.noreply.github.com>
2026-10-07 16:11:38 -04:00
Matt Van Horn 7f486d723f Add Layer Remove Background command (#734)
Isolates the subject of a pixel layer in one undoable step: a layer mask
by default, with optional transparency bake and a solid fill underneath.
The command is available from Properties Quick Actions, the Layer menu,
the CLI, and the control channel.
2026-10-07 16:09:46 -04:00
reese 3bea3f31c5 fix(transform): picking another tool applies an open transform (#732)
Fixes #670.
2026-10-07 16:08:39 -04:00
3a3984075a Surface Blur: 13x to 40x+ faster on 8-bit layers (sliding histogram) (#631)
* Surface Blur: sliding 8-bit histogram instead of the full window sum

Surface Blur summed the whole (2r+1)^2 window for every pixel and channel, so radius 100 read about 40,000 samples per pixel. For 8-bit layers (every colour sample k / 255) keep a 256-bin histogram per channel that slides along the row, one column out and one in per step, and weight only the levels within the threshold, with the same weight expression as the direct sum. 16-bit and float layers, and radii where the direct sum is cheaper, keep the direct sum (now surface_direct).

* Surface Blur test: as_chunks_mut (clippy)

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-07 16:06:23 -04:00
Armaan Sandhu 09b6572578 Minimum/Maximum (Squareness): running extremes, bit-identical (#630)
The square Minimum and Maximum scanned 2r+1 samples per pixel on each axis, so the cost grew with the radius (0.2..500). Preserve: Roundness already uses the van Herk / Gil-Werman running_extreme in the same file; use it for both passes of the square too. A min or max picks one of the same samples whatever the order, so the output is bit-identical; a new test checks it against the direct window.
2026-10-07 16:05:38 -04:00
43c86ef645 Layers panel: drag down the eyes to show or hide layers; empty eye when hidden (#666)
- The eye column takes drags: pressing an eye toggles that layer and every
  row the pointer then sweeps over gets the same visibility (each row once
  per drag). A drag that starts on an eye no longer reorders the layers.
- A hidden layer's eye box is left empty instead of drawing a struck-out
  eye; it is still clickable.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:03:21 -04:00
Armaan Sandhu 9270a31f24 Box Blur: running sums, so large radii cost the same as small ones (#628)
Box Blur ran a direct separable kernel of 2r+1 taps per pixel per axis, so its cost grew linearly with the radius (1..2000). Gaussian Blur already blurs with running-sum box passes (gaussian_boxes); generalise that into box_passes(widths) and use it for Box Blur radii above 4, keeping the direct kernel for radius 1..4 where it is quicker and output stays as before.
2026-10-07 16:00:29 -04:00
Alex GorevskiandCopilot 0c40ec17c6 fix(color): match Photoshop blend corner values (#699)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:00:27 -04:00
Ian Swope fe4243ba1f Text fields stop resetting the input method every frame, so keys no longer repeat endlessly on Wayland (#585) (#723)
The command palette and the channel rename field called request_focus on
every frame. egui interrupts IME composition on each request, so egui-winit
disabled and re-enabled zwp_text_input_v3 every frame. With an input method
such as fcitx5 the compositor regrabbed the keyboard each time, a key release
landing in the gap was lost, and winit's client-side repeat never stopped.

Both fields now request focus only when they do not have it, matching the
other rename fields. Tests assert the IME is not interrupted after the first
frame.
2026-10-07 15:59:51 -04:00
d5923e49db layer.showOnly: ⌥-click a layer's eye to show only that layer (#665)
New engine command `layer.showOnly {"layer"}`: hides every other layer
(keeping the layer's enclosing groups visible); run again on the same
layer, it restores every layer's previous visibility, layers that were
already hidden included. ⌥-clicking another eye while one layer is shown
alone moves the solo and keeps the original snapshot. The snapshot is
per-document view state (`DocState::show_only`), not history.

The Layers panel sends ⌥-clicks on an eye to it; plain clicks still toggle.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 15:59:29 -04:00
Elvis33LE fc00f1e5bc scorecard: BUG-203 (atomic saves) is done - flip the stale rows (#203) (#687)
The BUG-203-1..6 and REL-4 rows still say missing, but the atomic-save
work landed in #230 (8227071, 2026-10-05) - apparently after the 4b53f62
snapshot these statuses were verified against. Re-verified against the
current tree:

- one shared photocraft_format::atomic_write (same-dir temp, create_new +
  write_all + sync_all, rename, Unix directory fsync, Windows
  PermissionDenied rename retry with backoff), and every document write
  site routes through it (engine file_cmds/preset_store, automation
  files/headless/workspace, app services, format store/autosave);
- no in-place fallback exists; read-only dir, disk-full and cross-device
  errors name the cause and state the original was not changed;
- format/src/atomic/tests.rs failure-injects mid-write, rename and
  read-only-dir errors (11 tests; re-run for this change: green).

The regenerated docs/scorecard.md moves Reliability to 4/1/2 and Open
bugs to 12/3/17. No code changes.
2026-10-07 15:49:13 -04:00
Mawfyy 09f21d99ca fix(ui): the Enter that opens a dialog from the search palette no longer closes it (#640)
Selecting "Keyboard Shortcuts" in Edit > Search did nothing (#536): palette::show
and dialogs::show run in the same frame (lib.rs:959-960), so the dialog's OK footer
(dialogs.rs:183) read the very Enter that launched it as "confirm" and closed the
dialog before it was ever visible. Every palette-launched dialog hit this when
opened with Enter; the mouse path was unaffected.

The palette now consumes the Enter it acts on (input.consume_key, the same idiom
menu_nav already uses), so dialogs::show sees no press in that frame. A second Enter
still confirms and closes the dialog, which the regression test also asserts.

Test: palette_opens_the_keyboard_shortcuts_dialog drives the real UI harness
(search -> type -> Enter -> dialog open, with and without a document). Full ui-egui
suite 677 pass; clippy -D warnings; fmt; wasm32 check of ui-egui.

Fixes #536
2026-10-07 15:49:11 -04:00
e79dcb6f7f i18n: add German (de) UI translation (#309) (#663)
* i18n: add German (de) UI translation (#309)

A complete German catalog plus the registry row:

- de.tsv: the full key set (menus, tl! literals, blend-mode names, Camera
  Raw labels, generated preference labels), plural rule one|other, header
  documenting the format and the clean-room policy like ja.tsv.
- Registered with complete_menus: true; the locale resolver and the
  language-list tests gain de (de-DE/de-AT/de-CH resolve to it).
- ATTRIBUTION.md row and the docs/localization.md language table entry.

Translations are original work from the plain meaning of the English
labels in ordinary German image-editing vocabulary (Ebene, Maske,
Auswahl, Weichzeichnen, Tonwertkorrektur, Smart-Objekt ...); no other
product's localisation resources were consulted. Product and technology
names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab, Camera Raw) stay in
Latin letters; the Camera Raw curve bands are distinct (Schwarz, Tiefen,
Dunkle, Helle, Lichter, Weiss).

Verified: catalog tests and preference-label tests green,
cargo xtask i18n-coverage reports de 1404/1404 (100%), the full
ui-egui suite passes (except the known machine-local wgpu driver
failure that also fails on clean main), clippy and wasm32 clean, and
an offscreen snapshot sequence switches de -> en -> de in one process
and ends fully German.

* de: translate Metadata (Export As gains the label in the #647 fix)

* Revert "de: translate Metadata (Export As gains the label in the #647 fix)"

This reverts commit 948a8d01dd83579a899d938611101daa50a9e1e1.

* de: apply the native-speaker review (#663)

All meaning errors from the review: Auto Tone is the tonal range
(Automatische Tonwertkorrektur), the Divide blend mode is arithmetic
(Dividieren), Note Paper is Notizpapier, Legal is the US paper format,
Fuzziness widens the colour tolerance (Toleranz:), Perspective Warp
planes are Flaechen (not Ebenen, which reads as layers), Revert is
Zuruecksetzen (matching its confirm text), Fade is Verblassen.
Typos: Tonwerthistogramm, Magischer-Radiergummi-Werkzeug,
Einfrier-Lasso. Consistency: Kanalmixer everywhere, Zoom In/Out as
Vergrössern/Verkleinern, a space before % in the scale labels.

* xtask i18n-coverage: gate the German catalog too

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-07 15:48:02 -04:00
Alex GorevskiandCopilot e70deecbe5 fix(cms): check LUT declared sizes before arithmetic (#725)
Fixes #689

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 15:41:09 -04:00
才倾梦华V 9e6491e5f5 Keep Select Linked Layers within the linked groups (#764) 2026-10-07 15:41:01 -04:00
才倾梦华V 03b901cdde Persist migrated presets with preferences (#728)
* Persist migrated presets with preferences

* Check only imported entries after preset reload
2026-10-07 15:38:29 -04:00
Brandon ThomasandClaude Opus 5.5 d7df9d8aa6 Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#766)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:40:36 -04:00
47f9306fd0 Open HEIC/HEIF photos (#355) (#372)
* Open HEIC/HEIF photos (#355)

iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no
HEIF code, so .heic was missing from File > Open and failed to decode.

Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust,
forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and
grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha,
ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF
Orientation is rewritten to 1, so photos are never turned twice. Limits
are checked against the declared size before decoding. Read-only (no
pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS.

File > Open (desktop, web) and the batch list accept heic/heif/hif.
Tests compare against Apple's own decode and a 16-bit source, cover
orientation, metadata, limits and every truncation; fixtures from
heic-rs and pillow-heif are attributed in ATTRIBUTION.md.

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

* HEIF: pin heic-rs exactly and add a decode_heif fuzz target

Review follow-up: heic-rs is young and parses untrusted files, so pin it
(=0.1.1) and fuzz the HEIF path. The target covers both orientation
paths and is meant to run with -timeout so a hang is reported, not
only a panic. It is added to the CI fuzz matrix.

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

* HEIF: a heic-rs panic is a malformed-file error, not a crash

Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on
malformed files:
- in its box parser (boxes.rs:130), reachable from a 72-byte file, even
  through the metadata-only probe;
- during HEVC reconstruction (hevc/decode/recon.rs:70).

The background job path catches panics, but the synchronous ones (jobs
off, the CLI) did not. The HEIF codec now runs the decoder under
catch_unwind and reports a panic as CodecError::Malformed, as
gpu/health.rs already does around wgpu.

The fuzz target also calls heic-rs directly, so its panics still reach
the fuzzer. The 72-byte input is a regression test, which fails
without the guard.

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

* HEIF: move the decoder into an optional photocraft-heif crate

The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends
only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit,
ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded,
Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and
photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the
feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't
included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif
between standalone crates.

The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs
(tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against
xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with
`--features corpus,heif` (`cargo xtask test-corpus` enables both).

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

* Build CI and official binaries with HEIC support (--features heif)

CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and
web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so
the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with
`--features heif`.

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

* Docs: HEIC open is an optional build feature, on in official 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-07 10:20:40 -04:00
Brandon ThomasandClaude Opus 5.5 7055f0c51b README: remove a stray "ArtCraft" line before Star history (#636)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:12:51 -04:00