Commit Graph
1659 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 16dd4b19ad fix(layers): identify the Background by structure, never by its English name
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 05:57:11 -04:00
Brandon ThomasandClaude Opus 5.5 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 ThomasandClaude Opus 5.5 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 ThomasandClaude Opus 5.5 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 ThomasandClaude Opus 5.5 3d0c819d46 Release prep: 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:37 -04:00
Brandon ThomasandClaude Opus 5.5 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
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
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
bade115202 Fail CLI run when recorded action playback fails (#3861)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 05:14:54 -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
zenjabbaandzenjabba ad5fb53f68 Web: avoid shifting canvas during preset saves (#3805)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 05:14:07 -04:00
JGFandClaude Opus 5.5 a2cdc6c2d7 fix(ui): drag a document tab off the strip to float it in its own window (#3733) (#3785)
Dragging a document tab more than 24 pt above or below the tab strip now
tears it off into a separate OS window placed where it was dropped, so it
can be moved anywhere, other screens included (as in Photoshop). Sideways
drags still reorder. Both tab strips share the new tab_drag helper.

window.arrange.floatInWindow takes optional `document` and `position`
params (validated: bad values are errors, not panics); DocWindow gains a
serde-default `position`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:13:48 -04:00
Valentin KleinandClaude Opus 5.5 9f49cae093 Move: with an unlinked layer mask targeted, the Move tool moves the mask alone (#3604) (#3611)
layer.translate takes target=mask: a layer whose mask is unlinked then shifts that mask only
(the shell adds the target when the Layers panel targets the mask), live preview included.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:04:13 -04:00
xsvm 6d21a69968 Brush: symmetric live strokes update only what changed (#3593) (#3605)
With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored
pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an
event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and
composites only the coverage tiles that changed (new dabs, the smoothing tails now and before),
leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once.
2026-10-12 04:53:36 -04:00
4736a4bcbe Character panel: OpenType feature buttons (TYPE-218-5, #3752) (#3757)
The Character panel (and the Properties Character section) gains
Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual
Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling
Alternates, Ordinals and Fractions. Each shows pressed while its feature is
on at the selection (else the layer) and toggles it through the existing
type.openType.* command, on the same scope as the other Character controls
(the selected characters while typing, else every selected type layer).

Scorecard TYPE-218-5 stays partial: the Numerals and Stylistic Sets
submenus are still missing.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 04:52:23 -04:00
MrCloudy2andClaude Opus 5.5 8b21621671 Chisel bevels: Photoshop's facet slope and ramp ends (#3595)
Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel &
Emboss, light from the top at 30°, sampled pixel by pixel across its
edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100
and 300):

- A chisel facet's slope is depth × size / (2 (size + 1)): it rises to
  the same depth × size / 2 as a Smooth bevel, over size + 1 px.
  PhotoCraft gave it the full depth, about twice as steep (facet 217
  where Photoshop shows 188).
- Inner Hard's ramp runs from half a pixel beyond the edge to size + ½,
  so the facet is flat right up to the edge pixel; Inner Soft starts a
  pixel further out (a steeper edge pixel) and is not blurred; Outer
  Hard ends a pixel further out; Outer Soft is a pixel shorter with a
  steeper edge pixel. Outer chisel bevels also shade the pixel where
  their ramp reaches zero (it still slopes), as emboss styles already
  did.

All six inner and outer chisel cases are now within 1 level (they were
27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel
are not modelled yet (they keep the blur).

On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the
emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU
parity tests pass. Corpus: every Photoshop chisel oracle improves
(bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel-
hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow
chisel a few points); pass counts unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:43:01 -04:00
fabiodalez-dev dbf9d0d998 i18n(it): translate the dynamic labels the Italian catalog was missing (#3742)
Adds the 1,287 rows the other catalogs have and Italian lacked (filter and adjustment parameters and choices, Camera Raw, Lighting Effects, History and Layers labels, metadata, dialogs), fixes mistranslations in existing rows (Facet, Gradient stops, Save As Preset, Canvas, Spot Color) and uses one term per concept (Predefinito, Traccia, clic, scorciatoia, filmato).

Factors the Korean generated-label check into missing_generated_filter_labels and runs it for Italian too. Adds docs/localization-it.md and updates localization-parity.
2026-10-12 04:38:03 -04:00
FredApps 62f4b9d453 Menus: keep clear of the taskbar when Wayland hides the window position (#3737) (#3739)
* test(ui): scaffold reported menu viewport coverage (#3737)

* chore: opt FredApps into public profile credit

* Menus keep clear of the taskbar when the window position is unknown (Wayland) (#3737)

On Wayland winit can't report a window's position, so egui has no
inner_rect/outer_rect, only the monitor size. work_area::visible_part
then returned the whole window, and fit_window never maximized an
oversized window, so long menus (Layer, View, Window...) were sized to
the full window and ran under a bottom panel. Without a position, cut
the window to the monitor size less the taskbar (the most that can be
visible wherever the window is), and use the content size as the
outer size for fit_window.

Replaces the scaffold test with one that fails on main.

---------

Signed-off-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-10-12 04:33:54 -04:00
Giuseppe 986f977a01 Fix: video frames to layers working using folder dialog request (#3513) (#3718) 2026-10-12 04:33:48 -04:00
UnemarsandClaude Opus 5.5 986f68f969 Toolbar: the colour chips sit right under the tools again (#3745)
Since the shared docking migration (#3097) the tool column's scroll area
filled the toolbar's whole height, so a window taller than the tools
pushed the colour chips, Quick Mask and Screen Mode to the bottom.
Photoshop keeps them right under the last tool. The scroll area now
shrinks to its tools and only a column taller than the window scrolls,
with the chips kept at the bottom as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:33:36 -04:00
403e321034 Select: open the Save Selection and Load Selection dialogs (#3738)
The menu items ran the engine commands with no params: Save Selection
silently saved into a new alpha channel and Load Selection failed with
"missing `channel`". The form dialogs already existed in view_cmds but
the two ids were missing from wraps(), so they were never shown. Load
Selection now only offers the layer mask when the active layer has one.

Fixes #3713

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:33:25 -04:00
FredApps 4cf8a5fd05 Canvas: wheel and pinch zoom over the pasteboard keep the image in view (#3720) (#3731)
With the pointer on the gray pasteboard, wheel/Alt-wheel/pinch zoom anchored on the pointer,
so each notch slid the image away from it and out of the window. The anchor is now the
nearest point of the image (clamped in document space, so rotated and flipped views work);
over the image nothing changes.
2026-10-12 04:33:12 -04:00
MrCloudy2andClaude Opus 5.5 97c6769d6b Glows: Photoshop's precise ramp, and centre-source glows inverted after the contour (#3578)
Measured on Photoshop 27.11 (scripted: a 128 px square with one glow,
sampled pixel by pixel across its edges; outer and inner, both
techniques, sizes 10 and 20, spread 0/40 %, choke 0/30 %, Range 50 %):

- A Precise glow is 1 - D / (size + 1) beyond the solid spread/choke
  part, D being the centre-to-centre distance to the shape's edge.
  PhotoCraft's ramp ended at `size` and the default Range then doubled
  it, so it stayed solid for half the size and fell off steeply: up to
  122 levels off. The raw coverage is now 0.5 at the edge falling to 0
  at size + 1, so the shared Range step (v / range) gives Photoshop's
  ramp at the default 50 %; other Range values are unmeasured.
- A centre-source inner glow is exactly 1 - the edge glow, taken after
  the contour and Range. PhotoCraft inverted before them: the softer
  centre glow was 115 levels off, the precise one 64.

All 16 glow cases are now within 2 levels (10 were 58 to 122 off);
softer edge and outer glows are unchanged. On the GPU: the same ramp in
fs_mglow, and an "invert after the contour" mode (p0.y = 2) in
fs_mfinish for glows only (Satin keeps its own invert). The 44 GPU
parity tests pass. Corpus: the two Photoshop precise-glow oracles go
from 0.31 / 0.37 to 0.05 worst error (curved anti-aliased shapes,
still not under 2/255); nothing else changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:32:33 -04:00
MrCloudy2andClaude Opus 5.5 78c611007b PSD: alpha channel resources count the merged transparency, 13-byte DisplayInfo (#3577)
Photoshop 27.11 refused two of PhotoCraft's re-exported corpus files
(ag-psd layer-mask/src.psd, psd-tools effects/stroke-without-vector-mask)
with "Could not import the clipboard because an unexpected end-of-file
was encountered". Both carry transparency in the merged image plus an
alpha channel: PhotoCraft wrote two extra channels but named and
described only one in resources 1006, 1045 and 1077. In every corpus
file that has them (177, Photoshop's included), those resources hold one
entry per extra channel, the merged transparency first.

- Export: a "Transparency" entry leads the names and DisplayInfo when
  the merged image carries transparency and there are alpha channels.
- DisplayInfo (1077) records are 13 bytes, as in all 166 corpus files
  that have one; PhotoCraft wrote and read 14 (the older 1007 layout),
  which shifts every channel after the first.
- Import skips the transparency's entry, so an alpha channel keeps its
  own name and options ("Alpha 1", not "Transparency").

Corpus oracles and round trips are unchanged (io 148/170, psd-tools
237/309, Photoshop 137/258; every round trip renders the same).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:22:10 -04:00
dac995ee6c Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437) (#3590)
* Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437)

In Skew mode a corner drag scaled the box and the side handles moved
freely. Checked in Photoshop: a corner moves alone, horizontally or
vertically (whichever way the drag mostly goes); the top and bottom
handles slide only horizontally and the left and right ones only
vertically; Option moves the opposite corner or edge the other way. On
editable type a corner slides its whole edge instead, so the text stays a
parallelogram and remains live type. Free Transform is unchanged.

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

* Skew mode: a drag outside the box doesn't rotate it; ⇧ changes nothing (#3437)

Checked in Photoshop. A click outside still commits.

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 04:18:33 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 8c6e4a3abc fix(engine): image.canvasSize rejects unlisted anchors instead of landing between two (#2946) (#3572)
anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.

Match the anchor exactly against the nine documented values via one ANCHORS table;
anything else is a BadParams error naming `anchor` and listing them, as #507 settled
for other command params. The nine valid values keep the exact factors they had, and an
omitted anchor still means center.

Test covers the nine offsets, the omitted default, and the misspellings from the issue
("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE").

Fixes #2946.

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
2026-10-12 04:13:20 -04:00
Nigel Ihza Firdausy 84aa8df05a fix(transform): keep nearest-neighbor previews on original texels (#3688) 2026-10-12 04:10:55 -04:00
David MannandClaude Opus 5.5 b623db1ed7 Crop tool: a resized frame snaps its handle, not the pointer (#3638) (#3640)
With Auto Center Preview off, or in Classic Mode, dragging a crop edge or
corner snapped the pointer, which grabs the handle up to the 8 px grab
radius away from it. The snap line showed on the target while the frame
edge stopped as far from it as the grab offset, so recropping toward the
pixels a Delete Cropped Pixels-off crop kept left a transparent strip
along the top and sides (or cut a few pixels off).

The crop handle snap (snap_ui::crop_handle) now covers every resize: it
snaps the handle of the frame crop_ui would make and moves the pointer by
the difference, 1:1 without Auto Center Preview and holding the ratio on
a corner as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:10:50 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 55b3301384 test(ui): marquee shift+alt before the drag intersects the selection (#208) (#3624)
The marquee already picks the selection mode from the modifier held at the press
(shift adds, alt subtracts, shift+alt intersects) rather than constraining the shape.
marquee_tests.rs covered add and subtract but not intersect; this adds that case,
the one gap BUG-208-4 still names.

The test fixes an existing selection, holds shift+alt at the press (then releases so
the drag stays a plain rectangle, isolating the mode from the square/centre shape
constraints) and drags a second rectangle. It samples three points -- one in the
overlap, one only in the first rectangle, one only in the dragged rectangle -- so it
passes for intersect and fails for replace, add or subtract.

Test only; no production change.

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
2026-10-12 04:10:45 -04:00
Scott C Hughes 6ca04e5afa fix(ui): one eliding label for fixed-width chrome, so translations can't overrun (#3580) (#3616)
A translated label beside a fixed-width control could run over its neighbours: the Layers
Lock/Fill row guarded only the redundant "Lock:" caption, so "Fill:" ("Заливка:" in Russian,
longer still as pt-br "Preenchimento:" / fr "Remplissage :") overlapped the lock icons.

Rather than a per-row fit check, add the one shared primitive for fixed chrome:
`widgets::elided_label(ui, text, max_w)` lays the text out no wider than its room, cuts it with
'…' and puts the full string on hover (built on the existing `tab_strip::elided`). The
Opacity/Fill scrub labels now elide to the room beside their field, so any translation shrinks
instead of overlapping, in every locale.

Documented the rule in docs/ui-design.md: wrap by default; beside a fixed control, elide; a
redundant caption may drop on a narrow dock (the lock icons carry the meaning).

Test: widgets::tests::elided_label_never_exceeds_its_room. Full photocraft-ui-egui suite: 2077
pass; fmt and clippy clean.
2026-10-12 04:10:40 -04:00
Valentin KleinandClaude Opus 5.5 c7c6031254 Options bar: the Gradient tool's controls show in every theme (#3601) (#3607)
Studio, Studio Light and Classic showed only the hint 'Drag to draw a
gradient'. The Gradient arm was gated by t.pro; it now runs in every theme.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:10:35 -04:00
Reynold Hu d2b5b144aa test(automation): six rounding-out acceptance tasks (31-36) (#3568) 2026-10-12 04:03:30 -04:00
Nigel Ihza Firdausy c5a9e7f9b2 fix(ui): don't reopen floating Brush Settings on restart (#3570) 2026-10-12 04:03:07 -04:00
Reynold Hu 857a4f7d8a M11: ten more MCP acceptance tasks (11-20) for FILE-216-6 (#3562)
* test(automation): ten more MCP acceptance tasks (11-20)

* docs(scorecard): FILE-216-6 note counts 20 tasks
2026-10-12 04:00:30 -04:00
Atos LinsandBrandon Thomas da3da0942c Linux: StartupWMClass is the window's app id (#3481)
* Linux: StartupWMClass is the window's app id

The desktop entry said StartupWMClass=photocraft, but the window carries
the app id ai.storyteller.photocraft (APP_ID) on Wayland and under X11
alike, so nothing that matches a window to its launcher by that key ever
matched it. VectorCraft, GridCraft and WordCraft already use the app id.

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 03:28:09 -04:00
dbca08cd20 Add Vietnamese localization (#2636)
Adds the Vietnamese (vi) UI translation and turns on its coverage gate. A maintainer pass merged main, added 1,029 rows from @tonguyenducmanh's catalog (#3453) and 33 new ones, and set complete_menus.

Co-authored-by: tonguyenducmanh <63176793+tonguyenducmanh@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 03:05:18 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 649b1d5d92 psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542)
* psd: harden the standalone binary parsers (gradients, swatches, patterns, filter effects, metadata, resources, tagged blocks)

Correctness and robustness work on the psd crate's auxiliary parsers, with
fuzz targets and property tests so the invariants are enforced, not just asserted.

grd (gradients):
- Split num()/finite() into a single guard with a saturating f64->f32 cast: only
  NaN has no value, everything else (including +/-inf and 1e300) saturates to the
  representable range so the mapping stays monotonic instead of snapping to a field
  default and inverting a hostile value.
- Hue now wraps with rem_euclid(360) instead of clamping (370 -> 10, not 370 -> 360,
  which would be a different colour at the boundary).
- Every colour model is bounded: RGB/CMYK/Gray to 0..=1, L* to 0..=100 and a*/b* to
  the CIELAB gamut (-128..=127), so a downstream converter never sees out-of-gamut input.

aco/ase (swatches):
- Share one io::utf16_units() helper that caps a name by UTF-16 code units without
  ever splitting a surrogate pair; keeping it in one place stops the fix from drifting
  between the two formats.

Coverage:
- New fuzz targets for grd, aco, ase, patterns, filter_effects, metadata, tagged and
  resources (wired into the fuzz CI matrix).
- Property tests mirroring each fuzz invariant (round-trip stability, no panic on
  arbitrary bytes, documented colour/number ranges) so normal CI covers them; the
  fuzz jobs run on demand.

369 psd tests pass; clippy -D warnings and rustfmt clean; layering and wasm check pass;
all psd fuzz targets build and run clean.

* psd: address Gemini/Claude review of the parser hardening

Follow-up to the previous commit, applying the substantive points from two
independent code reviews (Gemini 3.8: ship; Claude: ship after small fixes).

Production:
- grd hue: an in-range value (0..=360, including exactly 360) is now kept verbatim;
  only an out-of-range hue wraps with rem_euclid. Wrapping unconditionally altered a
  legal 360 to 0 (and f32 round-off on a tiny negative could itself land on 360),
  changing the colour at the boundary for anything that interpolates in HSB.
- ase: compile-time assert that MAX_NAME_UNITS < u16::MAX, since put_name writes the
  length as `units.len() as u16 + 1`; guards the invariant if the cap is ever raised.

Stronger test/fuzz invariants (byte stability alone would pass even if the writer
silently dropped a field; these assert semantic equality):
- metadata fuzz: assert the parsed-then-written block re-parses to the same items
  (the weakened byte-only check was the fix the first fuzz run surfaced).
- patterns fuzz: assert parse(write(parse(x))) == parse(x); channel offsets/padding
  are wire artifacts, not fields, so the round trip is lossless (confirmed, 2M execs).
- filter_effects fuzz: assert back == fx as well as byte stability; also decode every
  cached slot, not just the mask.
- resources fuzz: round-trip ResolutionInfo through to_bytes/from_bytes.

Coverage:
- tagged fuzz: select the block key from a table of every typed/strict key via the
  first byte, so the fuzzer reaches each branch instead of guessing a 4-byte key.
- new property tests: damage-a-valid-block for metadata/patterns/filter_effects (pure
  random bytes rarely pass a header), and check_structure over every structural key.
- grd_every_colour_model_is_clamped_and_finite now pins exact saturation values
  (a*/b* to +/-128, L* to 0/100, units to 0/1), so a weaker clamp is caught, not just
  an out-of-range value; grd_hue_wraps now pins the exact-360 and 0 boundaries.

Verified: 371 psd tests pass; clippy -D warnings, rustfmt, layers and wasm clean; all
13 fuzz targets build and run clean (metadata 11.5M, patterns 2M, filter_effects 5.9M,
resources 3.5M, tagged 5.4M execs); the a*/b* clamp-weakening and hue-guard-removal
mutants are both killed by the strengthened tests.

---------

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
2026-10-12 02:49:08 -04:00
Valentin KleinandClaude Opus 5.5 35b5b8ceb0 Format: index ZIP entries by name and use crc32fast (#3474) (#3488)
ZipReader::find scanned every entry per lookup, and a bundle load looks
up one entry per tile reference, so loads were O(refs x entries). Build
a name -> index map once in ZipReader::new (first entry wins for
duplicate names, as the scan did) and look names up through it.

crc32 now delegates to crc32fast (already in Cargo.lock via flate2)
instead of a byte-at-a-time table loop.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:42:40 -04:00
Dan HiornsandClaude Opus 5.5 20cb95bc27 Windows installer: refuse Windows 7 and 8 with a clear message (#2310) (#3261)
The Rust standard library has needed Windows 10 since 1.78, so on
Windows 7 and 8 PhotoCraft fails at launch with "The procedure entry
point ProcessPrng could not be located in the dynamic link library
bcryptprimitives.dll" (#2310). The MSI installed anyway.

It now has a launch condition: a registry search sets WINDOWS10ORLATER
from HKLM\...\Windows NT\CurrentVersion\CurrentMajorVersionNumber,
which exists only from Windows 10 on (in the 32- and 64-bit views), and
without it the installer stops with "PhotoCraft needs Windows 10 or
later." Repair and uninstall still run (Installed). MSI's VersionNT
isn't used: it has reported both 603 and 1000 on Windows 10.

The README's Windows downloads now say Windows 10 or later is required;
the portable zip can't check, as Windows rejects the exe before any of
its code runs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:26:36 -04:00
6a852f8852 fix(ui): correct the clipping-mask indicator orientation (#3037)
* fix(ui): correct the clipping-mask indicator orientation

* Drop the before/after screenshots from the repository

Docs screenshots stay out of the tree; the PR description keeps them.

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 02:10:18 -04:00
Dan HiornsandClaude Opus 5.5 cb1c06fc57 GPU memory budget: read installed RAM on Windows (#3189)
physical_memory() had no Windows implementation, so the wgpu
compositor's budget capped layer textures at a quarter of an assumed
16 GB on every Windows machine: too little on a large one (4 GB of a
62 GB PC's 8 GB Memory Usage allowance) and too much on an 8 GB laptop
whose integrated GPU shares that RAM. Read it with GlobalMemoryStatusEx
through winsafe, the safe binding the desktop app already uses (same
pinned version, kernel feature only).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:43:06 -04:00
f14817a5d2 Animated GIF: every frame loads into video layers (#572) (#2999)
* feat(codecs): decode every frame of an animated GIF (#572)

decode_gif_frames returns each frame composited onto the logical screen
(disposal methods applied) with its delay in milliseconds. decode still
keeps the first frame only. A damaged later frame keeps the frames before
it and marks the first with DecodeWarning::Truncated; dimensions, the
total decoded bytes and a frame cap are checked against the limits.

* feat(io): import_gif_frames hands each GIF frame over as a document (#572)

A frame at a time becomes a single-layer document, so a caller can
flatten and drop it instead of holding every frame twice.

* feat(engine): animated GIFs load every frame into video layers (#572)

New Video Layer from File, Replace Footage, Reload Frame and Video Frames
to Layers take all frames of a GIF instead of the first. The timeline rate
comes from the GIF's delays (the most common one, delays under 20 ms shown
at 100 ms as browsers do); an explicit fps still wins.

* docs: animated GIF frames in video layers (#572)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:26:37 -04:00
skylar taylor-barrick 61b6276c78 Security: harden document exports and automation inputs (#2269)
* chore: clear strict Clippy simplifications

* security: harden web exports and generated image assets

* security: reject redirected directory bundle writes

* security: quote paths in droplet shell launchers

* security: use private ephemeral print spool files

* security: bound automation file reads and MCP input

* security: bound MCP transport requests before dispatch

* chore: keep current sample checks Clippy clean

* security: preserve safe export roots and print semantics

* security: clear non-sending print state before replay

* security: forget print send overrides between jobs

* chore: keep macOS UI tests and Clippy clean

* Test web export paths portably on Windows
2026-10-11 23:18:52 -06:00
Sir Starch b4a949225d Support automatic monitor ICC profiles on Linux X11 (#2963)
* Linux X11: detect and apply monitor ICC profiles

* Test Linux monitor ICC profile handling

* Gate display detection test to supported platforms
2026-10-12 01:17:59 -04:00
Sir Starch e8c7656150 Use each XRandR output's ICC profile in the screen picker (#2962)
* Screen picker: use each XRandR output's ICC profile

* Test X11 screen picker ICC validation
2026-10-12 01:09:03 -04:00
Sir Starch 82c58fabab Use Fontconfig for Linux font discovery (#2330) (#2961)
* Fix #2330: discover installed fonts through Fontconfig

* Fix wasm cfg for Fontconfig fallback

* Add Linux font discovery regression tests

* Reject Fontconfig matches without CJK coverage
2026-10-12 01:05:56 -04:00
5e0a6d404d Tools: Shift key switches tools by default, as in Photoshop (#3525) (#3528)
* Tools: Shift key switches tools by default, as in Photoshop (#3525)

Use Shift Key for Tool Switch is on by default in Photoshop: T keeps the
current tool, Shift+T cycles. Saved settings keep their stored value.

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

* Tests: the M-key marquee cycling test sends Shift+M, the new default (#3296)

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 00:54:41 -04:00